chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:04:19 +08:00
commit 46c3330e28
212 changed files with 65282 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Desktop Pet Skin System
## 快速开始
运行桌面宠物:
```bash
python3 desktop_pet_v2.pyw
```
## 功能特性
### 1. 多皮肤支持
- 自动发现 `skins/` 目录下的所有皮肤
- 右键菜单切换皮肤
- 支持 sprite sheet 和 GIF 两种格式
### 2. 多动画状态
- **idle** - 待机动画
- **walk** - 行走动画
- **run** - 跑步动画
- **sprint** - 冲刺动画
右键菜单可切换动画状态
### 3. 交互功能
- **单击** - 拖动宠物
- **双击** - 关闭程序
- **右键** - 打开菜单(切换皮肤/动画)
### 4. HTTP 远程控制
```bash
# 显示消息
curl "http://127.0.0.1:51983/?msg=Hello"
# 切换动画状态
curl "http://127.0.0.1:51983/?state=run"
# POST 消息
curl -X POST -d "任务完成" http://127.0.0.1:51983/
```
## 添加新皮肤
### 目录结构
```
skins/
└── your-skin-name/
├── skin.json # 配置文件(必需)
├── idle.png # 动画资源
├── walk.png
├── run.png
└── sprint.png
```
### skin.json 配置示例
#### Sprite Sheet 格式(推荐)
```json
{
"name": "My Pet",
"version": "1.0.0",
"author": "Your Name",
"description": "描述",
"format": "sprite",
"animations": {
"idle": {
"file": "idle.png",
"loop": true,
"sprite": {
"frameWidth": 44,
"frameHeight": 31,
"frameCount": 6,
"columns": 6,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "walk.png",
"loop": true,
"sprite": {
"frameWidth": 65,
"frameHeight": 32,
"frameCount": 8,
"columns": 8,
"fps": 8,
"startFrame": 0
}
}
}
}
```
#### GIF 格式
```json
{
"name": "My Pet",
"format": "gif",
"animations": {
"idle": {
"file": "idle.gif",
"loop": true
},
"walk": {
"file": "walk.gif",
"loop": true
}
}
}
```
### 配置说明
- **frameWidth/frameHeight**: 单帧尺寸(像素)
- **frameCount**: 帧数
- **columns**: sprite sheet 的列数
- **fps**: 播放帧率
- **startFrame**: 起始帧索引(从 0 开始)
### Sprite Sheet 布局
```
+-------+-------+-------+-------+
| 帧0 | 帧1 | 帧2 | 帧3 | ← 第一行
+-------+-------+-------+-------+
| 帧4 | 帧5 | 帧6 | 帧7 | ← 第二行
+-------+-------+-------+-------+
```
如果 `columns=4, startFrame=2, frameCount=3`,则读取:帧2, 帧3, 帧4
## 已包含的皮肤
1. **Glube** - 像素风小怪兽(多文件 sprite)
2. **Vita** - 像素风小恐龙(单文件 sprite)
3. **Doux** - 像素风小恐龙(单文件 sprite)
## 从 ai-bubu 导入更多皮肤
ai-bubu 项目包含更多皮肤资源,可以直接复制:
```bash
# 复制皮肤
cp -r ai-bubu-main/packages/app/public/skins/boy frontends/skins/
cp -r ai-bubu-main/packages/app/public/skins/dinosaur frontends/skins/
cp -r ai-bubu-main/packages/app/public/skins/line frontends/skins/
cp -r ai-bubu-main/packages/app/public/skins/mort frontends/skins/
cp -r ai-bubu-main/packages/app/public/skins/tard frontends/skins/
```
## 与 stapp.py 集成
`stapp.py` 中点击"🐱 桌面宠物"按钮会自动启动桌面宠物,并在每个 turn 结束时发送通知。
## 故障排查
### 皮肤不显示
1. 检查 `skin.json` 格式是否正确
2. 确认图片文件存在
3. 检查 sprite 配置参数是否匹配图片尺寸
### 动画不流畅
- 调整 `fps` 参数
- 检查帧数是否正确
### 透明背景问题
- 确保 PNG 文件包含 alpha 通道
- 使用 RGBA 模式的图片
## 技术细节
- 基于 Tkinter + PIL/Pillow
- 支持透明背景(#01FF01 色键)
- 窗口置顶、无边框
- HTTP 服务器端口:51983
+272
View File
@@ -0,0 +1,272 @@
"""@ file completion — shared UI-less logic for tui_v2 / tui_v3.
File index (os.scandir, cached per root) + fuzzy match + @token detection +
insert text. No UI deps; each front-end renders candidates its own way and
calls candidates_for(query, root). Index root is the front-end's choice
(session workspace, else CWD). Submit-time: completion-only does NOT read
content, but absolutize_mentions() rewrites @relative → @absolute so the
agent's file_read (relative to its own cwd) can locate the file. The
content-injecting auto-read variant lives in
temp/plan_v2_at_mention/autoread_version.py.
"""
import os
import re
import threading
# ---------------------------------------------------------------- index
_IGNORE_DIRS = {
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
".next", ".idea", ".vscode", "target", ".cache", ".eggs",
"model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选
}
_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"}
_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停
def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]:
"""Collect relative file paths under root, '/'-normalized.
os.scandir over os.walk: one syscall yields is_dir without an extra
stat per entry. Dotted dirs are skipped wholesale (.git, .venv...).
"""
out: list[str] = []
stack = [root]
while stack and len(out) < max_files:
d = stack.pop()
try:
with os.scandir(d) as it:
for e in it:
try:
if e.is_dir(follow_symlinks=False):
if e.name not in _IGNORE_DIRS and not e.name.startswith("."):
stack.append(e.path)
elif e.is_file(follow_symlinks=False):
if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT:
rel = os.path.relpath(e.path, root).replace("\\", "/")
out.append(rel)
if len(out) >= max_files:
return out
except OSError:
continue
except OSError:
continue
return out
class FileIndexCache:
"""Per-root background file index. warm() is idempotent-cheap: a
rebuild is only started when none is in flight."""
def __init__(self, root: str):
self.root = root
self._files: list[str] = []
self._lock = threading.Lock()
self._building = False
self.ready = threading.Event()
def warm(self) -> None:
with self._lock:
if self._building:
return
self._building = True
def _build():
try:
files = scan_files(self.root)
with self._lock:
self._files = files
self.ready.set()
finally:
with self._lock:
self._building = False
threading.Thread(target=_build, name="ga-at-index", daemon=True).start()
def snapshot(self) -> list[str]:
with self._lock:
return self._files
_indexes: dict[str, FileIndexCache] = {}
_indexes_lk = threading.Lock()
def get_index(root: str) -> FileIndexCache:
key = os.path.normcase(os.path.realpath(root or os.getcwd()))
with _indexes_lk:
idx = _indexes.get(key)
if idx is None:
idx = _indexes[key] = FileIndexCache(root)
return idx
# ---------------------------------------------------------------- fuzzy
def _subseq_score(q: str, path: str):
"""Subsequence match score (higher = better), None when q doesn't
fully appear in order. Contiguous runs dominate (fzf-style): scattered
one-char hits across a long path must not beat a tight cluster.
Word-boundary hits and basename substring add on top; ties broken by
caller on shorter path."""
if not q:
return 0
score, qi, prev_hit = 0, 0, -2
for pi, ch in enumerate(path):
if qi < len(q) and ch == q[qi]:
score += 1
if pi == prev_hit + 1:
score += 2 # contiguous run: the dominant signal
if pi == 0 or path[pi - 1] in "/\\_-. ":
score += 3
prev_hit = pi
qi += 1
if qi < len(q):
return None
base = path.rsplit("/", 1)[-1]
if q in base:
score += 8
elif q in path:
score += 4
return score
def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]:
q = query.lower()
if not q:
# bare `@`: surface shallow paths first for discoverability
return sorted(files, key=lambda f: (f.count("/"), f))[:limit]
scored = []
for f in files:
s = _subseq_score(q, f.lower())
if s is not None:
scored.append((s, f))
scored.sort(key=lambda x: (-x[0], len(x[1]), x[1]))
return [f for _, f in scored[:limit]]
# ------------------------------------------------------- edit-time token
# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。
# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。
_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE)
def find_at_token(line_before_cursor: str):
"""Return (query, at_pos) when the cursor sits in an @token being
typed on this line, else None. at_pos is the index of '@'."""
m = _AT_TOKEN_RE.search(line_before_cursor)
if not m:
return None
tok = m.group(1)
return tok[1:], m.start(1)
def format_pick(path: str) -> str:
"""`@path` insert text; dirs get no trailing space (keep completing next
level), files get one (close token). Spaces → quoted."""
trailing = '' if path.endswith(('/', '\\')) else ' '
return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}'
# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes
# to live directory completion instead of index fuzzy — this is how absolute
# paths outside the index root get completed level by level (claude-code parity).
def is_path_like(token: str) -> bool:
if token in ('~', '.', '..'):
return True
if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')):
return True
return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\'
def path_completions(token: str, root: str, limit: int = 15) -> list[str]:
"""readdir the real dir of a path-like token, prefix-match, dirs first.
`~` expanded, relative → root, absolute as-is; candidates keep the token's
spelling, dirs carry a trailing '/'."""
sep = max(token.rfind('/'), token.rfind('\\'))
if sep >= 0:
dir_part, prefix = token[:sep + 1], token[sep + 1:]
elif token in ('~', '.', '..'):
dir_part, prefix = token.rstrip('/\\') + '/', ''
else:
return []
exp = os.path.expanduser(dir_part)
real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp)
try:
with os.scandir(real_dir) as it:
entries = list(it)
except OSError:
return []
pl = prefix.lower()
rows = []
for e in entries:
nm = e.name
if pl and not nm.lower().startswith(pl):
continue
if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出
continue
try:
is_dir = e.is_dir()
except OSError:
is_dir = False
rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else '')))
rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序
return [d for _, _, d in rows[:limit]]
def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]:
"""@token candidates: path-like → directory completion, else index fuzzy.
Single dispatch point shared by both front-ends. `absolute=True` returns
fuzzy hits as absolute paths (front-end shows full path when no workspace
is bound, since the relative root isn't obvious to the user)."""
if is_path_like(query):
return path_completions(query, root, limit)
idx = get_index(root)
files = idx.snapshot()
if not files:
idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有)
res = fuzzy_rank(query, files, limit) if files else []
if absolute:
res = [os.path.normpath(os.path.join(root, c)) for c in res]
return res
# ------------------------------------------------------ submit-time absolutize
# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but
# the agent's file_read resolves relative to its own ./temp cwd — so a bare
# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a
# real file to an absolute path; display keeps the short form. Still no content
# read — this only completes the path so the agent can locate it.
_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE)
_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$')
def absolutize_mentions(text: str, root: str) -> str:
"""@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a
space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative
@words / typos pass through unchanged."""
def repl(m):
lead, quoted, bare = m.group(1), m.group(3), m.group(4)
raw = quoted if quoted is not None else bare
trail = ''
if quoted is None: # strip trailing prose punctuation
stripped = raw.rstrip(',。,;)]》>')
trail, raw = raw[len(stripped):], stripped
sm = _LINE_SUFFIX_RE.search(raw)
suffix = sm.group(1) if sm else ''
path = raw[:len(raw) - len(suffix)] if suffix else raw
if not path:
return m.group(0)
exp = os.path.expanduser(path)
absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp))
if not os.path.exists(absp): # decorative / typo → leave as-is
return m.group(0)
full = absp + suffix
token = f'@"{full}"' if ' ' in full else f'@{full}'
return lead + token + trail
return _AT_ABS_RE.sub(repl, text)
+142
View File
@@ -0,0 +1,142 @@
"""`/btw` 命令:side question — 不打断主 Agent 的临时 subagent 问答。
- 持锁 deepcopy backend.history → 后台线程 backend.raw_ask 单次拉答
- 主 agent backend.history 零写入;不入 task_queue
- 答案 → display_queue 'done'install 路径)或同步 returnfrontend 路径)
复用 backend.raw_ask + make_messages,不新建 LLM 实例。
"""
from __future__ import annotations
import copy, os, threading, time
from typing import Optional
_WRAPPER_ZH = """<system-reminder>
这是用户的临时插问 (side question)。主 agent 仍在后台运行,**不会被打断**。
身份与边界:
- 你是一个独立的轻量 sub-agent
- 上下文里能看到主 agent 与用户的完整对话、最近的工具调用与结果
- 用户在问当前进展或顺便确认某事——基于已有信息**一次性**作答
- 没有任何工具可用:不要"让我查一下" / "我去试试" / 任何承诺动作
- 信息不足就坦白说"基于目前对话我不知道"
侧问内容如下:
</system-reminder>
{question}"""
_WRAPPER_EN = """<system-reminder>
This is a side question from the user. The main agent is NOT interrupted — it continues in the background.
Identity & boundaries:
- You are an independent lightweight sub-agent
- You can see the full conversation between the main agent and the user, plus recent tool calls/results
- The user is asking about current progress or a quick aside — answer in **one shot** from existing info
- You have NO tools — never say "let me check" / "I'll try" / any action promise
- If info is missing, just say "based on the conversation I don't know"
Question:
</system-reminder>
{question}"""
_TIMEOUT_SEC = 120
def _wrapper(): return _WRAPPER_EN if os.environ.get('GA_LANG') == 'en' else _WRAPPER_ZH
def _strip_cmd(query):
s = (query or '').strip()
return s[len('/btw'):].strip() if s.startswith('/btw') else s
def _help_text():
return ('**/btw 用法**side question — 临时问主 agent 当前进展,不打断主线\n\n'
'`/btw <你的问题>`\n\n'
'行为:抓取当前对话上下文 → 单轮纯文本作答(无工具)→ 主 agent 历史不变。')
def _snapshot_history(backend):
"""Lock + deepcopy: defends against concurrent compress_history_tags mutating inner blocks."""
with backend.lock:
return copy.deepcopy(list(backend.history))
def _build_wire(backend, history, sidequest_msg):
"""history + sidequest → wire-format. Dispatches: BaseSession subclasses → make_messages,
Native* → raw pairs (raw_ask runs _fix/_drop/_ensure transforms itself)."""
msgs = history + [sidequest_msg]
if hasattr(backend, 'make_messages'):
return backend.make_messages(msgs)
return [{"role": m["role"], "content": list(m.get("content", []))} for m in msgs]
def _ask(agent, question, deadline):
"""One-shot raw_ask against current backend; never mutates backend.history."""
backend = agent.llmclient.backend
user_msg = {"role": "user",
"content": [{"type": "text", "text": _wrapper().format(question=question)}]}
wire = _build_wire(backend, _snapshot_history(backend), user_msg)
text = ''
for chunk in backend.raw_ask(wire):
text += chunk
if time.time() > deadline:
return text + '\n\n⚠️ /btw 超时,仅返回部分回复。'
return text
def _format(question, body, took):
head = f'> 🟡 /btw {question}\n\n'
return head + (body.strip() or '*(空回复)*') + f'\n\n*({took:.1f}s)*'
def _run(agent, question, deadline):
"""Catches errors at the boundary so neither caller path needs its own try/except."""
try: return _ask(agent, question, deadline)
except Exception as e: return f'❌ /btw 失败: {type(e).__name__}: {e}'
def handle(agent, query, display_queue) -> Optional[str]:
"""Slash-cmd entry (server-side, install path). Spawn worker; return None to consume."""
question = _strip_cmd(query)
if not question or question in ('help', '?', '-h', '--help'):
display_queue.put({'done': _help_text(), 'source': 'system'})
return None
started = time.time()
deadline = started + _TIMEOUT_SEC
def worker():
body = _run(agent, question, deadline)
display_queue.put({'done': _format(question, body, time.time() - started), 'source': 'system'})
threading.Thread(target=worker, daemon=True, name='btw-sidequest').start()
return None
def handle_frontend_command(agent, query) -> str:
"""Sync entry for frontends wanting a string back (tg/wx/stapp/...)."""
question = _strip_cmd(query)
if not question or question in ('help', '?', '-h', '--help'):
return _help_text()
started = time.time()
body = _run(agent, question, started + _TIMEOUT_SEC)
return _format(question, body, time.time() - started)
def install(cls):
"""Idempotent monkey-patch: intercept /btw before original dispatch."""
orig = cls._handle_slash_cmd
if getattr(orig, '_btw_patched', False): return
def patched(self, raw_query, display_queue):
s = (raw_query or '').strip()
if s == '/btw' or s.startswith('/btw ') or s.startswith('/btw\t'):
r = handle(self, raw_query, display_queue)
if r is None: return None
return r
return orig(self, raw_query, display_queue)
patched._btw_patched = True
cls._handle_slash_cmd = patched
Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

+352
View File
@@ -0,0 +1,352 @@
import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time
# 确保能导入上级目录的模块(如 agentmain)
_parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _parent_dir not in sys.path:
sys.path.insert(0, _parent_dir)
HELP_COMMANDS = (
("/help", "显示帮助"),
("/status", "查看状态"),
("/stop", "停止当前任务"),
("/new", "开启新对话并清空当前上下文"),
("/restore", "恢复上次对话历史"),
("/continue", "列出可恢复会话"),
("/continue [n]", "恢复第 n 个会话"),
("/btw <q>", "side question — 临时插问主 agent 进展,不打断主线"),
("/review [scope]", "in-session code review; 默认审当前 git diff"),
("/llm", "查看当前模型列表"),
("/llm [n]", "切换到第 n 个模型"),
)
TELEGRAM_MENU_COMMANDS = (
("help", "显示帮助"),
("status", "查看状态"),
("stop", "停止当前任务"),
("new", "开启新对话并清空当前上下文"),
("restore", "恢复上次对话历史"),
("continue", "列出可恢复会话;/continue n 恢复第 n 个"),
("btw", "临时插问主 agent 进展,不打断主线"),
("review", "in-session code review/review scope 指定范围"),
("llm", "查看模型列表;/llm n 切换到指定模型"),
)
def build_help_text(commands=HELP_COMMANDS):
return "📖 命令列表:\n" + "\n".join(f"{cmd} - {desc}" for cmd, desc in commands)
HELP_TEXT = build_help_text()
FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RESTORE_GLOBS = (
os.path.join(PROJECT_ROOT, "temp", "model_responses", "model_responses_*.txt"),
os.path.join(PROJECT_ROOT, "temp", "model_responses_*.txt"),
)
RESTORE_BLOCK_RE = re.compile(
r"^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)",
re.DOTALL | re.MULTILINE,
)
HISTORY_RE = re.compile(r"<history>\s*(.*?)\s*</history>", re.DOTALL)
SUMMARY_RE = re.compile(r"<summary>\s*(.*?)\s*</summary>", re.DOTALL)
def clean_reply(text):
for pat in TAG_PATS:
text = re.sub(pat, "", text or "", flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
def extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def split_text(text, limit):
text, parts = (text or "").strip() or "...", []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit * 0.6:
cut = limit
parts.append(text[:cut].rstrip())
text = text[cut:].lstrip()
return parts + ([text] if text else []) or ["..."]
def _restore_log_files():
files = []
for pattern in RESTORE_GLOBS:
files.extend(glob.glob(pattern))
return sorted(set(files))
def _restore_text_pairs(content):
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
restored = []
for u, r in zip(users, resps):
u, r = u.strip(), r.strip()[:500]
if u and r:
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
return restored
def _native_prompt_obj(prompt_body):
try:
prompt = json.loads(prompt_body)
except Exception:
return None
if not isinstance(prompt, dict) or prompt.get("role") != "user":
return None
if not isinstance(prompt.get("content"), list):
return None
return prompt
def _native_prompt_text(prompt):
texts = []
for block in prompt.get("content", []):
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str) and text.strip():
texts.append(text)
return "\n".join(texts).strip()
def _native_history_lines(prompt_text):
match = HISTORY_RE.search(prompt_text or "")
if not match:
return []
restored = []
for line in match.group(1).splitlines():
line = line.strip()
if line.startswith("[USER]: ") or line.startswith("[Agent] "):
restored.append(line)
return restored
def _native_first_user_line(prompt_text):
text = (prompt_text or "").strip()
if not text or "<history>" in text or text.startswith("### [WORKING MEMORY]"):
return ""
if text.startswith(FILE_HINT):
text = text[len(FILE_HINT):].lstrip()
if "### 用户当前消息" in text:
text = text.split("### 用户当前消息", 1)[-1].strip()
return text
def _native_response_summary(response_body):
try:
blocks = ast.literal_eval((response_body or "").strip())
except Exception:
return ""
if not isinstance(blocks, list):
return ""
text_parts = []
for block in blocks:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str) and text:
text_parts.append(text)
match = SUMMARY_RE.search("\n".join(text_parts))
return (match.group(1).strip() if match else "")[:500]
def _restore_native_history(content):
blocks = RESTORE_BLOCK_RE.findall(content or "")
if not blocks:
return []
pairs = []
pending_prompt = None
for label, body in blocks:
if label == "Prompt":
pending_prompt = body
elif pending_prompt is not None:
pairs.append((pending_prompt, body))
pending_prompt = None
for prompt_body, response_body in reversed(pairs):
prompt = _native_prompt_obj(prompt_body)
if prompt is None:
continue
prompt_text = _native_prompt_text(prompt)
restored = list(_native_history_lines(prompt_text))
if restored:
summary = _native_response_summary(response_body)
summary_line = f"[Agent] {summary}" if summary else ""
if summary_line and (not restored or restored[-1] != summary_line):
restored.append(summary_line)
return restored
user_text = _native_first_user_line(prompt_text)
summary = _native_response_summary(response_body)
if user_text and summary:
return [f"[USER]: {user_text}", f"[Agent] {summary}"]
return []
def format_restore():
files = _restore_log_files()
if not files:
return None, "❌ 没有找到历史记录"
latest = max(files, key=os.path.getmtime)
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
restored = _restore_text_pairs(content) or _restore_native_history(content)
if not restored:
return None, "❌ 历史记录里没有可恢复内容"
count = sum(1 for line in restored if line.startswith("[USER]: "))
return (restored, os.path.basename(latest), count), None
def build_done_text(raw_text):
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
body = strip_files(clean_reply(raw_text))
if files:
body = (body + "\n\n" if body else "") + "\n".join(f"生成文件: {p}" for p in files)
return body or "..."
def public_access(allowed):
return not allowed or "*" in allowed
def to_allowed_set(value):
if value is None:
return set()
if isinstance(value, str):
value = [value]
return {str(x).strip() for x in value if str(x).strip()}
def allowed_label(allowed):
return "public" if public_access(allowed) else sorted(allowed)
def ensure_single_instance(port, label):
try:
lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lock_sock.bind(("127.0.0.1", port))
return lock_sock
except OSError:
print(f"[{label}] Another instance is already running, skipping...")
sys.exit(1)
def require_runtime(agent, label, **required):
missing = [k for k, v in required.items() if not v]
if missing:
print(f"[{label}] ERROR: please set {', '.join(missing)} in mykey.py or mykey.json")
sys.exit(1)
if agent.llmclient is None:
print(f"[{label}] ERROR: no usable LLM backend found in mykey.py or mykey.json")
sys.exit(1)
def redirect_log(script_file, log_name, label, allowed):
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(script_file))), "temp")
os.makedirs(log_dir, exist_ok=True)
logf = open(os.path.join(log_dir, log_name), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = logf
print(f"[NEW] {label} process starting, the above are history infos ...")
print(f"[{label}] allow list: {allowed_label(allowed)}")
class AgentChatMixin:
label = "Chat"
source = "chat"
split_limit = 1500
ping_interval = 20
def __init__(self, agent, user_tasks):
self.agent, self.user_tasks = agent, user_tasks
async def send_text(self, chat_id, content, **ctx):
raise NotImplementedError
async def send_done(self, chat_id, raw_text, **ctx):
await self.send_text(chat_id, build_done_text(raw_text), **ctx)
async def handle_command(self, chat_id, cmd, **ctx):
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/help":
return await self.send_text(chat_id, HELP_TEXT, **ctx)
if op == "/stop":
state = self.user_tasks.get(chat_id)
if state:
state["running"] = False
self.agent.abort()
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
if op == "/status":
llm = self.agent.get_llm_name() if self.agent.llmclient else "未配置"
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if self.agent.is_running else '🟢 空闲'}\nLLM: [{self.agent.llm_no}] {llm}", **ctx)
if op == "/llm":
if not self.agent.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
if len(parts) > 1:
try:
self.agent.next_llm(int(parts[1]))
return await self.send_text(chat_id, f"✅ 已切换到 [{self.agent.llm_no}] {self.agent.get_llm_name()}", **ctx)
except Exception:
return await self.send_text(chat_id, f"用法: /llm <0-{len(self.agent.list_llms()) - 1}>", **ctx)
lines = [f"{'' if cur else ' '} [{i}] {name}" for i, name, cur in self.agent.list_llms()]
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
if op == "/restore":
try:
restored_info, err = format_restore()
if err:
return await self.send_text(chat_id, err, **ctx)
restored, fname, count = restored_info
self.agent.abort()
self.agent.history.extend(restored)
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
except Exception as e:
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
if op == "/continue":
return await self.send_text(chat_id, _handle_continue_frontend(self.agent, cmd), **ctx)
if op == "/new":
return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx)
if op == "/btw":
answer = await asyncio.to_thread(_handle_btw_frontend, self.agent, cmd)
return await self.send_text(chat_id, answer, **ctx)
if op == "/review":
return await self.run_agent(chat_id, cmd, **ctx)
return await self.send_text(chat_id, HELP_TEXT, **ctx)
async def run_agent(self, chat_id, text, **ctx):
state = {"running": True}
self.user_tasks[chat_id] = state
try:
await self.send_text(chat_id, "思考中...", **ctx)
dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
last_ping = time.time()
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if self.agent.is_running and time.time() - last_ping > self.ping_interval:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""), **ctx)
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
except Exception as e:
import traceback
print(f"[{self.label}] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
finally:
self.user_tasks.pop(chat_id, None)
from agentmain import GeneraticAgent as _GA
from continue_cmd import handle_frontend_command as _handle_continue_frontend, install as _install_continue, reset_conversation as _reset_conversation
_install_continue(_GA)
from btw_cmd import handle_frontend_command as _handle_btw_frontend, install as _install_btw; _install_btw(_GA)
from review_cmd import install as _install_review; _install_review(_GA)
+513
View File
@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Conductor</title>
<style>
:root {
--bg: #f5f7fb;
--panel: rgba(255,255,255,0.78);
--panel-2: rgba(255,255,255,0.92);
--border: rgba(31,42,68,0.10);
--text: #172033;
--muted: #7a8497;
--soft: #43506a;
--accent: #3b82f6;
--accent-2: #8b5cf6;
--green: #10b981;
--amber: #f59e0b;
--red: #ef4444;
--gray: #9ca3af;
--shadow: 0 18px 55px rgba(30, 45, 80, .11);
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
overflow: hidden;
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(900px 600px at 12% 8%, rgba(59, 130, 246, .14), transparent 54%),
radial-gradient(800px 650px at 86% 18%, rgba(139, 92, 246, .12), transparent 58%),
radial-gradient(650px 520px at 61% 94%, rgba(16, 185, 129, .10), transparent 60%),
linear-gradient(180deg, #fbfcff, var(--bg));
}
.app {
height: 100vh;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.app-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(340px, 40%) 1fr;
gap: 18px;
}
.pane {
min-height: 0;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
backdrop-filter: blur(18px);
border-radius: 26px;
box-shadow: var(--shadow);
overflow: hidden;
}
.left-col { min-height: 0; display: flex; flex-direction: column; gap: 18px; }
.left-col > .pane.left { flex: 1; min-height: 0; }
.left, .right { display: flex; flex-direction: column; min-height: 0; }
.right { background: transparent; border: 0; box-shadow: none; backdrop-filter: none; gap: 18px; }
.head {
padding: 18px 20px 13px;
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid rgba(31,42,68,.075);
background: rgba(255,255,255,.34);
}
.title { font-size: 17px; letter-spacing: .2px; font-weight: 700; }
.hint { color: var(--muted); font-size: 12px; }
.cards {
flex: 1;
min-height: 0;
padding: 14px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.cards::-webkit-scrollbar, .messages::-webkit-scrollbar,
.console-body::-webkit-scrollbar, .approvals-body::-webkit-scrollbar { width: 8px; }
.cards::-webkit-scrollbar-thumb, .messages::-webkit-scrollbar-thumb,
.console-body::-webkit-scrollbar-thumb, .approvals-body::-webkit-scrollbar-thumb {
background: rgba(31,42,68,.16);
border-radius: 10px;
}
.approvals-body::-webkit-scrollbar { width: 10px; }
.approvals-body::-webkit-scrollbar-track { background: rgba(31,42,68,.08); border-radius: 10px; }
.approvals-body::-webkit-scrollbar-thumb { background: rgba(31,42,68,.34); border-radius: 10px; border: 2px solid rgba(255,255,255,.65); }
.empty { color: var(--muted); font-size: 13px; padding: 8px 4px; }
/* ---------- subagent 卡片(可折叠) ---------- */
.card {
position: relative;
padding: 13px 14px 12px 16px;
border-radius: 18px;
background: rgba(255,255,255,.76);
border: 1px solid rgba(31,42,68,.10);
box-shadow: 0 8px 24px rgba(30,45,80,.055);
cursor: pointer;
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.card:hover {
transform: translateY(-1px);
background: rgba(255,255,255,.95);
border-color: rgba(59,130,246,.24);
box-shadow: 0 12px 30px rgba(30,45,80,.08);
}
.card::before {
content: "";
position: absolute;
left: 0; top: 14px; bottom: 14px;
width: 4px;
border-radius: 99px;
background: var(--state);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 84%);
}
.card.running { --state: var(--green); }
.card.waiting { --state: var(--amber); }
.card.stopped { --state: var(--gray); }
.card.failed { --state: var(--red); }
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 7px;
}
.dot-text { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--state);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 86%); }
.chev { color: var(--muted); font-size: 11px; transition: transform .2s ease; }
.card.expanded .chev { transform: rotate(180deg); }
.prompt {
color: #24314d; font-size: 13px; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.card.expanded .prompt { -webkit-line-clamp: unset; margin-bottom: 11px; }
.card .reply { display: none; }
.card.expanded .reply {
display: block;
color: var(--soft); font-size: 13px; line-height: 1.4;
background: rgba(245,247,251,.78); border: 1px solid rgba(31,42,68,.07);
border-radius: 14px; padding: 11px 12px; max-height: 220px; overflow-y: auto; word-wrap: break-word;
}
.reply p, .reply ul, .reply ol, .reply pre { margin: 0 0 .25em 0; }
.reply p:last-child { margin-bottom: 0; }
.reply code { font-size: 12px; background: rgba(0,0,0,.05); padding: 1px 4px; border-radius: 4px; }
.reply pre { white-space: pre-wrap; }
/* ---------- 任务奏折栏(无事则隐) ---------- */
.approvals { display: none; min-height: 0; overflow: hidden; }
.approvals.show { display: flex; flex: 0 0 420px; height: 420px; max-height: 42vh; flex-direction: column; }
.approvals .head {
background: linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.05));
border-bottom: 1px solid rgba(245,158,11,.18);
}
.approvals .title { display: inline-flex; align-items: center; gap: 8px; }
.badge {
font-size: 11px; font-weight: 800; color: #fff;
background: var(--amber); border-radius: 99px; padding: 2px 9px;
box-shadow: 0 4px 12px rgba(245,158,11,.35);
}
.approvals-body { flex: 1; min-height: 0; padding: 13px; display: flex; flex-direction: column; gap: 11px; overflow-y: scroll; overflow-x: hidden; scrollbar-gutter: stable; }
.approval {
flex: 0 0 auto;
border-radius: 16px; background: rgba(255,255,255,.8);
border: 1px solid rgba(245,158,11,.25);
box-shadow: 0 8px 22px rgba(245,158,11,.10);
overflow: hidden;
}
.approval-bar { padding: 12px 14px; cursor: pointer; display: flex; gap: 9px; align-items: center; }
.approval-tag { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); font-weight: 800; }
.approval-sum { flex: 1; font-size: 13px; color: #24314d; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.approval .body { display: none; padding: 0 14px 14px; }
.approval.expanded .body { display: block; }
.approval .src { font-size: 12px; color: var(--muted); margin-bottom: 8px; line-height: 1.4; }
.approval textarea {
width: 100%; min-height: 88px; resize: vertical;
border: 1px solid rgba(31,42,68,.14); border-radius: 12px;
padding: 10px 12px; font: inherit; font-size: 13px; line-height: 1.45;
background: rgba(255,255,255,.95); color: var(--text); outline: none;
}
.approval textarea:focus { border-color: rgba(245,158,11,.5); box-shadow: 0 0 0 4px rgba(245,158,11,.10); }
.approval-acts { display: flex; gap: 10px; margin-top: 11px; }
.approval-acts button { height: 40px; min-width: 0; flex: 1; border-radius: 13px; font-size: 14px; font-weight: 750; }
.btn-ok { background: linear-gradient(135deg, #10b981, #059669); box-shadow: 0 8px 20px rgba(16,185,129,.25); }
.btn-no { background: linear-gradient(135deg, #f87171, #ef4444); box-shadow: 0 8px 20px rgba(239,68,68,.22); }
/* ---------- 聊天框 ---------- */
.chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.messages { flex: 1; overflow: auto; padding: 20px; display: flex; flex-direction: column; gap: 13px; }
.msg { max-width: 80%; padding: 13px 15px; border-radius: 18px; line-height: 1.45; font-size: 14px;
word-wrap: break-word; box-shadow: 0 8px 22px rgba(30,45,80,.055); }
.msg p { margin: 0 0 .3em 0; } .msg p:last-child { margin-bottom: 0; }
.msg.me { align-self: flex-end; color: white;
background: linear-gradient(135deg, #3b82f6, #7c3aed); border: 1px solid rgba(59,130,246,.20);
border-bottom-right-radius: 7px; }
.msg.me.unread { box-shadow: 0 0 0 2px #f59e0b, 0 8px 22px rgba(30,45,80,.055); }
.msg.me.unread::after { content: '未读'; display: block; margin-top: 6px; font-size: 11px; opacity: .85; color: #fbbf24; }
.msg.ai { align-self: flex-start; background: rgba(255,255,255,.88);
border: 1px solid rgba(31,42,68,.10); border-bottom-left-radius: 7px; }
.msg.system { align-self: center; max-width: 88%; color: var(--muted); font-size: 12px;
padding: 7px 11px; border-radius: 99px; background: rgba(255,255,255,.70);
border: 1px solid rgba(31,42,68,.08); box-shadow: none; }
.composer { padding: 14px; border-top: 1px solid rgba(31,42,68,.075); display: flex; gap: 12px;
align-items: end; background: rgba(255,255,255,.40); }
textarea#input { flex: 1; height: 52px; max-height: 140px; resize: none; outline: none; color: var(--text);
background: rgba(255,255,255,.92); border: 1px solid rgba(31,42,68,.12); border-radius: 18px;
padding: 14px 15px; font: inherit; line-height: 1.35; }
textarea#input:focus { border-color: rgba(59,130,246,.44); box-shadow: 0 0 0 4px rgba(59,130,246,.10); }
button { height: 52px; min-width: 92px; border: 0; border-radius: 18px; color: white; font-weight: 750;
cursor: pointer; background: linear-gradient(135deg, #3b82f6, #10b981);
box-shadow: 0 10px 26px rgba(59,130,246,.22); }
/* ---------- conductor 输出(横跨底部的独立卡片,默认收起) ---------- */
.console {
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.console-bar {
padding: 13px 20px; cursor: pointer; display: flex; align-items: center; justify-content: space-between;
background: rgba(255,255,255,.34); user-select: none;
}
.console-bar:hover { background: rgba(255,255,255,.6); }
.console-bar .t { font-size: 13px; font-weight: 700; color: var(--soft); display: inline-flex; align-items: center; gap: 8px; }
.console-bar .chev2 { color: var(--muted); font-size: 12px; transition: transform .2s ease; }
.console.open .console-bar .chev2 { transform: rotate(180deg); }
.console-body {
display: none;
max-height: 42vh; overflow-y: auto; overflow-x: hidden;
background: #0f1626; color: #cdd6e6;
padding: 14px 16px; font-family: "SF Mono", Consolas, monospace; font-size: 12px; line-height: 1.55;
white-space: normal; word-break: break-word; overflow-wrap: anywhere;
}
.console.open .console-body { display: block; }
.log-rec { padding: 8px 0; border-bottom: 1px dashed rgba(255,255,255,.08); min-width: 0; }
.log-rec:last-child { border-bottom: 0; }
.log-rec :is(p, ul, ol, pre, table) { margin: 0 0 .4em 0; max-width: 100%; }
.log-rec :is(h1, h2, h3, h4, h5, h6) {
font-size: 12px; font-weight: 700; color: #93c5fd;
margin: .5em 0 .25em 0; line-height: 1.4; border: 0; padding: 0;
}
.log-rec pre { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;
background: rgba(255,255,255,.05); border-radius: 8px; padding: 8px 10px; overflow-x: auto; }
.log-rec code { word-break: break-word; overflow-wrap: anywhere; }
.log-rec table { display: block; overflow-x: auto; }
.log-evt { color: #7dd3fc; }
.log-ts { color: #64748b; }
@media (max-width: 900px) {
.app { padding: 12px; }
.app-grid { grid-template-columns: 1fr; grid-template-rows: 38% 1fr; }
.msg { max-width: 92%; }
}
</style>
</head>
<body>
<main class="app">
<div class="app-grid">
<!-- 左列:subagent 卡片 + conductor 输出 -->
<div class="left-col">
<!-- subagent 卡片 -->
<section class="pane left">
<div class="head">
<div class="title">Subagents</div>
<div class="hint">点击卡片展开 · live</div>
</div>
<div class="cards" id="cards">
<div class="empty">暂无 subagent。</div>
</div>
</section>
<!-- conductor 输出:左列底部独立卡片,系统级,默认收起 -->
<section class="console" id="console">
<div class="console-bar" id="consoleBar">
<span class="t">🧠 Conductor 输出 <span class="hint" id="logCount"></span></span>
<span class="chev2"></span>
</div>
<div class="console-body" id="consoleBody"></div>
</section>
</div><!-- /left-col -->
<!-- 右:待批任务 + 聊天 -->
<section class="right">
<!-- 待批任务(无事则隐) -->
<section class="pane approvals" id="approvals">
<div class="head">
<div class="title">待批任务 <span class="badge" id="apprBadge">0</span></div>
<div class="hint">conductor 拟派 subagent · 待你批复</div>
</div>
<div class="approvals-body" id="approvalsBody"></div>
</section>
<!-- 聊天 -->
<section class="pane chat">
<div class="head">
<div class="title">Conversation</div>
<div class="hint" id="wsStatus">connecting...</div>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<textarea id="input" placeholder="告诉 Conductor 你想做什么……"></textarea>
<button id="send">发送</button>
</div>
</section>
</section>
</div><!-- /app-grid -->
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const cardsEl = document.getElementById('cards');
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const wsStatus = document.getElementById('wsStatus');
const apprEl = document.getElementById('approvals');
const apprBody = document.getElementById('approvalsBody');
const apprBadge = document.getElementById('apprBadge');
const consoleEl = document.getElementById('console');
const consoleBar = document.getElementById('consoleBar');
const consoleBody= document.getElementById('consoleBody');
const logCount = document.getElementById('logCount');
let ws;
function esc(s){ return String(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function renderMd(s){ try { return marked.parse(s||'', { breaks: true, gfm: true }); } catch(e){ return esc(s); } }
/* ---------- subagent 卡片 ---------- */
function renderCards(items){
if(!items || !items.length){ cardsEl.innerHTML = '<div class="empty">暂无 subagent。</div>'; return; }
// 重建前记录展开状态(按卡片顺序索引),避免刷新后用户手动展开的卡片被收起
const expandedIdx = new Set();
cardsEl.querySelectorAll('.card').forEach((c,i)=>{ if(c.classList.contains('expanded')) expandedIdx.add(i); });
cardsEl.innerHTML = items.map((it,i)=>{
const status = it.status || 'stopped';
return `<article class="card ${esc(status)}${expandedIdx.has(i)?' expanded':''}">
<div class="status-row">
<span class="dot-text"><span class="dot"></span>${esc(status)}</span>
<span class="chev">▾</span>
</div>
<div class="prompt">${esc(it.prompt)}</div>
<div class="reply">${renderMd(it.reply || '等待输出……')}</div>
</article>`;
}).join('');
}
cardsEl.addEventListener('click', e=>{
const card = e.target.closest('.card'); if(card) card.classList.toggle('expanded');
});
/* ---------- 任务奏折 ---------- */
function apprHtml(it){ return `
<div class="approval" data-id="${esc(it.id)}">
<div class="approval-bar">
<span class="approval-tag">拟派</span>
<span class="approval-sum">${esc(it.prompt)}</span>
<span class="chev">▾</span>
</div>
<div class="body">
<div class="src">触发:${esc(it.source || '外部事件')}</div>
<textarea class="appr-prompt">${esc(it.prompt)}</textarea>
<div class="approval-acts">
<button class="btn-ok" data-act="ok">同意派发</button>
<button class="btn-no" data-act="no">否决</button>
</div>
</div>
</div>`; }
function addApproval(it){
apprEl.classList.add('show');
apprBody.insertAdjacentHTML('beforeend', apprHtml(it));
apprBadge.textContent = apprBody.querySelectorAll('.approval').length;
}
apprBody.addEventListener('click', e=>{
const bar = e.target.closest('.approval-bar');
if(bar){ bar.parentElement.classList.toggle('expanded'); return; }
const btn = e.target.closest('button[data-act]');
if(btn){
const card = btn.closest('.approval');
const act = btn.dataset.act;
const prompt = card.querySelector('.appr-prompt').value;
if(act==='ok') fetch('/subagent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt})});
card.remove();
const left = apprBody.querySelectorAll('.approval').length;
apprBadge.textContent = left;
if(!left) apprEl.classList.remove('show');
}
});
/* ---------- 聊天 ---------- */
function addMsg(item){
const role = item.role || 'system';
const cls = role==='user' ? 'me' : (role==='conductor' ? 'ai' : 'system');
const div = document.createElement('div');
div.className = `msg ${cls}` + (role==='user' && !item.read ? ' unread' : '');
if(item.id) div.dataset.id = item.id;
div.innerHTML = renderMd(item.msg || '');
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function markAllRead(){ messagesEl.querySelectorAll('.msg.me.unread').forEach(el=>el.classList.remove('unread')); }
/* ---------- conductor 输出 ---------- */
function fmtTs(ms){ try{ return new Date(ms).toLocaleTimeString('zh-CN',{hour12:false}); }catch(e){ return ''; } }
function logItemHtml(r){
return `<div class="log-rec"><span class="log-ts">[${fmtTs(r.ts)}]</span> <span class="log-evt">${esc(r.event)} · t${r.turn}</span>${renderMd(r.text)}</div>`;
}
function renderLog(items){ // 全量重建(hello 快照)
items = items || [];
logCount.textContent = items.length ? `· ${items.length}` : '';
consoleBody.innerHTML = items.map(logItemHtml).join('');
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
}
function appendLog(item){ // 增量追加一轮(完整 markdown)
if(!item) return;
consoleBody.insertAdjacentHTML('beforeend', logItemHtml(item));
logCount.textContent = `· ${consoleBody.querySelectorAll('.log-rec').length}`;
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
}
consoleBar.addEventListener('click', ()=>{
consoleEl.classList.toggle('open');
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
});
/* ---------- WS ---------- */
function connect(){
const proto = location.protocol==='https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = ()=> wsStatus.textContent = 'connected';
ws.onclose = ()=>{ wsStatus.textContent = 'disconnected · retrying'; setTimeout(connect,1200); };
ws.onerror = ()=> wsStatus.textContent = 'error';
ws.onmessage = ev=>{
const data = JSON.parse(ev.data);
if(data.type==='hello'){
renderCards(data.subagents||[]);
messagesEl.innerHTML=''; (data.chat||[]).forEach(addMsg);
renderLog(data.log||[]);
} else if(data.type==='subagents'){ renderCards(data.items||[]); }
else if(data.type==='chat'){ addMsg(data.item); }
else if(data.type==='chat_read'){ markAllRead(); }
else if(data.type==='log'){ appendLog(data.item); }
else if(data.type==='approval'){ addApproval(data.item); }
};
}
function send(){
const msg = inputEl.value.trim();
if(!msg || !ws || ws.readyState!==WebSocket.OPEN) return;
ws.send(JSON.stringify({msg})); inputEl.value=''; inputEl.focus();
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', e=>{ if(e.key==='Enter' && !e.shiftKey && !e.isComposing && e.keyCode!==229){ e.preventDefault(); send(); } });
// Input length limit (10000 chars)
const MAX_LEN = 10000;
let limitToast = null;
function showLimitToast(msg) {
if (limitToast) return; // already showing, don't spam
limitToast = document.createElement('div');
limitToast.textContent = msg;
limitToast.style.cssText = 'position:fixed;top:24px;left:50%;transform:translateX(-50%);background:#dc2626;color:#fff;padding:10px 24px;border-radius:8px;font-size:14px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s';
document.body.appendChild(limitToast);
setTimeout(() => {
if (limitToast) { limitToast.style.opacity = '0'; }
setTimeout(() => { if (limitToast) { limitToast.remove(); limitToast = null; } }, 300);
}, 3000);
}
function checkLimit() {
if (inputEl.value.length > MAX_LEN) {
inputEl.value = inputEl.value.slice(0, MAX_LEN);
showLimitToast('已达字数上限(' + MAX_LEN + '');
}
}
inputEl.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
const remaining = MAX_LEN - inputEl.value.length + (inputEl.selectionEnd - inputEl.selectionStart);
const insert = text.slice(0, Math.max(0, remaining));
const start = inputEl.selectionStart;
const end = inputEl.selectionEnd;
inputEl.value = inputEl.value.slice(0, start) + insert + inputEl.value.slice(end);
inputEl.setSelectionRange(start + insert.length, start + insert.length);
if (text.length > remaining) {
showLimitToast('已达字数上限(' + MAX_LEN + '');
}
});
inputEl.addEventListener('input', checkLimit);
/* 接通后端: hello 推送 cards/chat/log 快照, approval 由 /approval 推送 */
connect();
</script>
</body>
</html>
+537
View File
@@ -0,0 +1,537 @@
import os, sys, re, time, json, uuid, queue, asyncio, threading
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, List
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path: sys.path.insert(0, ROOT)
from agentmain import GenericAgent
HOST = "127.0.0.1"
PORT = 8900
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
def _desktop_llm_no() -> Optional[int]:
"""Read the model index the user picked in the desktop UI.
Persisted by the bridge at ~/.ga_desktop_settings.json under ui.llmNo.
Returns None when unavailable, so callers keep the agent's default model."""
try:
from pathlib import Path
doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8"))
no = (doc.get("ui") or {}).get("llmNo")
return int(no) if no is not None else None
except Exception:
return None
def _apply_desktop_model(agent: "GenericAgent") -> None:
"""Switch a freshly built agent to the desktop-selected model (if any)."""
no = _desktop_llm_no()
if no is None:
return
try:
agent.next_llm(int(no))
except Exception as e:
print(f"[conductor] failed to apply desktop model #{no}: {e}", file=sys.stderr)
def _select_llm(agent: "GenericAgent", llm: Any) -> bool:
if llm is None or str(llm).strip() == "": return False
q = str(llm).strip()
if isinstance(llm, int) or q.isdigit(): agent.next_llm(int(q)); return True
q = q.lower(); agent.load_llm_sessions()
for i, c in enumerate(agent.llmclients):
vals = []
for fn in (lambda: agent.get_llm_name(c), lambda: agent.get_llm_name(c, model=True), lambda: c.backend.name, lambda: c.backend.model):
try: vals.append(str(fn()).lower())
except Exception: pass
if any(q in v for v in vals): agent.next_llm(i); return True
raise ValueError(f"llm not found: {llm}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# 服务启动(事件循环已就绪):捕获 loop 供工作线程跨线程推 WS 广播,并起主agent
global main_loop
main_loop = asyncio.get_running_loop()
import cost_tracker; cost_tracker.install()
conductor.start()
threading.Thread(target=im_poll_loop, name="im-poller", daemon=True).start()
yield
app = FastAPI(title="Conductor", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class ChatIn(BaseModel):
msg: str
role: str = "conductor" # conductor | system | user
class StartSubagentIn(BaseModel):
prompt: str
llm: Any = None
class ApprovalIn(BaseModel):
prompt: str
source: str = ""
class SubagentActionIn(BaseModel):
action: str = "intervene" # intervene | abort | kill
msg: str = ""
llm: Any = None
@dataclass
class SubAgentState:
id: str
agent: GenericAgent
prompt: str
thread: Optional[threading.Thread] = None
reply: str = ""
status: str = "running" # running | stopped
created_at: int = field(default_factory=lambda: int(time.time()))
updated_at: int = field(default_factory=lambda: int(time.time()))
ws_clients: set[WebSocket] = set()
main_loop: Optional[asyncio.AbstractEventLoop] = None
# conductor event queue: only user messages and subagent-done events enter here.
chat_messages: List[dict] = []
def now_ms() -> int:
return int(time.time() * 1000)
def short_id() -> str:
return uuid.uuid4().hex[:8]
_TURN_SPLIT_RE = re.compile(r'\**LLM Running \(Turn \d+\) \.\.\.\**')
_SUMMARY_RE = re.compile(r'<summary>(.*?)</summary>\s*', re.DOTALL)
def extract_last_summary(full: str) -> str:
"""Extract the latest <summary> content for in-progress display."""
matches = _SUMMARY_RE.findall(full or "")
if not matches: return ""
s = matches[-1].strip()
return s[-1000:] if len(s) > 1000 else s
def extract_last_text_reply(full: str) -> str:
"""Extract only the last turn's text reply (like stapp.py fold_turns logic)."""
# Split by turn markers, take last segment
parts = _TURN_SPLIT_RE.split(full)
last = parts[-1] if parts else full
# Strip <summary> tags
last = _SUMMARY_RE.sub('', last)
# Strip [Status] and [Info] lines
last = re.sub(r'\[(Status|Info)\][^\n]*\n?', '', last)
# Strip trailing whitespace
last = last.strip()
# Cap length
return last[-3000:] if len(last) > 3000 else last
def clean_log_text(s: str) -> str:
if not s: return s
s = re.sub(r'`{5}\n.*?`{5}\n?', '', s, flags=re.DOTALL)
s = re.sub(r'🛠️ Tool: `([^`]+)`\s*📥 args:\n`{4}.*?`{4}\n?', r'🛠️ `\1`\n', s, flags=re.DOTALL)
s = re.sub(r'^🛠️ .*\n?', '', s, flags=re.MULTILINE) # remove tool call summary lines
s = re.sub(r'<thinking>.*?</thinking>\s*', '', s, flags=re.DOTALL)
s = re.sub(r'^\s*\[(?:Info|Status)\][^\n]*\n?', '', s, flags=re.MULTILINE)
s = re.sub(r'^\s*`{4,5}\s*$\n?', '', s, flags=re.MULTILINE)
s = re.sub(r'\n{3,}', '\n\n', s)
return s.strip()
def schedule_broadcast(payload: dict):
if main_loop and main_loop.is_running():
asyncio.run_coroutine_threadsafe(broadcast(payload), main_loop)
async def broadcast(payload: dict):
dead = []
for ws in list(ws_clients):
try: await ws.send_json(payload)
except Exception: dead.append(ws)
for ws in dead: ws_clients.discard(ws)
def push_cards(): schedule_broadcast({"type": "subagents", "items": pool.snapshot()})
def add_chat(msg: str, role: str = "conductor", files: list = None, images: list = None):
item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user", "files": files or [], "images": images or []}
chat_messages.append(item)
if len(chat_messages) > 200: del chat_messages[:-200]
schedule_broadcast({"type": "chat", "item": item})
return item
def start_agent_runner(agent: GenericAgent, name: str):
t = threading.Thread(target=agent.run, name=name, daemon=True)
t.start(); return t
def monitor_display_queue(agent_id: str, dq: "queue.Queue", trigger_when_done: bool):
acc = ""
while True:
item = dq.get()
if "next" in item:
chunk = item.get("next") or ""
acc += chunk
pool.on_display(agent_id, acc, done=False)
push_cards()
if "done" in item:
done = item.get("done") or acc
pool.on_display(agent_id, done, done=True)
push_cards()
if trigger_when_done: conductor.notify({"type": "subagent_done", "id": agent_id, "reply": done})
break
class SubagentPool:
def __init__(self):
self.subagents: Dict[str, SubAgentState] = {}
self.lock = threading.RLock()
threading.Thread(target=self._auto_cleanup_loop, name="subagent-cleanup", daemon=True).start()
def snapshot(self) -> list[dict]:
with self.lock:
return [
{
"id": s.id,
"prompt": s.prompt,
"reply": (extract_last_summary(s.reply) if s.status == "running" else extract_last_text_reply(s.reply)) if s.reply else "",
"status": s.status,
"created_at": s.created_at,
"updated_at": s.updated_at,
}
for s in self.subagents.values()
]
def get(self, sid: str) -> Optional[SubAgentState]:
with self.lock: return self.subagents.get(sid)
def counts(self) -> tuple:
with self.lock:
running = sum(1 for s in self.subagents.values() if s.status == "running")
stopped = sum(1 for s in self.subagents.values() if s.status != "running")
return running, stopped
def on_display(self, agent_id: str, acc: str, done: bool):
with self.lock:
s = self.subagents.get(agent_id)
if s:
s.reply = acc
s.updated_at = int(time.time())
s.status = "stopped" if done else "running"
def _auto_cleanup_loop(self):
IDLE_TIMEOUT = 3600
while True:
time.sleep(300)
now = time.time()
to_abort = []
with self.lock:
for sid, s in self.subagents.items():
if s.status == "stopped" and (now - s.updated_at) > IDLE_TIMEOUT: to_abort.append((sid, s))
for sid, s in to_abort:
s.agent.abort()
s.agent.task_queue.put("EXIT")
with self.lock: self.subagents.pop(sid, None)
if to_abort: push_cards()
def start_subagent(self, prompt: str, llm: Any = None) -> dict:
sid = short_id()
agent = GenericAgent()
agent.inc_out = True
agent.verbose = False
agent.no_print = True
if not _select_llm(agent, llm): _apply_desktop_model(agent)
th = start_agent_runner(agent, f"subagent-{sid}")
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running", thread=th)
with self.lock: self.subagents[sid] = state
return self._send_msg(sid, prompt)
def _send_msg(self, sid, msg):
with self.lock: s = self.subagents.get(sid)
if not s: return {"error": "subagent not found", "id": sid}
dq = s.agent.put_task(msg, source=f"subagent:{sid}")
threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True).start()
push_cards()
return {"id": sid, "status": "running"}
def input_subagent(self, sid: str, msg: str, llm: Any = None) -> dict:
with self.lock: s = self.subagents.get(sid)
if not s: return {"error": "subagent not found", "id": sid}
if s.status == "running": return {"error": "subagent is still running, cannot input/reply. Start a new subagent instead.", "id": sid}
_select_llm(s.agent, llm)
s.prompt = msg
s.reply = ""
s.status = "running"
s.updated_at = int(time.time())
return self._send_msg(sid, msg)
def keyinfo_subagent(self, sid: str, msg: str) -> dict:
with self.lock: s = self.subagents.get(sid)
if not s: return {"error": "subagent not found", "id": sid}
h = s.agent.handler
h.working['key_info'] = h.working.get('key_info', '') + f"\n[MASTER] {msg}"
s.updated_at = int(time.time())
return {"id": sid, "status": "keyinfo_injected"}
pool = SubagentPool()
READMES = {
"api": f"""\
Conductor API\tBase: http://{HOST}:{PORT}
POST /chat\tbody: {{"msg": "..."}}\t给用户发消息
POST /subagent\tbody: {{"prompt": "..."}}\t启动新subagent,返回 {{"id": "xxx"}};指定模型加参数llm(数字/名称)
POST /approval\tbody: {{"prompt": "...", "source": "..."}}\t推一条待批任务到前端(后端不存),用户同意则直接派发为subagent
POST /subagent/{{id}}\tbody: {{"action": "keyinfo", "msg": "..."}}\t注入key_infoagent下轮可见)
POST /subagent/{{id}}\tbody: {{"action": "input", "msg": "..."}}\t开新一轮任务;指定模型加参数llm(数字/名称)
POST /subagent/{{id}}\tbody: {{"action": "stop"}}\t中断执行但保留(可继续input/reply
GET /chat?last=N\t返回最近N条对话(默认20
GET /subagent\t返回 {{"items": [...]}}\t查看所有subagent状态
GET /subagent/{{id}}?max_len=N\t返回单个subagent详情,reply经清洗后截取尾部max_len字(默认5000)。仅在摘要不够判断时使用
""",
"usermsg": """\
用户消息流程:
1. 结合记忆、上下文和用户偏好判断真实需求;不清楚/不能代劳时,用精简checklist一次性问用户。
2. 判断是新任务还是延续现有任务;优先复用已有stopped subagent(用input追加),只有确实无关的新任务才新建。
3. 分派前必须POST /chat告知用户:改写后的prompt + 分派方案(新建/复用哪个subagent)。
4. 执行分派,完成即停。危险操作(改源码/删数据/安全敏感)必须改成先让subagent出方案;你验收后POST /chat请用户确认,确认后才继续执行。""",
"subagent": """\
subagent完成流程:
1. 如果是IM采集subagent,按GET /readme/im进行而非本流程
2. 读subagent输出;若最后一条不足以判断,GET /subagent/{id}?max_len=3000 补足信息。
3. 预测用户是否满意;不满意就reply/keyinfo要求返工、修改、优化,继续监督,不急着报告。
4. 预计用户满意后,POST /chat给简洁交付报告。""",
"im": """\
你要审查IM采集subagent的输出,把**值得用户关注的内容**报告给用户或转化成"可点击执行"的待批TODOapproval)。
先读L2记忆中User相关,推荐的动作和措辞要符合用户画像。
要求:
1. 不要只凭采集摘要;重要事实要核实,需要判断时先派subagent补做必要调查,再下结论。
2. 没有值得用户点击执行的动作就直接结束,不要打扰;尤其不要对执行回执/完成确认/纯闲聊报"无需关注"
3. 判断标准:私聊默认重要,群聊除非@用户否则忽略。
4. 只有真正需要用户的内容才报告或形成TODO。不要推"去看看/研究一下"这种半成品。TODO必须是最后一步可直接执行的动作(发某段微信回复、回复某封邮件草稿、处理某PR、整理某文件等)。
5. 如果形成用户TODOPOST /approval 推送,prompt里同时写清两部分:
① 奏折式报告给用户拍板:背景(什么事/来自谁) + 已核实(你做了哪些调查/关键事实) + 判断(为什么这样建议) + 风险。用户看完这段就能直接拍板,不用再去翻原消息。
② 用户同意后该执行的完整任务指令(approval通过会直接作为subagent的prompt派发,必须具体到可直接执行)。""",
}
class Conductor:
LOG_MAX = 50
def __init__(self):
self.inbox: "queue.Queue[dict]" = queue.Queue() # 收件箱:唯一对外接口
self.agent: Optional[GenericAgent] = None
self.started = False
self.log: list = []
def notify(self, event: dict): self.inbox.put(event)
def _build_prompt(self, events: list) -> str:
running, stopped = pool.counts()
unread = sum(1 for m in chat_messages if m.get("role") == "user" and not m.get("read"))
done_count = sum(1 for e in events if e.get("type") == "subagent_done")
event_type = events[0].get("type") if events else "wake"; im_sources = [e.get("source") for e in events if e.get("type") == "im_signal"]
if event_type == "user_message": summary = f"[用户消息] {unread}条未读用户消息,GET /chat 读取;按GET /readme/usermsg处理。"
elif event_type == "subagent_done": summary = f"[subagent完成] {done_count}个完成报告;GET /subagent 查看并验收;IM subagent完成报告按GET /readme/im处理,其他subagent完成报告按GET /readme/subagent处理。"
elif event_type == "im_signal": summary = f"[IM信号] {', '.join(im_sources)} 有新消息;" + "".join(f"GET /im_prompt/{s}取采集prompt" for s in im_sources) + ";尽量复用已有subagent。"
else: summary = f"[唤醒] subagents: {running} running, {stopped} stopped | {unread}条用户未读消息, {done_count}个subagent完成报告"
base = f"http://{HOST}:{PORT}"
return f"""你是agent总管。用户只和你对话,你负责调度、验收、交付,目标是降低用户管理多个agent的负担。
API: {base}requestsGET /readme查用法,GET /chat读未读对话,GET /subagent看状态;POST /chat是唯一对用户说话方式。
铁律:
- 绝不亲自执行任务/探测环境;一切执行交给subagent。你只分析、派遣、审查、沟通。
- 每次唤醒只做最小必要动作(发消息/开subagent/reply/keyinfo/abort),做完立刻停,等待下次事件唤醒。
- 改写prompt时严禁添加用户未提及的假设、工具、前提条件。只能精炼/结构化用户原意,不能脑补,只能做很小的改写
原则:
- 信任subagent足够聪明,不要写具体步骤和容易探测的信息;能自己判断的自己判断,只在真正需要用户决策时打扰。\n
需要处理:
{summary}"""
def _drain(self, dq: "queue.Queue", events: list) -> str:
event_label = ",".join(e.get("type", "") for e in events) or "wake"
cur_turn = None; buf = ""
def flush():
nonlocal buf
cleaned = clean_log_text(buf)
if cleaned:
item = {"id": short_id(), "ts": now_ms(), "event": event_label,
"turn": cur_turn, "text": cleaned}
self.log.append(item)
if len(self.log) > self.LOG_MAX: self.log.pop(0)
schedule_broadcast({"type": "log", "item": item})
buf = ""
while True:
item = dq.get()
if "next" in item:
t = item.get("turn")
if cur_turn is None: cur_turn = t
elif t != cur_turn:
flush(); cur_turn = t
buf += item.get("next", "") or ""
elif "done" in item:
if cur_turn is None: cur_turn = item.get("turn")
flush()
print("Conductor task done")
return
def _run(self):
self.agent = GenericAgent()
self.agent.inc_out = True
start_agent_runner(self.agent, "conductor-agent")
self.started = True
while True:
# Block until first event arrives
first = self.inbox.get()
self.inbox.task_done()
# Short debounce: collect any additional events that arrived meanwhile
time.sleep(0.3)
events = [first]
while not self.inbox.empty():
try:
events.append(self.inbox.get_nowait())
self.inbox.task_done()
except Exception:
break
try:
prompt = self._build_prompt(events)
# Follow the desktop-selected model live: re-read before each task
# so switching models in the UI takes effect without restarting.
_apply_desktop_model(self.agent)
dq = self.agent.put_task(prompt, source="conductor")
self._drain(dq, events)
except Exception as e: print(f"Conductor error: {e}")
def start(self): threading.Thread(target=self._run, name="conductor-loop", daemon=True).start()
conductor = Conductor()
# ---- IM poller: 探测conductor_im_plugins/下各插件,信号变化→唤醒总管 ----
IM_DIR, IM_COOLDOWN = os.path.join(os.path.dirname(__file__), "conductor_im_plugins"), 300
IM_PROMPTS: Dict[str, str] = {} # source -> 采集prompt(派采集subagent时按需取)
def im_poll_loop():
import importlib.util
mods, last_fire = {}, {}
for f in (x for x in os.listdir(IM_DIR) if x.endswith(".py") and not x.startswith("_")):
spec = importlib.util.spec_from_file_location(f[:-3], os.path.join(IM_DIR, f))
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
if hasattr(m, "check"):
mods[f[:-3]] = m
IM_PROMPTS[f[:-3]] = getattr(m, "PROMPT", "")
last_check = {}
while True:
time.sleep(10)
for name, m in mods.items():
now = time.time()
if now - last_check.get(name, 0) < getattr(m, "INTERVAL", 30): continue
last_check[name] = now
try:
if not m.check() or now - last_fire.get(name, 0) < IM_COOLDOWN: continue
except Exception: continue
last_fire[name] = now
conductor.notify({"type": "im_signal", "source": name})
@app.get("/token-stats")
def conductor_token_stats():
import cost_tracker
return {"records": [{"thread": k, "input": v.input, "output": v.output, "cacheCreate": v.cache_create, "cacheRead": v.cache_read} for k, v in cost_tracker.all_trackers().items()]}
@app.get("/")
def index(): return FileResponse(HTML_PATH)
@app.get("/readme")
def readme(): return PlainTextResponse(READMES["api"])
@app.get("/readme/{topic}")
def readme_topic(topic: str):
if topic not in READMES:
return PlainTextResponse(f"Unknown topic: {topic}. Available: {', '.join(READMES.keys())}", status_code=404)
return PlainTextResponse(READMES[topic])
@app.get("/im_prompt/{source}")
def im_prompt(source: str):
if source not in IM_PROMPTS:
return PlainTextResponse(f"Unknown source: {source}. Available: {', '.join(IM_PROMPTS.keys())}", status_code=404)
return PlainTextResponse(IM_PROMPTS[source])
@app.get("/subagent")
def list_subagents(): return {"items": pool.snapshot()}
@app.get("/subagent/{sid}")
def get_subagent(sid: str, max_len: int = 5000):
s = pool.get(sid)
if not s:
return JSONResponse({"error": "not found"}, status_code=404)
cleaned = clean_log_text(s.reply or "")
return {"id": s.id, "prompt": s.prompt, "status": s.status,
"reply": cleaned[-max_len:] if len(cleaned) > max_len else cleaned,
"created_at": s.created_at, "updated_at": s.updated_at}
INSTR_DISPATCHED = "Task received. I'll handle THIS TASK from here. You MUST to do other task or end your reply."
@app.post("/subagent")
def api_start_subagent(body: StartSubagentIn):
result = pool.start_subagent(body.prompt, body.llm)
result["instruction"] = INSTR_DISPATCHED
return result
@app.post("/subagent/{sid}")
def api_subagent_action(sid: str, body: SubagentActionIn):
s = pool.get(sid)
if not s: return JSONResponse({"error": "subagent not found", "id": sid}, status_code=404)
action = body.action.lower().strip()
if action == "keyinfo":
result = pool.keyinfo_subagent(sid, body.msg)
result["instruction"] = "Received. I'll incorporate this. You MUST to do other task or end your reply."
return result
if action in ("input", "reply", "append", "message", "msg"):
result = pool.input_subagent(sid, body.msg, body.llm)
result["instruction"] = INSTR_DISPATCHED
return result
if action in ("abort", "stop"):
s.agent.abort()
s.status = "stopped"
s.updated_at = int(time.time())
push_cards()
return {"id": sid, "status": "stopped"}
return JSONResponse({"error": f"unknown action: {body.action}"}, status_code=400)
@app.get("/chat")
def api_get_chat(last: int = 20):
items = [m.copy() for m in chat_messages[-last:]]
for m in chat_messages:
if m.get("role") == "user" and not m.get("read"): m["read"] = True
schedule_broadcast({"type": "chat_read"})
return {"items": items}
@app.post("/chat")
def api_chat(body: ChatIn):
return add_chat(body.msg, role=body.role)
@app.post("/approval")
def api_approval(body: ApprovalIn):
schedule_broadcast({"type": "approval", "item": {"id": short_id(), "prompt": body.prompt, "source": body.source}})
return {"ok": True}
@app.websocket("/ws")
async def websocket(ws: WebSocket):
await ws.accept()
ws_clients.add(ws)
try:
running = any(s.status == "running" for s in pool.subagents.values())
await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log, "running": running})
while True:
data = await ws.receive_json()
msg = (data.get("msg") or "").strip()
if not msg: continue
add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or [])
conductor.notify({"type": "user_message", "msg": msg})
except WebSocketDisconnect: pass
finally: ws_clients.discard(ws)
if __name__ == "__main__":
import uvicorn
# bridge 自启 conductor 时传 --no-browser:不在用户浏览器里弹一个独立 conductor UI,
# 用户从桌面版「指挥家」页直接连过来即可。手动跑 conductor.py(没带 flag)保持原行为。
if "--no-browser" not in sys.argv:
import webbrowser, threading
threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start()
uvicorn.run("conductor:app", host=HOST, port=PORT, reload=False)
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
"""Per-thread LLM token usage via llmcore monkey-patches.
`install()` wraps `llmcore._record_usage` + `llmcore.print` (the SSE
`messages` path only emits final `output_tokens` through `[Output] tokens=N`).
Trackers are keyed by `threading.current_thread().name`; each TUI session
runs its agent on `ga-tui-agent-<id>`, so `/cost` is a thread lookup.
Subagent processes are out-of-process, so `scan_subagent_logs` parses the
same `[Cache]` / `[Output]` print lines from `temp/*/stdout.log`.
"""
from __future__ import annotations
import glob, os, re, threading, time
from dataclasses import dataclass, field
@dataclass
class TokenStats:
requests: int = 0
input: int = 0
output: int = 0
cache_create: int = 0
cache_read: int = 0
# Latest single-LLM-call sizes — drive the spinner's `↑ N · ↓ M`.
last_input: int = 0
last_output: int = 0
started_at: float = field(default_factory=time.time)
def total_input_side(self) -> int:
return self.input + self.cache_create + self.cache_read
def total_tokens(self) -> int:
return self.input + self.output + self.cache_create + self.cache_read
def cache_hit_rate(self) -> float:
side = self.total_input_side()
return (self.cache_read / side * 100.0) if side else 0.0
def elapsed_seconds(self) -> float:
return max(0.0, time.time() - self.started_at)
# GA's real context budget lives on `BaseSession.context_win` (chars). The
# trim trigger is `context_win * 3` (see llmcore.trim_messages_history), so
# `/cost` compares actual-history chars against that cap for consistent units.
def context_window_chars(backend) -> int:
"""`context_win * 3` — the char cap before `trim_messages_history` kicks
in. Reads dynamically so a `mykey.py` override propagates. Returns 0 on
bad/missing backend so the caller can hide the row."""
try:
return int(getattr(backend, 'context_win', 0)) * 3
except (TypeError, ValueError):
return 0
def current_input_chars(backend) -> int:
"""Char-size of the message history (same unit as `trim_messages_history`)."""
try:
import json as _json
history = getattr(backend, 'history', None) or []
return sum(len(_json.dumps(m, ensure_ascii=False)) for m in history)
except Exception:
return 0
_trackers: dict[str, TokenStats] = {}
_lock = threading.Lock()
_OUT_RE = re.compile(r'\[Output\]\s+tokens=(\d+)')
_CACHE_RE_NEW = re.compile(r'\[Cache\]\s+input=(\d+)\s+creation=(\d+)\s+read=(\d+)')
_CACHE_RE_OLD = re.compile(r'\[Cache\]\s+input=(\d+)\s+cached=(\d+)')
_INSTALLED = False
_SUBAGENT_GLOB = os.path.join("temp", "*", "stdout.log")
def scan_subagent_logs(since: float = 0.0, root: str | None = None) -> TokenStats:
"""Aggregate subagent tokens from `temp/<task>/stdout.log` files; pass
`since=tui_start_time` to scope to this run. Best-effort: bad logs skipped."""
out = TokenStats()
if since > 0: out.started_at = since
pattern = os.path.join(root, _SUBAGENT_GLOB) if root else _SUBAGENT_GLOB
for p in glob.glob(pattern):
try:
if since and os.path.getmtime(p) < since: continue
with open(p, encoding="utf-8", errors="ignore") as f:
for line in f:
if line.startswith("[Output]"):
m = _OUT_RE.match(line)
if m:
out.output += int(m.group(1)); out.requests += 1
elif line.startswith("[Cache]"):
# messages → `input=N creation=C read=R` (input excl. cache);
# chat_completions / responses → `input=N cached=R` (input incl. cached).
m = _CACHE_RE_NEW.match(line)
if m:
i, c, r = int(m.group(1)), int(m.group(2)), int(m.group(3))
out.input += i
out.cache_create += c; out.cache_read += r
continue
m = _CACHE_RE_OLD.match(line)
if m:
i, r = int(m.group(1)), int(m.group(2))
out.input += max(0, i - r); out.cache_read += r
except OSError:
continue
return out
def get(thread_name: str) -> TokenStats:
with _lock:
if thread_name not in _trackers:
_trackers[thread_name] = TokenStats()
return _trackers[thread_name]
def reset(thread_name: str) -> None:
with _lock:
_trackers.pop(thread_name, None)
def all_trackers() -> dict[str, TokenStats]:
with _lock:
return dict(_trackers)
def install() -> None:
"""Idempotently wrap llmcore._record_usage and llmcore.print."""
global _INSTALLED
if _INSTALLED: return
import llmcore
orig_record, orig_print = llmcore._record_usage, print
def record_patched(usage, api_mode):
# Handles INPUT / CACHE only; OUTPUT comes via `[Output]` print_patched
# below (the SSE path emits it that way; double-counting was the prior bug).
try:
if usage:
t = get(threading.current_thread().name)
t.requests += 1
if api_mode == 'messages':
inp = int(usage.get('input_tokens', 0) or 0)
cc = int(usage.get('cache_creation_input_tokens', 0) or 0)
cr = int(usage.get('cache_read_input_tokens', 0) or 0)
t.input += inp; t.cache_create += cc; t.cache_read += cr
# Non-stream `messages` skips the [Output] print, so count
# output_tokens here; SSE message_start carries a 1-token
# placeholder to skip.
out = int(usage.get('output_tokens', 0) or 0)
if out > 1: t.output += out; t.last_output = out
t.last_input = inp + cc + cr
elif api_mode == 'chat_completions':
cached = int((usage.get('prompt_tokens_details') or {}).get('cached_tokens', 0) or 0)
inp = int(usage.get('prompt_tokens', 0) or 0) - cached
t.input += inp; t.cache_read += cached
t.last_input = inp + cached
elif api_mode == 'responses':
cached = int((usage.get('input_tokens_details') or {}).get('cached_tokens', 0) or 0)
inp = int(usage.get('input_tokens', 0) or 0) - cached
t.input += inp; t.cache_read += cached
t.last_input = inp + cached
except Exception: pass
return orig_record(usage, api_mode)
llmcore._record_usage = record_patched
def print_patched(*args, **kwargs):
try:
if args and isinstance(args[0], str):
m = _OUT_RE.match(args[0])
if m:
t = get(threading.current_thread().name)
n = int(m.group(1))
t.output += n; t.last_output = n
except Exception: pass
return orig_print(*args, **kwargs)
llmcore.print = print_patched
_INSTALLED = True
+407
View File
@@ -0,0 +1,407 @@
# Discord Bot Frontend for GenericAgent
# ⚠️ 需要在 Discord Developer Portal 开启 "Message Content Intent"
# Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开
# pip install discord.py
import asyncio, json, os, queue as Q, re, sys, threading, time
from collections import OrderedDict
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from agentmain import GeneraticAgent
from chatapp_common import (
AgentChatMixin, build_done_text, ensure_single_instance, extract_files,
public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply,
HELP_TEXT, FILE_HINT, format_restore,
_handle_continue_frontend, _reset_conversation,
)
from llmcore import mykeys
try:
import discord
except Exception:
print("Please install discord.py to use Discord: pip install discord.py")
sys.exit(1)
agent = GeneraticAgent(); agent.verbose = False
BOT_TOKEN = str(mykeys.get("discord_bot_token", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("discord_allowed_users", []) if str(x).strip()}
USER_TASKS = {}
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
MEDIA_DIR = os.path.join(TEMP_DIR, "discord_media")
ACTIVE_FILE = os.path.join(TEMP_DIR, "discord_active_channels.json")
ACTIVE_TTL_SECONDS = 30 * 24 * 3600
EXIT_CHANNEL_TEXTS = {"退出该频道", "退出此频道", "退出频道"}
EXIT_THREAD_TEXTS = {"退出该子区", "退出此子区", "退出子区"}
os.makedirs(MEDIA_DIR, exist_ok=True)
def _extract_discord_progress(text):
"""Return the newest concise <summary> from a streaming transcript."""
matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
if not matches:
return ""
summary = re.sub(r"\s+", " ", matches[-1]).strip()
return summary[:120]
def _strip_discord_transcript(text):
"""Hide LLM/tool transcript noise while preserving the final natural reply."""
text = text or ""
text = re.sub(r"^\s*\*?\*?LLM Running \(Turn \d+\) \.\.\.\*?\*?\s*$", "", text, flags=re.M)
text = re.sub(r"^\s*🛠️\s+.*?(?=^\s*(?:\*?\*?LLM Running|<summary>|$))", "", text, flags=re.M | re.DOTALL)
text = re.sub(r"^\s*(?:✅|❌|ERR|STDOUT|PAT\b|RC\b).*?$", "", text, flags=re.M)
text = re.sub(r"<tool_use>.*?</tool_use>", "", text, flags=re.DOTALL)
text = clean_reply(text)
return strip_files(text).strip()
def _display_done_text(text):
body = _strip_discord_transcript(text)
if body and body != "...":
return body
summaries = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
if summaries:
return re.sub(r"\s+", " ", summaries[-1]).strip() or "..."
return "..."
class DiscordApp(AgentChatMixin):
label, source, split_limit = "Discord", "discord", 1900
def __init__(self):
super().__init__(agent, USER_TASKS)
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.dm_messages = True
proxy = str(mykeys.get("proxy", "") or "").strip() or None
self.client = discord.Client(intents=intents, proxy=proxy)
self.background_tasks = set()
self._channel_cache = OrderedDict() # chat_id -> channel/user object (LRU, max 500)
self._active_channels = self._load_active_channels() # guild chat_id -> {last_seen: float}
self._active_lock = threading.Lock()
self._agents = OrderedDict() # chat_id -> GeneraticAgent, each chat has isolated history
self._agent_lock = threading.Lock()
@self.client.event
async def on_ready():
print(f"[Discord] bot ready: {self.client.user} ({self.client.user.id})")
@self.client.event
async def on_message(message):
await self._handle_message(message)
def _chat_id(self, message):
"""Return a string chat_id: 'dm:<user_id>' or 'ch:<channel_id>'."""
if isinstance(message.channel, discord.DMChannel):
return f"dm:{message.author.id}"
return f"ch:{message.channel.id}"
def _load_active_channels(self):
try:
with open(ACTIVE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
now = time.time()
active = {}
for chat_id, item in data.items():
if not str(chat_id).startswith("ch:") or not isinstance(item, dict):
continue
last_seen = float(item.get("last_seen") or 0)
if now - last_seen <= ACTIVE_TTL_SECONDS:
active[str(chat_id)] = {"last_seen": last_seen}
return active
except FileNotFoundError:
return {}
except Exception as e:
print(f"[Discord] failed to load active channels: {e}")
return {}
def _save_active_channels(self):
try:
os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True)
tmp = ACTIVE_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True)
os.replace(tmp, ACTIVE_FILE)
except Exception as e:
print(f"[Discord] failed to save active channels: {e}")
def _is_active_channel(self, chat_id, now=None):
now = now or time.time()
with self._active_lock:
item = self._active_channels.get(chat_id)
if not item:
return False
if now - float(item.get("last_seen") or 0) > ACTIVE_TTL_SECONDS:
self._active_channels.pop(chat_id, None)
self._save_active_channels()
print(f"[Discord] channel expired: {chat_id}")
return False
return True
def _touch_active_channel(self, chat_id, now=None):
if not chat_id.startswith("ch:"):
return
with self._active_lock:
self._active_channels[chat_id] = {"last_seen": float(now or time.time())}
self._save_active_channels()
def _deactivate_channel(self, chat_id):
with self._active_lock:
changed = self._active_channels.pop(chat_id, None) is not None
self._save_active_channels()
state = self.user_tasks.get(chat_id)
if state:
state["running"] = False
try:
self._get_agent(chat_id).abort()
except Exception as e:
print(f"[Discord] deactivate abort failed for {chat_id}: {e}")
return changed
def _get_agent(self, chat_id):
with self._agent_lock:
ga = self._agents.get(chat_id)
if ga is None:
ga = GeneraticAgent()
ga.verbose = False
self._agents[chat_id] = ga
threading.Thread(target=ga.run, daemon=True, name=f"discord-agent-{chat_id}").start()
if len(self._agents) > 200:
old_chat_id, _old_agent = self._agents.popitem(last=False)
print(f"[Discord] dropped agent cache entry: {old_chat_id}")
else:
self._agents.move_to_end(chat_id)
return ga
async def _download_attachments(self, message):
"""Download attachments/images to MEDIA_DIR, return list of local paths."""
paths = []
for att in message.attachments:
safe_name = re.sub(r'[<>:"/\\|?*]', '_', att.filename or f"file_{att.id}")
local_path = os.path.join(MEDIA_DIR, f"{att.id}_{safe_name}")
try:
await att.save(local_path)
paths.append(local_path)
print(f"[Discord] saved attachment: {local_path}")
except Exception as e:
print(f"[Discord] failed to save attachment {att.filename}: {e}")
return paths
async def send_text(self, chat_id, content, **ctx):
"""Send text (and optionally files) to a chat_id."""
channel = self._channel_cache.get(chat_id)
if channel is None:
try:
if chat_id.startswith("dm:"):
user = await self.client.fetch_user(int(chat_id[3:]))
channel = await user.create_dm()
else:
channel = await self.client.fetch_channel(int(chat_id[3:]))
self._channel_cache[chat_id] = channel
if len(self._channel_cache) > 500:
self._channel_cache.popitem(last=False)
except Exception as e:
print(f"[Discord] cannot resolve channel for {chat_id}: {e}")
return
for part in split_text(content, self.split_limit):
try:
await channel.send(part)
except Exception as e:
print(f"[Discord] send error: {e}")
async def send_done(self, chat_id, raw_text, **ctx):
"""Send final reply: text parts + file attachments."""
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
body = _display_done_text(raw_text)
# Send text (send_text handles splitting internally)
if body and body != "...":
await self.send_text(chat_id, body, **ctx)
# Send files as Discord attachments
if files:
channel = self._channel_cache.get(chat_id)
if channel:
for fpath in files:
try:
await channel.send(file=discord.File(fpath))
except Exception as e:
print(f"[Discord] failed to send file {fpath}: {e}")
await self.send_text(chat_id, f"⚠️ 文件发送失败: {os.path.basename(fpath)}", **ctx)
if not body and not files:
await self.send_text(chat_id, "...", **ctx)
async def handle_command(self, chat_id, cmd, **ctx):
"""Handle slash commands against the per-chat agent, keeping Discord chats isolated."""
ga = self._get_agent(chat_id)
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/help":
return await self.send_text(chat_id, HELP_TEXT, **ctx)
if op == "/stop":
state = self.user_tasks.get(chat_id)
if state:
state["running"] = False
ga.abort()
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
if op == "/status":
llm = ga.get_llm_name() if ga.llmclient else "未配置"
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\nLLM: [{ga.llm_no}] {llm}", **ctx)
if op == "/llm":
if not ga.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
if len(parts) > 1:
try:
ga.next_llm(int(parts[1]))
return await self.send_text(chat_id, f"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}", **ctx)
except Exception:
return await self.send_text(chat_id, f"用法: /llm <0-{len(ga.list_llms()) - 1}>", **ctx)
lines = [f"{'' if cur else ' '} [{i}] {name}" for i, name, cur in ga.list_llms()]
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
if op == "/restore":
try:
restored_info, err = format_restore()
if err:
return await self.send_text(chat_id, err, **ctx)
restored, fname, count = restored_info
ga.abort()
ga.history.extend(restored)
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
except Exception as e:
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
if op == "/continue":
return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx)
if op == "/new":
return await self.send_text(chat_id, _reset_conversation(ga), **ctx)
return await self.send_text(chat_id, HELP_TEXT, **ctx)
async def run_agent(self, chat_id, text, **ctx):
"""Run the isolated per-chat Discord agent."""
ga = self._get_agent(chat_id)
state = {"running": True}
self.user_tasks[chat_id] = state
try:
await self.send_text(chat_id, "思考中...", **ctx)
dq = ga.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
last_ping = time.time()
last_step = ""
step_no = 0
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if ga.is_running and time.time() - last_ping > self.ping_interval:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
last_ping = time.time()
continue
if "next" in item:
step = _extract_discord_progress(item.get("next", ""))
if step and step != last_step:
step_no += 1
await self.send_text(chat_id, f"步骤{step_no}{step}", **ctx)
last_step = step
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""), **ctx)
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
except Exception as e:
import traceback
print(f"[{self.label}] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
finally:
self.user_tasks.pop(chat_id, None)
async def _handle_message(self, message):
# Ignore self
if message.author == self.client.user or message.author.bot:
return
is_dm = isinstance(message.channel, discord.DMChannel)
is_guild = message.guild is not None
chat_id = self._chat_id(message)
now = time.time()
mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message))
self._channel_cache[chat_id] = message.channel
if len(self._channel_cache) > 500:
self._channel_cache.popitem(last=False)
user_id = str(message.author.id)
user_name = str(message.author)
if not public_access(ALLOWED) and user_id not in ALLOWED:
print(f"[Discord] unauthorized user: {user_name} ({user_id})")
return
if is_guild:
active = self._is_active_channel(chat_id, now)
if not mentioned and not active:
return
if mentioned or active:
self._touch_active_channel(chat_id, now)
# Strip bot mention from content
content = message.content or ""
if is_guild and self.client.user:
content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip()
else:
content = content.strip()
normalized = re.sub(r"\s+", "", content)
if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS:
self._deactivate_channel(chat_id)
label = "子区" if normalized in EXIT_THREAD_TEXTS else "频道"
await self.send_text(chat_id, f"✅ 已退出该{label},之后除非重新 @ 我,否则不会主动响应。")
print(f"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})")
return
# Download attachments
attachment_paths = await self._download_attachments(message)
# Build message text with attachment paths
if attachment_paths:
paths_text = "\n".join(f"[附件: {p}]" for p in attachment_paths)
content = f"{content}\n{paths_text}" if content else paths_text
if not content:
return
print(f"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}")
if content.startswith("/"):
return await self.handle_command(chat_id, content)
task = asyncio.create_task(self.run_agent(chat_id, content))
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
async def start(self):
print("[Discord] bot starting...")
delay, max_delay = 5, 300
while True:
started_at = time.monotonic()
try:
await self.client.start(BOT_TOKEN)
except Exception as e:
print(f"[Discord] error: {e}")
if time.monotonic() - started_at >= 60:
delay = 5
print(f"[Discord] reconnect in {delay}s...")
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
if __name__ == "__main__":
_LOCK_SOCK = ensure_single_instance(19532, "Discord")
require_runtime(agent, "Discord", discord_bot_token=BOT_TOKEN)
redirect_log(__file__, "dcapp.log", "Discord", ALLOWED)
asyncio.run(DiscordApp().start())
+7
View File
@@ -0,0 +1,7 @@
# Rust build artifacts
src-tauri/target/
src-tauri/gen/
# Node
node_modules/
package-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"name": "genericagent-web2",
"version": "0.1.0",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "ga-desktop"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
rfd = "0.15"
[lib]
name = "ga_desktop_lib"
crate-type = ["lib", "cdylib", "staticlib"]
+25
View File
@@ -0,0 +1,25 @@
use std::process::Command;
fn main() {
// Build identity used to decide whether a bridge already holding :14168 belongs to THIS
// build. commit hash + build timestamp → distinct on every build, even when the human
// version in tauri.conf.json is unchanged (so same-version re-publishes still take over
// a stale bridge). The bridge reports this back via GET /services/identity.
let commit = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "nogit".to_string());
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
println!("cargo:rustc-env=GA_BUILD_ID={}-{}", commit, stamp);
// Re-run when the checked-out commit changes so the id stays fresh.
println!("cargo:rerun-if-changed=../../../.git/HEAD");
tauri_build::build()
}
@@ -0,0 +1,24 @@
{
"identifier": "default",
"description": "Default capabilities for all windows",
"windows": ["main", "setup"],
"remote": {
"urls": ["http://127.0.0.1:14168", "http://localhost:14168"]
},
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-get-all-windows",
"core:webview:default",
"allow-start-bridge",
"allow-start-bridge-with-config",
"allow-get-config",
"allow-export-mykey",
"allow-shortcut-should-ask",
"allow-shortcut-decide"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,29 @@
[[permission]]
identifier = "allow-start-bridge"
description = "Spawn bridge from the services panel when bridge was stopped."
commands.allow = ["start_bridge"]
[[permission]]
identifier = "allow-start-bridge-with-config"
description = "Spawn bridge from setup with explicit python/project paths."
commands.allow = ["start_bridge_with_config"]
[[permission]]
identifier = "allow-get-config"
description = "Read saved bridge spawn configuration."
commands.allow = ["get_config"]
[[permission]]
identifier = "allow-export-mykey"
description = "Save mykey.py via native file dialog."
commands.allow = ["export_mykey"]
[[permission]]
identifier = "allow-shortcut-should-ask"
description = "Check whether to show the first-run desktop-shortcut prompt."
commands.allow = ["shortcut_should_ask"]
[[permission]]
identifier = "allow-shortcut-decide"
description = "Persist the user's desktop-shortcut choice and create it if enabled."
commands.allow = ["shortcut_decide"]
+908
View File
@@ -0,0 +1,908 @@
use std::process::{Command, Child, Stdio};
use std::io::{BufRead, BufReader};
use std::sync::Mutex;
use std::net::TcpStream;
use std::time::{Duration, Instant};
use std::thread;
use std::path::PathBuf;
use tauri::Manager;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
static BRIDGE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
/// Get project root (parent of frontends/)
fn project_root() -> PathBuf {
std::env::current_exe()
.expect("cannot get exe path")
.parent().expect("cannot get exe dir") // frontends/
.parent().expect("cannot get project root") // project root
.to_path_buf()
}
/// Directory next to which a self-contained bundle keeps its runtime/ folder.
/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an
/// AppImage (current_exe would otherwise point inside the read-only squashfs mount).
/// macOS portable package: the folder containing GenericAgent.app and runtime/.
fn bundle_anchor_dir() -> Option<PathBuf> {
#[cfg(not(windows))]
{
if let Some(p) = std::env::var_os("APPIMAGE") {
if let Some(d) = PathBuf::from(p).parent() {
return Some(d.to_path_buf());
}
}
}
let exe = std::env::current_exe().ok()?;
#[cfg(target_os = "macos")]
{
// current_exe() inside a bundle is:
// <package>/GenericAgent.app/Contents/MacOS/GenericAgent
// Prefer the standard macOS layout where runtime is embedded in the app:
// GenericAgent.app/Contents/Resources/runtime/app/agentmain.py
// Fall back to the old portable layout for compatibility:
// <package>/runtime/app/agentmain.py
let mut d = exe.parent();
while let Some(dir) = d {
if dir.extension().and_then(|s| s.to_str()) == Some("app") {
let resources = dir.join("Contents").join("Resources");
if resources.join("runtime").join("app").join("agentmain.py").exists() {
return Some(resources);
}
if let Some(parent) = dir.parent() {
return Some(parent.to_path_buf());
}
}
d = dir.parent();
}
}
Some(exe.parent()?.to_path_buf())
}
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
fn bundle_python() -> Option<PathBuf> {
let root = bundle_root()?;
#[cfg(windows)]
let p = root.join("python").join("python.exe");
#[cfg(not(windows))]
let p = root.join("python").join("bin").join("python3");
if p.exists() { Some(p) } else { None }
}
/// Find python executable:
/// 1. The embedded bundle python (runtime/python) — deps are installed directly into it
/// (no venv), and its path is resolved relative to the bundle anchor at runtime, so the
/// package stays relocatable (moving the folder doesn't break absolute venv paths).
/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
/// 3. Fallback to system PATH
fn find_python() -> String {
if let Some(p) = bundle_python() {
return p.to_string_lossy().to_string();
}
let root = project_root();
let portable_python_dir = root.join(".portable").join("uv-python");
if portable_python_dir.exists() {
// uv installs python like: uv-python/cpython-3.12.x-windows-x86_64/python.exe
// We need to search for python.exe inside subdirectories
if let Ok(entries) = std::fs::read_dir(&portable_python_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
#[cfg(windows)]
{
let py = path.join("python.exe");
if py.exists() {
return py.to_string_lossy().to_string();
}
}
#[cfg(not(windows))]
{
let py = path.join("bin").join("python3");
if py.exists() {
return py.to_string_lossy().to_string();
}
}
}
}
}
}
// Fallback: system PATH
#[cfg(windows)]
{ "python".to_string() }
#[cfg(not(windows))]
{ "python3".to_string() }
}
/// Find the project directory (folder containing agentmain.py).
/// Bundle layout: <exe dir>/runtime/app/agentmain.py. Dev layout: walk up from the exe.
fn find_project_dir() -> Option<String> {
// Bundle layout: source tucked under <anchor>/runtime/app/
if let Some(anchor) = bundle_anchor_dir() {
let app = anchor.join("runtime").join("app");
if app.join("agentmain.py").exists() {
return Some(app.to_string_lossy().to_string());
}
}
// Dev/source layout: walk up to 8 levels from the exe location.
let exe = std::env::current_exe().ok()?;
let mut dir = Some(exe.parent()?);
for _ in 0..8 {
match dir {
Some(d) => {
if d.join("agentmain.py").exists() {
return Some(d.to_string_lossy().to_string());
}
dir = d.parent();
}
None => break,
}
}
None
}
/// Settings file path: ~/.ga_desktop_settings.json
fn settings_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ga_desktop_settings.json")
}
/// Read the settings file as a JSON object (empty object when missing/unparseable).
fn read_settings() -> serde_json::Map<String, serde_json::Value> {
let path = settings_path();
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(serde_json::Value::Object(m)) = serde_json::from_str(&content) {
return m;
}
}
serde_json::Map::new()
}
/// Merge `updates` into the existing settings file and write it back, preserving any keys
/// we don't touch. The old code rewrote the file with only python_path/project_dir, which
/// would silently drop sibling keys like `desktop_shortcut`. Always go through here.
fn merge_settings(updates: serde_json::Value) {
let mut obj = read_settings();
if let serde_json::Value::Object(m) = updates {
for (k, v) in m {
obj.insert(k, v);
}
}
let val = serde_json::Value::Object(obj);
if let Ok(text) = serde_json::to_string_pretty(&val) {
let _ = std::fs::write(settings_path(), text);
}
}
/// Desktop-shortcut preference stored in settings under `desktop_shortcut`.
/// None = never asked (first run)
/// Some(true)/Some(false) = user's remembered choice.
fn read_shortcut_pref() -> Option<bool> {
read_settings().get("desktop_shortcut").and_then(|v| v.as_bool())
}
fn write_shortcut_pref(enabled: bool) {
merge_settings(serde_json::json!({ "desktop_shortcut": enabled }));
}
/// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every
/// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and
/// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell).
#[cfg(windows)]
fn ensure_desktop_shortcut() {
let Ok(exe) = std::env::current_exe() else { return; };
let Some(desktop) = dirs::desktop_dir() else { return; };
let lnk = desktop.join("GenericAgent.lnk");
let work_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe.clone());
let exe_s = exe.to_string_lossy().replace('\'', "''");
let lnk_s = lnk.to_string_lossy().replace('\'', "''");
let work_s = work_dir.to_string_lossy().replace('\'', "''");
// Build the shortcut via WScript.Shell COM, consistent with the existing powershell usage
// elsewhere in this file. No extra crate needed.
let script = format!(
"$ws = New-Object -ComObject WScript.Shell; \
$sc = $ws.CreateShortcut('{lnk}'); \
$sc.TargetPath = '{exe}'; \
$sc.WorkingDirectory = '{work}'; \
$sc.IconLocation = '{exe}'; \
$sc.Save()",
lnk = lnk_s, exe = exe_s, work = work_s
);
let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let _ = cmd.status();
}
#[cfg(target_os = "linux")]
fn ensure_desktop_shortcut() {
// Launch target: the AppImage path when running as one, else the current exe. Writing the
// current path on every enabled launch keeps a relocated bundle's launcher valid.
let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from)
.or_else(|| std::env::current_exe().ok()) else { return; };
let exec = target.to_string_lossy().replace('"', "");
// Linux .desktop Icon= needs an image file (or themed name), not the AppImage path. The CI
// ships GenericAgent.png next to the AppImage; fall back to a generic themed icon otherwise.
let icon = bundle_anchor_dir()
.map(|d| d.join("GenericAgent.png"))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "application-x-executable".to_string());
let entry = format!(
"[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\
Exec=\"{exec}\"\nIcon={icon}\nTerminal=false\nCategories=Utility;Development;\n",
exec = exec, icon = icon
);
let write_desktop = |path: &std::path::Path| {
if std::fs::write(path, &entry).is_ok() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
}
};
if let Some(home) = dirs::home_dir() {
let apps = home.join(".local/share/applications");
let _ = std::fs::create_dir_all(&apps);
write_desktop(&apps.join("GenericAgent.desktop"));
}
if let Some(desktop) = dirs::desktop_dir() {
let _ = std::fs::create_dir_all(&desktop);
let f = desktop.join("GenericAgent.desktop");
write_desktop(&f);
// GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort.
let _ = Command::new("gio")
.args(["set", &f.to_string_lossy(), "metadata::trusted", "true"])
.status();
}
}
#[cfg(target_os = "macos")]
fn ensure_desktop_shortcut() {
// The .app is the launchable unit; drop a symlink to it on the Desktop.
let Ok(exe) = std::env::current_exe() else { return; };
let mut app: Option<PathBuf> = None;
let mut d = exe.parent();
while let Some(dir) = d {
if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; }
d = dir.parent();
}
let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; };
let link = desktop.join("GenericAgent.app");
let _ = std::fs::remove_file(&link);
let _ = std::os::unix::fs::symlink(&app, &link);
}
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
fn ensure_desktop_shortcut() {}
/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut
/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend
/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this
/// background startup thread has no parent window and gets buried behind the main window on
/// first launch, so the prompt is owned by the web UI instead, which always renders on top.
fn maybe_setup_shortcut() {
if bundle_root().is_none() {
return;
}
// Only self-heal when the user already opted in. Never prompt here.
if read_shortcut_pref() == Some(true) {
ensure_desktop_shortcut();
}
}
/// Frontend asks whether to show the first-run "create desktop shortcut?" prompt.
/// True only on a portable bundle whose preference has never been set.
#[tauri::command]
fn shortcut_should_ask() -> bool {
bundle_root().is_some() && read_shortcut_pref().is_none()
}
/// Frontend reports the user's choice. Persists it and creates the shortcut when enabled.
#[tauri::command]
fn shortcut_decide(create: bool) {
write_shortcut_pref(create);
if create {
ensure_desktop_shortcut();
}
}
/// True when this binary is running from inside a macOS .app bundle (packaged build).
/// Used to refuse stale ~/.ga_desktop_settings.json that could point at an old checkout
/// when App Translocation hides our own runtime/ from current_exe().
#[cfg(target_os = "macos")]
fn running_inside_app_bundle() -> bool {
std::env::current_exe()
.ok()
.map(|p| {
p.components().any(|c| {
c.as_os_str().to_string_lossy().ends_with(".app")
})
})
.unwrap_or(false)
}
/// Read config from settings file, or auto-discover and save.
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
pub fn get_or_discover_config() -> (String, String) {
let path = settings_path();
if bundle_root().is_some() {
let python = find_python();
let project = find_project_dir().unwrap_or_default();
if !python.is_empty() && !project.is_empty() {
merge_settings(serde_json::json!({
"python_path": python,
"project_dir": project
}));
return (python, project);
}
}
// Try reading existing settings.
// On macOS, a packaged .app must never trust ~/.ga_desktop_settings.json: App
// Translocation can run the bundle from a random read-only copy where bundle_root()
// fails to see our own runtime/, and an old settings file would then silently point
// the bridge at a previously installed checkout. In that case fall through to
// auto-discovery (which still resolves the bundle via .app-relative search below).
#[cfg(target_os = "macos")]
let trust_settings = !running_inside_app_bundle();
#[cfg(not(target_os = "macos"))]
let trust_settings = true;
if trust_settings && path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
let python = val.get("python_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let project = val.get("project_dir")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !python.is_empty() && !project.is_empty() {
return (python, project);
}
}
}
}
// Auto-discover
let python = find_python();
let project = find_project_dir().unwrap_or_default();
// Save discovered config
if !python.is_empty() && !project.is_empty() {
merge_settings(serde_json::json!({
"python_path": python,
"project_dir": project
}));
}
(python, project)
}
/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/.
/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything
/// else under <exe dir>/runtime/. Returns None when this is not a bundle (e.g. dev build).
fn bundle_root() -> Option<PathBuf> {
let runtime = bundle_anchor_dir()?.join("runtime");
if runtime.join("app").join("agentmain.py").exists() {
return Some(runtime);
}
None
}
/// Marker written after a successful offline prepare. Lives under runtime/ so it travels
/// with the bundle: a relocated folder stays "prepared" (deps live in the embedded python,
/// which is itself relocatable) and won't re-run prepare.
fn prepared_marker() -> Option<PathBuf> {
Some(bundle_root()?.join(".prepared"))
}
/// True when this is a self-contained bundle whose python env has not been prepared yet
/// (embedded python present but deps not yet installed into it).
fn needs_first_run_prepare(project_dir: &str) -> bool {
if project_dir.is_empty() { return false; }
bundle_python().is_some() && prepared_marker().map(|m| !m.exists()).unwrap_or(false)
}
/// Clear env vars a host launcher injects pointing at its own runtime. The Linux AppImage exports
/// PYTHONHOME/PYTHONPATH (-> bundled python crashes with "No module named 'encodings'") and
/// LD_LIBRARY_PATH (-> wrong shared libs). Our bundled python / prepare / bridge must run clean.
fn sanitize_bundle_env(cmd: &mut Command) {
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
cmd.env_remove("LD_LIBRARY_PATH");
// Stamp the bridge we spawn with this build's id so a later app launch can tell whether the
// bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity).
cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID"));
}
/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels.
/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`.
/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json.
fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> {
let root = bundle_root().ok_or("cannot locate bundle root")?;
let wheels = root.join("wheels");
#[cfg(windows)]
let (script, py) = (
root.join("install_windows.ps1"),
root.join("python").join("python.exe"),
);
#[cfg(target_os = "macos")]
let (script, py) = (
root.join("install_macos.sh"),
root.join("python").join("bin").join("python3"),
);
#[cfg(all(not(windows), not(target_os = "macos")))]
let (script, py) = (
root.join("install_linux.sh"),
root.join("python").join("bin").join("python3"),
);
if !script.exists() || !py.exists() || !wheels.exists() {
return Err(format!("prepare resources missing under {:?}", root));
}
#[cfg(windows)]
let mut cmd = {
let mut c = Command::new("powershell.exe");
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
.arg(&script)
.arg("-PythonPath").arg(&py)
.arg("-ProjectDir").arg(project_dir)
.arg("-WheelDir").arg(&wheels)
.arg("-ExtraPipPackages").arg("fastapi uvicorn websockets")
// -NoVenv: install deps straight into the embedded python (no venv) so the
// bundle is relocatable. See prepared_marker / find_python.
.args(["-Mode", "PrepareOnly", "-SkipNpmInstall", "-NoVenv"]);
c
};
#[cfg(not(windows))]
let mut cmd = {
let mut c = Command::new("bash");
c.arg(&script)
.arg("--python-path").arg(&py)
.arg("--project-dir").arg(project_dir)
.arg("--wheel-dir").arg(&wheels)
.arg("--extra-packages").arg("fastapi uvicorn websockets")
// --no-venv: install deps straight into the embedded python (no venv) so the
// bundle is relocatable. See prepared_marker / find_python.
.args(["--mode", "PrepareOnly", "--no-venv"]);
c
};
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?;
// Forward the script's ASCII progress keys to the loading window, which localizes them
// (window.gaProgress maps key -> zh/en by navigator.language).
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().flatten() {
if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") {
match key.trim() {
"venv" => report(15, "venv"),
"deps" => report(45, "deps"),
"done" => report(90, "done"),
_ => {}
}
}
}
}
let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?;
if !status.success() {
return Err(format!("prepare exited with status {:?}", status.code()));
}
// Record success so later launches (and relocated copies) skip the prepare step.
if let Some(marker) = prepared_marker() {
let _ = std::fs::write(&marker, b"ok\n");
}
Ok(())
}
/// GET /services/identity from a running bridge; returns the parsed JSON (or None when the
/// endpoint is absent — i.e. an older/foreign bridge).
fn bridge_reported_identity() -> Option<serde_json::Value> {
use std::io::{Read, Write};
let mut stream = TcpStream::connect_timeout(
&"127.0.0.1:14168".parse().unwrap(),
Duration::from_millis(800),
).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
stream.write_all(req).ok()?;
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let text = String::from_utf8_lossy(&buf);
let body = text.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body.trim()).ok()
}
fn norm_path(p: &str) -> String {
std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| p.to_string())
}
/// A running bridge is "ours" only when it serves the same install path AND was spawned by the
/// same build. The build id (commit+timestamp, see build.rs) changes on every build, so an
/// in-place upgrade or a same-version re-publish still counts as a different bridge → take over.
/// An old bridge with no /identity (None) or no build_id field ("") never matches → taken over.
fn bridge_identity_matches(project_dir: &str) -> bool {
let Some(id) = bridge_reported_identity() else { return false; };
let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or("");
let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or("");
if reported_build != env!("GA_BUILD_ID") {
return false;
}
let (a, b) = (norm_path(reported_root), norm_path(project_dir));
#[cfg(windows)]
{ a.eq_ignore_ascii_case(&b) }
#[cfg(not(windows))]
{ a == b }
}
/// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with
/// no such endpoint): force-kill whatever process is listening on :14168 so the new bridge can
/// bind it. Only called after an identity mismatch, so we never kill a bridge that is ours.
fn force_free_bridge_port() {
#[cfg(windows)]
{
// netstat -ano: last column is the PID for the :14168 LISTENING row.
if let Ok(out) = Command::new("netstat").args(["-ano", "-p", "tcp"]).output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if line.contains(":14168") && line.to_uppercase().contains("LISTENING") {
if let Some(pid) = line.split_whitespace().last() {
let mut c = Command::new("taskkill");
c.args(["/F", "/PID", pid]);
c.creation_flags(0x08000000);
let _ = c.status();
}
}
}
}
}
#[cfg(not(windows))]
{
// lsof prints the listening PIDs; kill -9 each.
if let Ok(out) = Command::new("lsof").args(["-ti", "tcp:14168", "-sTCP:LISTEN"]).output() {
for pid in String::from_utf8_lossy(&out.stdout).split_whitespace() {
let _ = Command::new("kill").args(["-9", pid]).status();
}
}
}
}
fn request_bridge_shutdown() {
use std::io::{Read, Write};
let Ok(mut stream) = TcpStream::connect_timeout(
&"127.0.0.1:14168".parse().unwrap(),
Duration::from_millis(800),
) else {
return;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(req);
let _ = stream.read(&mut [0u8; 512]);
}
fn takeover_stale_bridge(project_dir: &str) {
if project_dir.is_empty() || !is_bridge_running() {
return;
}
if bridge_identity_matches(project_dir) {
return;
}
eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over");
request_bridge_shutdown();
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(10) {
thread::sleep(Duration::from_millis(200));
}
// Old bridges have no /services/bridge/exit endpoint and ignore the request above — if the
// port is still held, force-kill the listener so our fresh bridge can bind it.
if is_bridge_running() {
eprintln!("[tauri] stale bridge did not exit; force-freeing :14168");
force_free_bridge_port();
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(5) {
thread::sleep(Duration::from_millis(200));
}
}
}
fn is_bridge_running() -> bool {
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
}
fn wait_for_port(port: u16, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return true;
}
thread::sleep(Duration::from_millis(100));
}
false
}
fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> {
if is_bridge_running() {
return Ok(());
}
let py = PathBuf::from(python_path);
let dir = PathBuf::from(project_dir);
let script = dir.join("frontends").join("desktop_bridge.py");
if !script.exists() {
return Err(format!("desktop_bridge.py not found at {:?}", script));
}
let mut cmd = Command::new(&py);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
Ok(())
}
fn show_bridge_window(app_handle: &tauri::AppHandle) {
if let Some(main_win) = app_handle.get_webview_window("main") {
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
let _ = main_win.navigate(url);
let _ = main_win.show();
let _ = main_win.set_focus();
}
if let Some(setup_win) = app_handle.get_webview_window("setup") {
let _ = setup_win.hide();
}
}
#[tauri::command]
fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
// Save to settings (merge so sibling keys like desktop_shortcut survive).
merge_settings(serde_json::json!({"python_path": python_path, "project_dir": project_dir}));
spawn_bridge_process(&python_path, &project_dir)?;
// Wait for port
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("Bridge did not become ready within 20s".into());
}
show_bridge_window(&app_handle);
Ok(())
}
#[tauri::command]
fn start_bridge(app_handle: tauri::AppHandle) -> Result<(), String> {
let (python_path, project_dir) = get_or_discover_config();
spawn_bridge_process(&python_path, &project_dir)?;
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("Bridge did not become ready within 20s".into());
}
show_bridge_window(&app_handle);
Ok(())
}
#[tauri::command]
fn get_config() -> (String, String) {
get_or_discover_config()
}
#[tauri::command]
fn export_mykey(content: String) -> Result<Option<String>, String> {
let path = rfd::FileDialog::new()
.set_file_name("mykey.py")
.add_filter("Python", &["py"])
.save_file();
match path {
Some(p) => {
std::fs::write(&p, content.as_bytes()).map_err(|e| e.to_string())?;
Ok(Some(p.to_string_lossy().into_owned()))
}
None => Ok(None),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = std::env::args().collect();
let no_autostart = args.iter().any(|a| a == "--no-autostart");
let dev_mode = args.iter().any(|a| a == "--dev");
let project_dir = find_project_dir().unwrap_or_default();
let needs_prepare = needs_first_run_prepare(&project_dir);
takeover_stale_bridge(&project_dir);
let bridge_ok = is_bridge_running();
let mut spawned_bridge = false;
// Skip the early spawn when a first-run prepare is required (no venv yet);
// the setup thread prepares the env first and then starts the bridge.
if !bridge_ok && !no_autostart && !needs_prepare {
// Try to start bridge with saved/discovered config
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
if let Ok(child) = cmd.spawn() {
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
spawned_bridge = true;
}
}
}
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
.setup(move |app| {
// Show the loading window immediately so the first-run prepare isn't a blank screen.
// The window starts on loading.html (a local page), so no "connection refused" flash.
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
}
let handle = app.handle().clone();
let project_dir = project_dir.clone();
thread::spawn(move || {
// Progress reporter: push status into the loading window (window.gaProgress).
let main_win = handle.get_webview_window("main");
let report = |pct: i32, msg: &str| {
if let Some(w) = &main_win {
let js = format!(
"window.gaProgress && window.gaProgress({}, {})",
pct,
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
);
let _ = w.eval(&js);
}
};
// First-run (self-contained bundle): prepare the embedded python env offline,
// then start the bridge with the freshly created venv.
if needs_prepare {
report(5, "start");
if let Err(e) = run_offline_prepare(&project_dir, &report) {
eprintln!("[tauri] first-run prepare failed: {}", e);
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); }
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
return;
}
report(95, "starting");
if !is_bridge_running() {
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
if let Ok(child) = cmd.spawn() {
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
}
}
}
}
// First run (prepare) and cold bridge start may take a while; allow up to 60s.
let wait = if needs_prepare || spawned_bridge {
Duration::from_secs(60)
} else {
Duration::from_secs(2)
};
let bridge_ready = wait_for_port(14168, wait);
if bridge_ready {
// The bridge auto-starts conductor + scheduler itself (on_startup), so we do
// NOT probe their ports here: that would self-detect the bridge's own
// just-started extras and falsely report "ports busy".
if !wait_for_port(14168, Duration::from_secs(15)) {
eprintln!("[tauri] bridge not reachable before navigate");
if let Some(w) = &main_win {
let msg = "无法连接 bridge (127.0.0.1:14168),请关闭程序后重试。";
let js = format!(
"alert({})",
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
);
let _ = w.eval(&js);
}
return;
}
// Navigate to the bridge HTTP only after it is ready.
if let Some(w) = handle.get_webview_window("main") {
if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") {
let _ = w.navigate(url);
}
if dev_mode {
w.open_devtools();
} else {
// Disable F5/F12/Ctrl+R/right-click in production
let _ = w.eval(r#"
document.addEventListener('keydown', function(e) {
if (e.key === 'F12' || e.key === 'F5' ||
(e.ctrlKey && e.key === 'r') ||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
"#);
}
let _ = w.show();
let _ = w.set_focus();
}
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); }
// App is up and reachable: ask-once / self-heal the desktop shortcut.
// Runs last so it never blocks the loading/navigation path.
maybe_setup_shortcut();
} else {
// Bridge never came up -> let the user fix paths in the setup window.
if let Some(sw) = handle.get_webview_window("setup") {
if dev_mode { sw.open_devtools(); }
let _ = sw.show();
}
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let label = window.label();
if label == "main" {
// Persistent backend: closing the window does NOT stop the bridge or its
// services, so relaunching attaches to the warm backend on 14168.
window.app_handle().exit(0);
} else if label == "setup" {
// Setup closed -> exit if main is not visible
if let Some(main_win) = window.app_handle().get_webview_window("main") {
if !main_win.is_visible().unwrap_or(false) {
window.app_handle().exit(0);
}
} else {
window.app_handle().exit(0);
}
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ga_desktop_lib::run()
}
@@ -0,0 +1,41 @@
{
"productName": "GenericAgent",
"version": "0.1.0",
"identifier": "com.genericagent.app",
"build": {
"frontendDist": "../static"
},
"app": {
"withGlobalTauri": true,
"security": {
"csp": null
},
"windows": [
{
"title": "GenericAgent",
"label": "main",
"width": 1280,
"height": 800,
"resizable": true,
"maximized": false,
"visible": false,
"url": "loading.html"
},
{
"title": "GenericAgent — Setup",
"label": "setup",
"width": 600,
"height": 580,
"resizable": false,
"visible": false,
"center": true,
"url": "fallback.html"
}
]
},
"bundle": {
"active": true,
"targets": ["nsis", "dmg"],
"icon": ["icons/icon.ico", "icons/icon.png", "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
# Desktop Font Assets
These font files are the offline Latin bundle for the vanilla desktop shell.
## Files
- `azonix-wordmark.woff2`
- Role: brand wordmark only
- Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf`
- License: owner-provided / verify before external redistribution
- Note: this repo currently treats Azonix as a project-local brand asset
- `lexend-latin.woff2`
- Role: English titles and navigation
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/specimen/Lexend>
- Downloaded: 2026-05-25
- License: SIL Open Font License 1.1
- `noto-sans-latin.woff2`
- Role: English body copy and default Latin UI text
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/noto/specimen/Noto+Sans>
- Downloaded: 2026-05-25
- License: SIL Open Font License 1.1
- `jetbrains-mono-latin.woff2`
- Role: config/value dense controls and numeric surfaces
- Source: Google Fonts CSS2 API, specimen page <https://fonts.google.com/specimen/JetBrains+Mono>
- Downloaded: 2026-05-25
- License: SIL Open Font License 1.1
## Notes
- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack.
- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell.
@@ -0,0 +1,21 @@
@font-face {
font-family: "GA Azonix";
src:
url("./azonix-wordmark.woff2") format("woff2"),
local("Azonix"),
local("Azonix Regular"),
local("Azonix-Regular");
font-style: normal;
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}
@font-face {
font-family: "GA JetBrains Mono";
src: url("./jetbrains-mono-latin.woff2") format("woff2");
font-style: normal;
font-weight: 400 600;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>GenericAgent — Setup</title>
<style>
:root{--bg:#fff;--text:#0a0a0a;--muted:#6b6b6b;--border:#e8e8e8;--accent:#0a0a0a;--accent-text:#fff;--radius:10px;--font:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif}
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--text:#ededed;--muted:#a0a0a0;--border:#2a2a2a;--accent:#ededed;--accent-text:#0a0a0a}}
*{box-sizing:border-box}
html,body{margin:0;height:100%;font-family:var(--font);background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center}
.card{max-width:480px;width:90%;padding:2.5rem;border:1px solid var(--border);border-radius:var(--radius);text-align:center}
h1{font-size:1.4rem;margin:0 0 .5rem}
p{color:var(--muted);font-size:.9rem;margin:.4rem 0;line-height:1.5}
.input-row{display:flex;gap:8px;margin-top:1.2rem}
input[type=text]{flex:1;padding:10px 12px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:var(--bg);color:var(--text);outline:none}
input[type=text]:focus{border-color:var(--accent)}
button{padding:10px 18px;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;background:var(--accent);color:var(--accent-text);font-weight:500}
button:hover{opacity:.85}
button:disabled{opacity:.4;cursor:not-allowed}
.field-label{display:block;text-align:left;font-size:.8rem;font-weight:500;margin-top:1rem;margin-bottom:.3rem;color:var(--text)}
#status{margin-top:1rem;font-size:.85rem;min-height:1.2em}
.err{color:#dc2626}.ok{color:#16a34a}
</style>
</head>
<body>
<div class="card">
<h1>⚙️ Setup Required</h1>
<p>The backend could not start. Please configure the paths below.</p>
<p>后端启动失败,请配置以下路径。</p>
<label class="field-label">Python interpreter 解释器路径</label>
<div class="input-row">
<input id="pypath" type="text" placeholder="python / path to interpreter" spellcheck="false">
</div>
<p style="color:var(--muted);font-size:.75rem;margin-top:.3rem">
e.g. <code>C:/Python312/python.exe</code> / <code>~/miniconda3/envs/myenv/bin/python</code>
</p>
<label class="field-label">Project directory 项目目录</label>
<div class="input-row">
<input id="projdir" type="text" placeholder="path to GenericAgent folder" spellcheck="false">
</div>
<p style="color:var(--muted);font-size:.75rem;margin-top:.3rem">
The folder containing <code>frontends/desktop_bridge.py</code>
</p>
<button id="start-btn" onclick="doStart()" style="margin-top:1.2rem;width:100%">Start 启动</button>
<div id="status"></div>
</div>
<script>
const {invoke} = window.__TAURI__.core;
const {getCurrentWindow, Window} = window.__TAURI__.window;
const statusEl = document.getElementById('status');
const btn = document.getElementById('start-btn');
const pyInput = document.getElementById('pypath');
const dirInput = document.getElementById('projdir');
// Pre-fill from Rust config discovery
invoke('get_config').then(([py, dir]) => {
if (py) pyInput.value = py;
if (dir) dirInput.value = dir;
}).catch(()=>{});
async function doStart() {
const pythonPath = pyInput.value.trim();
const projectDir = dirInput.value.trim();
if (!pythonPath) { statusEl.className='err'; statusEl.textContent='Please enter Python path / 请输入 Python 路径'; return; }
if (!projectDir) { statusEl.className='err'; statusEl.textContent='Please enter project directory / 请输入项目目录'; return; }
btn.disabled = true;
statusEl.className=''; statusEl.textContent='Starting bridge… 正在启动…';
try {
await invoke('start_bridge_with_config', {pythonPath, projectDir});
statusEl.className='ok'; statusEl.textContent='Connected! 已连接,正在打开主窗口…';
// Show main window and hide setup
const mainWin = await Window.getByLabel('main');
if (mainWin) {
await mainWin.show();
await mainWin.setFocus();
}
const setupWin = getCurrentWindow();
await setupWin.hide();
} catch(e) {
statusEl.className='err'; statusEl.textContent='Failed: '+e;
btn.disabled = false;
}
}
pyInput.addEventListener('keydown', e=>{ if(e.key==='Enter') dirInput.focus(); });
dirInput.addEventListener('keydown', e=>{ if(e.key==='Enter') doStart(); });
</script>
<script>
// Input length limit (300 chars) — in separate script to work even without Tauri
const MAX_LEN = 300;
const _pyInput = document.getElementById('pypath');
const _dirInput = document.getElementById('projdir');
[_pyInput, _dirInput].forEach(el => {
if (!el) return;
const hint = document.createElement('span');
hint.style.cssText = 'color:#dc2626;font-size:.75rem;display:none;margin-top:2px';
el.parentNode.appendChild(hint);
function checkLimit() {
if (el.value.length > MAX_LEN) {
el.value = el.value.slice(0, MAX_LEN);
}
if (el.value.length >= MAX_LEN) {
hint.textContent = 'Character limit reached (' + MAX_LEN + ') / 已达字数上限(' + MAX_LEN + '';
hint.style.display = 'block';
} else {
hint.style.display = 'none';
}
}
// Intercept paste: only take first MAX_LEN chars from clipboard
el.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
const start = el.selectionStart;
const end = el.selectionEnd;
const remaining = MAX_LEN - el.value.length + (end - start);
const insert = text.slice(0, Math.max(0, remaining));
el.value = el.value.slice(0, start) + insert + el.value.slice(end);
el.setSelectionRange(start + insert.length, start + insert.length);
checkLimit();
});
// Fallback: keyboard input beyond limit
el.addEventListener('input', checkLimit);
});
</script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
// GenericAgent Web2 browser bridge adapter.
// HTTP is the command/data channel. WebSocket only carries small state events.
(() => {
'use strict';
const listeners = new Map();
let ws = null;
let cachedBridgeReady = null;
const bridgeBase = `${location.protocol}//${location.hostname}:14168`;
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/ws`;
function on(channel, cb) {
if (typeof cb !== 'function') return () => {};
if (!listeners.has(channel)) listeners.set(channel, new Set());
listeners.get(channel).add(cb);
if (channel === 'bridge-ready' && cachedBridgeReady) {
try { cb(cachedBridgeReady); } catch (err) { console.error('[ga-web2 listener] replay bridge-ready', err); }
}
return () => listeners.get(channel)?.delete(cb);
}
function emit(channel, payload) {
if (channel === 'bridge-ready') cachedBridgeReady = payload;
const set = listeners.get(channel);
if (!set) return;
for (const cb of Array.from(set)) {
try { cb(payload); } catch (err) { console.error('[ga-web2 listener]', channel, err); }
}
}
async function http(path, options = {}) {
const headers = Object.assign({}, options.headers || {});
const init = Object.assign({}, options, { headers });
if (init.body && typeof init.body !== 'string') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
init.body = JSON.stringify(init.body);
}
const res = await fetch(`${bridgeBase}${path}`, init);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!res.ok) {
const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
function connectWs() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
try {
ws = new WebSocket(wsUrl);
ws.addEventListener('open', () => emit('bridge-log', 'WS state channel connected'));
ws.addEventListener('message', (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (msg.type === 'bridge-ready') {
emit('bridge-ready', msg);
} else if (msg.type === 'session-state') {
emit('bridge-notification', msg);
} else if (msg.type === 'bridge-log') {
emit('bridge-log', msg.payload || msg);
} else if (msg.type === 'bridge-error') {
emit('bridge-error', msg.payload || msg);
}
});
ws.addEventListener('close', () => emit('bridge-closed', { reason: 'ws-closed' }));
ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' }));
} catch (err) {
emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
}
}
async function rpc(method, params = {}) {
switch (method) {
case 'app/status':
return http('/status');
case 'app/config/get':
return http('/config');
case 'app/config/save':
return http('/config', { method: 'POST', body: params || {} });
case 'get/model-profiles':
return http('/model-profiles');
case 'session/new':
return http('/session/new', { method: 'POST', body: params || {} });
case 'session/prompt': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/prompt missing sessionId');
return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} });
}
case 'session/poll': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/poll missing sessionId');
const after = params.afterId ?? params.after ?? 0;
const limit = params.limit ?? 200;
return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
}
case 'session/cancel': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/cancel missing sessionId');
return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} });
}
case 'app/path/open':
return http('/path/open', { method: 'POST', body: params || {} });
case 'app/path/selectGaRoot':
return http('/config');
case 'list_continuable_sessions':
return { sessions: [] };
case 'restore_session':
throw new Error('restore_session is not implemented in web2 bridge');
default:
throw new Error(`Unknown RPC method: ${method}`);
}
}
window.ga = {
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
startBridge: async () => { connectWs(); return http('/status'); },
stopBridge: async () => ({ ok: true }),
checkStatus: () => rpc('app/status', {}),
getConfig: () => rpc('app/config/get', {}),
saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
getModelProfiles: () => rpc('get/model-profiles', {}),
selectGaRoot: () => rpc('app/path/selectGaRoot', {}),
openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }),
openMykey: () => rpc('app/path/open', { kind: 'mykey' }),
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
rpc,
onBridgeMessage: (cb) => on('bridge-message', cb),
onBridgeNotification: (cb) => on('bridge-notification', cb),
onBridgeError: (cb) => on('bridge-error', cb),
onBridgeClosed: (cb) => on('bridge-closed', cb),
onBridgeReady: (cb) => on('bridge-ready', cb),
onBridgeLog: (cb) => on('bridge-log', cb),
onOpenSearch: (cb) => on('open-search', cb)
};
connectWs();
http('/status').then(status => emit('bridge-ready', status)).catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) }));
})();
+679
View File
@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<link rel="stylesheet" href="assets/fonts/fonts.css?v=2">
<link rel="stylesheet" href="styles.css?v=181">
<!-- KaTeX: LaTeX 公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<!-- highlight.js: 代码高亮 -->
<link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" crossorigin="anonymous">
<!-- Flatpickr: 日期选择器 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh.js" crossorigin="anonymous"></script>
<script>
(function(){
var t=localStorage.getItem('ga_theme'); if(t) document.documentElement.dataset.theme=t;
var l=localStorage.getItem('ga_lang'); if(l==='en') document.documentElement.lang='en';
var legacy=localStorage.getItem('ga_style');
var app=localStorage.getItem('ga_appearance');
if(!app&&legacy){
if(legacy==='dark') app='dark';
else{ app='light'; if(legacy==='classic') localStorage.setItem('ga_plain','1'); }
localStorage.setItem('ga_appearance',app);
localStorage.removeItem('ga_style');
}
app=app||'light';
document.documentElement.dataset.appearance=app;
if(app==='light'&&localStorage.getItem('ga_plain')==='1') document.documentElement.dataset.plain='1';
var LEGACY_FONT={sm:12,md:14,lg:16};
function chatFontPx(v){
if(v&&LEGACY_FONT[v]) return LEGACY_FONT[v];
var n=parseInt(v,10); if(!isNaN(n)&&n>=10&&n<=20) return n;
return 14;
}
var fs=localStorage.getItem('ga_font_size');
var cfp=chatFontPx(fs);
document.documentElement.dataset.chatFont=String(cfp);
document.documentElement.style.setProperty('--chat-font',cfp+'px');
var hljs=document.getElementById('hljs-theme');
if(hljs&&app==='dark') hljs.href='https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css';
try{ var sraw=localStorage.getItem('ga_sessions'); if(sraw && JSON.parse(sraw).length) document.documentElement.dataset.bootHasSessions='1'; }catch(_){}
})();
</script>
</head>
<body>
<div id="app">
<div class="body">
<!-- ① 左侧边栏 -->
<aside class="sidebar">
<div class="brand">
<div class="brand-text">
<div class="brand-name ga-wordmark">Generic Agent</div>
<div class="brand-sub" data-i18n="brand.sub"></div>
</div>
</div>
<nav class="nav" id="nav">
<a class="nav-item active" data-page="chat">
<span class="ic" data-ga-icon="chatTeardropText"></span>
<span data-i18n="nav.chat"></span>
</a>
<a class="nav-item" data-page="collab">
<span class="ic" data-ga-icon="gitFork"></span>
<span data-i18n="nav.collab"></span>
<span id="collab-badge" class="collab-nav-badge" hidden></span>
</a>
<a class="nav-item" data-page="services">
<span class="ic" data-ga-icon="broadcast"></span>
<span data-i18n="nav.services"></span>
</a>
<a class="nav-item" data-page="token">
<span class="ic" data-ga-icon="coins"></span>
<span data-i18n="nav.token"></span>
</a>
</nav>
<!-- 快速接入官方模型(DeepSeek / 通义千问):填好 API Key 即可使用 -->
<div class="provider-quickstart" id="provider-quickstart">
<div class="pq-head" id="pq-toggle" role="button" tabindex="0" aria-expanded="true">
<span class="fr-l">
<span class="ic" data-ga-icon="lightning"></span>
<span class="pq-title" data-i18n="pq.title"></span>
</span>
<span class="pq-collapse chev" data-ga-icon="caretRight" aria-hidden="true"></span>
</div>
<div class="pq-body">
<button type="button" class="pq-btn" data-provider="deepseek">
<span class="pq-logo" style="background:rgba(77,107,254,.12)" aria-hidden="true"><svg viewBox="0 0 24 24" fill="#4D6BFE" xmlns="http://www.w3.org/2000/svg"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg></span>
<span class="pq-btn-text">
<span class="pq-btn-name">DeepSeek</span>
<span class="pq-btn-desc" data-i18n="pq.deepseekDesc"></span>
</span>
<span class="pq-arrow" data-ga-icon="caretRight"></span>
</button>
<button type="button" class="pq-btn" data-provider="qwen">
<span class="pq-logo" style="background:rgba(97,92,237,.12)" aria-hidden="true"><svg viewBox="0 0 24 24" fill="#615CED" xmlns="http://www.w3.org/2000/svg"><path d="M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg></span>
<span class="pq-btn-text">
<span class="pq-btn-name">通义千问</span>
<span class="pq-btn-desc" data-i18n="pq.qwenDesc"></span>
</span>
<span class="pq-arrow" data-ga-icon="caretRight"></span>
</button>
</div>
</div>
<div class="sidebar-foot">
<a class="foot-row" id="settings-btn">
<span class="fr-l">
<span class="ic" data-ga-icon="gear"></span>
<span data-i18n="foot.settings"></span>
</span>
<span class="chev" data-ga-icon="caretRight"></span>
</a>
<div class="foot-ver" data-i18n="foot.ver"></div>
</div>
</aside>
<div class="sb-resize" id="sb-resize"></div>
<!-- 中间主区 -->
<main class="main">
<div id="pages">
<!-- 聊天 -->
<section class="page active page--chat-ui" data-page="chat">
<div class="page-top">
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
<span data-ga-icon="caretLeft"></span>
</button>
<button class="run-state" id="run-toggle" type="button">
<i class="dot"></i> <span class="rs-label"></span>
</button>
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
<span data-ga-icon="caretRight"></span>
</button>
</div>
<div class="chat-shell">
<div class="chat-panel">
<div class="msg-area">
<div class="chat-start">
<div class="feature-head">
<div class="page-title" data-i18n="chat.startTitle"></div>
<div class="page-sub" data-i18n="chat.startSub"></div>
</div>
<div class="feature-grid"></div>
</div>
<div class="session-loading" id="session-loading" hidden aria-live="polite">
<span class="ga-spin" aria-hidden="true"></span>
<span data-i18n="chat.sessionLoading"></span>
</div>
<div class="msg-loading" id="msg-loading" hidden aria-live="polite">
<span class="ga-spin" aria-hidden="true"></span>
<span data-i18n="chat.interrupting"></span>
</div>
</div>
<div class="composer-anchor" id="chat-composer-anchor">
<div class="composer" id="chat-composer" data-composer-ctx="chat">
<div class="composer-slot">
<div class="plan-card" id="plan-bar" hidden aria-live="polite"></div>
<div class="composer-inset">
<div class="thumb-strip" id="thumb-strip" hidden></div>
<div class="input" id="chat-input" contenteditable="true" role="textbox" aria-multiline="true" data-i18n-ph="composer.placeholder"></div>
<div class="composer-bot">
<div class="composer-plus-wrap">
<button type="button" class="ic-btn composer-plus" id="chat-plus-btn" aria-haspopup="menu" aria-expanded="false" data-i18n-title="collab.plusMenu" title="">
<span data-ga-icon="plus"></span>
</button>
<div class="ga-menu composer-menu" id="chat-menu" hidden role="menu">
<button type="button" class="ga-menu-item" data-composer-action="upload" role="menuitem">
<span data-ga-icon="paperclip"></span>
<span data-i18n="upload.button"></span>
</button>
<button type="button" class="ga-menu-item" data-composer-action="preset" role="menuitem">
<span data-ga-icon="lightning"></span>
<span data-i18n="modal.preset"></span>
</button>
</div>
</div>
<span class="spacer"></span>
<button type="button" class="chip" id="model-chip" data-i18n-title="model.menuLabel" title=""><span class="model-name"></span> <span class="chip-ic" data-ga-icon="caretDown"></span></button>
<button type="button" class="send" id="send-btn" aria-label="send">
<span data-ga-icon="paperPlaneTilt"></span>
</button>
</div>
</div>
</div>
<div class="ga-menu" id="model-menu" hidden></div>
<input type="file" id="chat-file-input" multiple hidden>
</div>
</div>
</div>
</div>
</section>
<!-- 后台服务:消息通道 + 状态面板 -->
<section class="page" data-page="services">
<div class="page-top">
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
<span data-ga-icon="caretLeft"></span>
</button>
<span class="pt-spacer"></span>
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
<span data-ga-icon="caretRight"></span>
</button>
</div>
<div class="page-pad">
<div class="page-title" data-i18n="page.services.title"></div>
<div class="svc-tabs" id="svc-tabs">
<button type="button" class="svc-tab active" data-tab="channels" data-i18n="page.channels.title"></button>
<button type="button" class="svc-tab" data-tab="status" data-i18n="page.status.title"></button>
</div>
<div class="svc-panel active" data-svc-panel="channels">
<div class="list" id="chan-list"></div>
<div class="list-empty" id="chan-empty" hidden data-i18n="ch.empty"></div>
</div>
<div class="svc-panel" data-svc-panel="status">
<div class="list" id="status-list"></div>
</div>
</div>
</section>
<!-- 指挥家 Conductor(对话区结构复用 page--chat-ui / chat-shell -->
<section class="page page--chat-ui" data-page="collab">
<div class="page-top">
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
<span data-ga-icon="caretLeft"></span>
</button>
<div class="page-top-center">
<button class="run-state" id="collab-run-toggle" type="button">
<i class="dot"></i> <span class="rs-label"></span>
</button>
<button type="button" class="pt-icon collab-retry-btn" id="collab-retry" hidden data-i18n-aria-label="collab.retry" aria-label="retry">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
</div>
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
<span data-ga-icon="caretRight"></span>
</button>
</div>
<div class="chat-shell">
<div class="chat-panel">
<div class="msg-area collab-msgs" id="collab-msgs">
<div class="collab-scroll">
<div class="collab-guide" id="collab-welcome" hidden>
<div class="feature-head">
<div class="page-title" data-i18n="collab.guideTitle"></div>
<p class="page-sub" data-i18n="collab.guideWhen"></p>
</div>
<div class="collab-guide-steps">
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="chatTeardropText"></span></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep1t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep1d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="gridFour"></span></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep2t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep2d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="listChecks"></span></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep3t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep3d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><span data-ga-icon="pencilSimple"></span></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep4t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep4d"></span></span>
</div>
</div>
</div>
<div class="msgs" id="collab-msg-list" hidden></div>
</div>
<div class="collab-overlay">
<div class="collab-rail" id="collab-rail" hidden>
<button type="button" class="collab-rail-handle" id="collab-rail-toggle" data-i18n-title="collab.showProgressTitle" title="">
<span class="collab-rail-grip" aria-hidden="true"></span>
<svg class="collab-rail-chev" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"/></svg>
<span class="collab-rail-badge collab-rail-run" id="collab-rail-run" hidden>
<span class="collab-rail-spin" aria-hidden="true"></span>
<span class="n" id="collab-rail-run-n">0</span>
</span>
<span class="collab-rail-badge collab-rail-done" id="collab-rail-done" hidden>
<span class="collab-rail-dot" aria-hidden="true"></span>
<span class="n" id="collab-rail-done-n">0</span>
</span>
<span class="collab-rail-badge collab-rail-issue" id="collab-rail-issue" hidden>
<span class="collab-rail-dot collab-rail-dot--danger" aria-hidden="true"></span>
<span class="n" id="collab-rail-issue-n">0</span>
</span>
</button>
</div>
<aside class="collab-prog-panel" id="collab-prog-panel">
<div class="collab-prog-panel-head">
<span data-i18n="collab.progressTitle"></span>
<button type="button" class="ic-btn collab-prog-close" id="collab-prog-close" aria-label="close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
</button>
</div>
<div class="collab-prog-stats" id="collab-progress-stats" hidden></div>
<div class="collab-progress-empty" id="collab-progress-empty" data-i18n="collab.progressEmpty"></div>
<div id="collab-workers"></div>
</aside>
</div>
</div>
<div class="collab-quick" id="collab-quick">
<button type="button" class="pill" data-prompt-key="collab.chipProgress" data-i18n="collab.chipProgress"></button>
<button type="button" class="pill" data-prompt-key="collab.chipPause" data-i18n="collab.chipPause"></button>
<button type="button" class="pill" data-prompt-key="collab.chipSummary" data-i18n="collab.chipSummary"></button>
</div>
<div class="composer-anchor" id="cdb-composer-anchor">
<div class="composer" id="cdb-composer" data-composer-ctx="collab">
<div class="composer-slot">
<div class="composer-inset">
<div class="thumb-strip" id="cdb-thumb-strip" hidden></div>
<div class="input" id="cdb-input" contenteditable="true" role="textbox" aria-multiline="true" data-i18n-ph="collab.placeholder"></div>
<div class="composer-bot">
<div class="composer-plus-wrap">
<button type="button" class="ic-btn composer-plus" id="cdb-plus-btn" aria-haspopup="menu" aria-expanded="false" data-i18n-title="collab.plusMenu" title="">
<span data-ga-icon="plus"></span>
</button>
<div class="ga-menu composer-menu" id="cdb-menu" hidden role="menu">
<button type="button" class="ga-menu-item" data-composer-action="upload" role="menuitem">
<span data-ga-icon="paperclip"></span>
<span data-i18n="upload.button"></span>
</button>
</div>
</div>
<span class="spacer"></span>
<button type="button" class="chip" id="cdb-model-chip" data-i18n-title="model.menuLabel" title="">
<span class="model-name"></span>
<span class="chip-ic" data-ga-icon="caretDown"></span>
</button>
<button type="button" class="send" id="cdb-send" aria-label="send">
<span data-ga-icon="paperPlaneTilt"></span>
</button>
</div>
</div>
</div>
<div class="ga-menu" id="cdb-model-menu" hidden></div>
<input type="file" id="cdb-file-input" multiple hidden>
</div>
</div>
</div>
</div>
</section>
<!-- Token 统计 -->
<section class="page" data-page="token">
<div class="page-top">
<button type="button" class="pt-icon pt-sb-toggle" aria-label="toggle sidebar">
<span data-ga-icon="caretLeft"></span>
</button>
<span class="pt-spacer"></span>
<button type="button" class="pt-icon pt-rp-toggle" aria-label="toggle conversations">
<span data-ga-icon="caretRight"></span>
</button>
</div>
<div class="page-pad">
<div class="page-title" data-i18n="page.token.title"></div>
<div class="tok-tabs" id="tok-tabs">
<button class="tok-tab active" data-tab="chat" data-i18n="tok.tabAll"></button>
<button class="tok-tab" data-tab="conductor" data-i18n="tok.tabConductor"></button>
</div>
<div class="tok-filter">
<label data-i18n="tok.from"></label>
<input type="text" id="tok-since" class="tok-date" readonly>
<label data-i18n="tok.to"></label>
<input type="text" id="tok-until" class="tok-date" readonly>
<button class="tok-reset" id="tok-reset" data-i18n="tok.reset"></button>
</div>
<div class="stat-row">
<div class="stat"><div class="s-n" id="tok-total-n">0</div><div class="s-l" data-i18n="tok.total"></div></div>
<div class="stat"><div class="s-n" id="tok-cost-n">0%</div><div class="s-l" data-i18n="tok.cost"></div></div>
<div class="stat"><div class="s-n" id="tok-today-n">0</div><div class="s-l" data-i18n="tok.today"></div></div>
</div>
<table class="tok-table" id="tok-table">
<thead><tr>
<th data-i18n="tok.colSession"></th>
<th data-i18n="tok.colIn"></th>
<th data-i18n="tok.colOut"></th>
<th data-i18n="tok.colCacheW"></th>
<th data-i18n="tok.colCache"></th>
<th data-i18n="tok.cost"></th>
</tr></thead>
<tbody id="tok-tbody"></tbody>
</table>
<div class="tok-pager" id="tok-pager"></div>
</div>
</section>
</div>
</main>
<!-- ④ 右侧会话面板 -->
<div class="rp-resize" id="rp-resize"></div>
<aside class="rightpanel" id="rightpanel">
<div class="rp-head">
<div class="search">
<span class="search-ic" data-ga-icon="magnifyingGlass"></span>
<input data-i18n-ph="search.placeholder" placeholder="" maxlength="50">
</div>
</div>
<button type="button" class="new-conv">
<span data-ga-icon="plus"></span>
<span data-i18n="conv.new"></span>
</button>
<div class="conv-list"></div>
<!-- 会话操作菜单 -->
<div class="ctx-menu" id="conv-menu" hidden>
<div class="ctx-item" data-act="pin">
<span data-ga-icon="pushPinSimple"></span>
<span data-i18n="ctx.pin"></span>
</div>
<div class="ctx-item" data-act="rename">
<span data-ga-icon="pencilSimple"></span>
<span data-i18n="ctx.rename"></span>
</div>
<div class="ctx-item danger" data-act="del">
<span data-ga-icon="trash"></span>
<span data-i18n="ctx.del"></span>
</div>
</div>
</aside>
</div>
<!-- 预设功能 弹窗 -->
<div class="modal" id="preset-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card" role="dialog">
<div class="modal-head">
<div class="modal-title" data-i18n="modal.preset"></div>
<button id="preset-restore-btn" class="preset-restore" type="button" data-i18n="builtinPreset.restoreBtn" hidden></button>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body preset-pop">
<div class="feature-grid"></div>
</div>
</div>
</div>
<!-- 添加模型 弹窗(模板,未实现)-->
<div class="modal" id="add-model-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card" role="dialog">
<div class="modal-head">
<div class="modal-title" id="model-form-title" data-i18n="modal.addModel"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body model-form-body">
<div class="model-guide" id="model-guide" hidden>
<div class="model-guide-head">
<span class="model-guide-logo" id="model-guide-logo" aria-hidden="true"></span>
<span class="model-guide-name" id="model-guide-name"></span>
</div>
<ol class="model-guide-steps">
<li data-i18n="guide.step1"></li>
<li data-i18n="guide.step2"></li>
<li data-i18n="guide.step3"></li>
</ol>
<div class="model-guide-link-row">
<a class="model-guide-link" id="model-guide-link" href="#" target="_blank" rel="noopener noreferrer"></a>
<button type="button" class="model-guide-copy" id="model-guide-copy" data-i18n-title="guide.copy" title="" aria-label="copy">
<span data-ga-icon="copy"></span>
</button>
</div>
<p class="model-guide-tip" data-i18n="guide.prefillTip"></p>
</div>
<form id="add-model-form" class="model-form" autocomplete="off">
<label><span class="field-label"><span data-i18n="model.model"></span><span class="field-req" aria-hidden="true">*</span></span><input name="model" required data-i18n-ph="model.modelPh" maxlength="50"><span class="field-hint" data-i18n="model.modelHint"></span></label>
<label id="model-apikey-label"><span class="field-label"><span data-i18n="model.apikey"></span><span class="field-req" aria-hidden="true">*</span></span><input name="apikey" type="password" data-i18n-ph="model.apikeyPh" id="model-apikey-input" maxlength="200"></label>
<label><span class="field-label"><span data-i18n="model.apibase"></span><span class="field-req" aria-hidden="true">*</span></span><input name="apibase" required data-i18n-ph="model.apibasePh" maxlength="200"></label>
<div class="model-form-field"><span class="field-label"><span data-i18n="model.protocol"></span><span class="field-req" aria-hidden="true">*</span></span>
<div class="seg-group" role="radiogroup">
<label class="seg-opt"><input type="radio" name="protocol" value="oai" required><span data-i18n="model.protocolOai"></span></label>
<label class="seg-opt"><input type="radio" name="protocol" value="claude" required><span data-i18n="model.protocolClaude"></span></label>
</div>
</div>
<div class="model-form-field"><span class="field-label"><span data-i18n="model.stream"></span></span>
<div class="seg-group" role="radiogroup">
<label class="seg-opt"><input type="radio" name="stream" value="true" checked><span data-i18n="model.streamOn"></span></label>
<label class="seg-opt"><input type="radio" name="stream" value="false"><span data-i18n="model.streamOff"></span></label>
</div>
</div>
<label><span data-i18n="model.name"></span><input name="name" data-i18n-ph="model.namePh" data-optional-ph maxlength="50"></label>
<div class="model-form-row">
<label class="model-form-mini"><span data-i18n="model.retries"></span><input name="max_retries" type="text" inputmode="numeric" pattern="[0-9]*" min="1" max="20" value="5"></label>
<label class="model-form-mini"><span data-i18n="model.connTimeout"></span><input name="connect_timeout" type="text" inputmode="numeric" pattern="[0-9]*" min="1" max="300" value="15"></label>
<label class="model-form-mini"><span data-i18n="model.readTimeout"></span><input name="read_timeout" type="text" inputmode="numeric" pattern="[0-9]*" min="5" max="3600" value="300"></label>
</div>
<p class="model-form-err" id="add-model-err" hidden></p>
<div class="model-form-actions">
<button type="button" class="set-btn ghost" data-close data-i18n="common.cancel"></button>
<button type="submit" class="set-btn primary"><span data-i18n="model.save"></span></button>
</div>
</form>
</div>
</div>
</div>
<div class="modal" id="confirm-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card confirm-card" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-head">
<div class="modal-title" id="confirm-title"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body confirm-body">
<div class="confirm-message" id="confirm-message"></div>
<div class="confirm-actions">
<button type="button" class="set-btn ghost" id="confirm-cancel"></button>
<button type="button" class="set-btn primary" id="confirm-ok"></button>
</div>
</div>
</div>
</div>
<!-- 自定义预设 弹窗 -->
<div class="modal" id="custom-preset-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card" role="dialog">
<div class="modal-head">
<div class="modal-title" data-i18n="modal.customPreset"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body cp-body">
<input class="cp-input" id="cp-title" type="text" data-i18n-ph="customPreset.titlePh" placeholder="" maxlength="50">
<textarea class="cp-textarea" id="cp-prompt" rows="6" data-i18n-ph="customPreset.promptPh" placeholder="" maxlength="10000"></textarea>
<div class="cp-error" id="cp-error" hidden></div>
<div class="cp-actions">
<button class="cp-btn" data-close data-i18n="common.cancel" type="button"></button>
<button class="cp-btn cp-btn-primary" id="cp-save" data-i18n="common.save" type="button"></button>
</div>
</div>
</div>
</div>
<!-- 渠道日志 弹窗 -->
<div class="modal" id="chan-log-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card modal-card-wide modal-card-inset" role="dialog">
<div class="modal-head">
<div class="modal-title" id="chan-log-title" data-i18n="modal.channelLogs"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body modal-body-inset">
<pre class="chan-log-pre" id="chan-log-pre"></pre>
</div>
</div>
</div>
<!-- mykey 配置 弹窗 -->
<div class="modal" id="chan-config-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card modal-card-wide modal-card-inset" role="dialog">
<div class="modal-head">
<div class="modal-title" id="chan-config-title" data-i18n="modal.mykeyConfig"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body modal-body-inset chan-config-body">
<textarea id="chan-config-editor" class="mykey-editor" spellcheck="false" maxlength="1000000"></textarea>
<div class="chan-config-foot">
<button type="button" class="chan-config-save" id="chan-config-save" data-i18n="common.save"></button>
</div>
</div>
</div>
</div>
<!-- 配置 弹窗 -->
<div class="modal" id="settings-modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card" role="dialog">
<div class="modal-head">
<div class="modal-title" data-i18n="modal.settings"></div>
<button class="modal-x" data-close data-i18n-title="common.close" title="">
<span data-ga-icon="x"></span>
</button>
</div>
<div class="modal-body set-body">
<div class="set-block">
<div class="set-sec-t" data-i18n="set.appearance"></div>
<div class="appear-cards" id="appearance-seg" role="radiogroup">
<button class="appear-card sel" type="button" data-appearance="light" role="radio" aria-checked="true">
<span class="appear-preview appear-preview-light" aria-hidden="true"></span>
<span class="appear-name" data-i18n="appearance.light"></span>
</button>
<button class="appear-card" type="button" data-appearance="dark" role="radio" aria-checked="false">
<span class="appear-preview appear-preview-dark" aria-hidden="true"></span>
<span class="appear-name" data-i18n="appearance.dark"></span>
</button>
</div>
<div class="set-toggle-row" id="plain-ui-row" hidden>
<span class="set-toggle-label" data-i18n="set.plainUi"></span>
<button class="set-switch" type="button" id="plain-ui-switch" role="switch" aria-checked="false">
<span class="set-switch-knob" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="set-block">
<div class="set-sec-t" id="chat-font-label" data-i18n="set.fontSize"></div>
<div class="chat-font-panel">
<div class="chat-font-stepper" id="chat-font-stepper" role="slider"
aria-valuemin="10" aria-valuemax="20" aria-valuenow="14"
aria-valuetext="14px" aria-labelledby="chat-font-label" tabindex="0">
<div class="chat-font-segments" id="chat-font-segments"></div>
</div>
<span class="chat-font-value" id="chat-font-value" aria-hidden="true">14px</span>
<div class="chat-font-scale" aria-hidden="true">
<span class="chat-font-scale-min">10</span>
<span class="chat-font-scale-max">20</span>
</div>
</div>
</div>
<div class="set-block">
<div class="set-sec-t" data-i18n="set.lang"></div>
<div class="model-list lang-list" id="lang-list"></div>
</div>
<div class="set-block">
<div class="set-sec-t" data-i18n="set.model"></div>
<div class="model-list" id="model-list"></div>
<button class="set-btn" id="add-model-btn" type="button">
<span data-ga-icon="plus"></span>
<span data-i18n="set.addModel"></span>
</button>
</div>
<div class="set-block">
<div class="set-sec-t" data-i18n="set.features"></div>
<button class="set-btn" id="import-mykey-btn" type="button">
<span data-ga-icon="fileText"></span>
<span data-i18n="set.importMykey"></span>
</button>
<input type="file" id="import-mykey-input" accept=".py,text/plain" hidden>
<button class="set-btn set-btn-follow" id="export-mykey-btn" type="button">
<span data-ga-icon="folderSimple"></span>
<span data-i18n="set.exportMykey"></span>
</button>
<button class="set-btn set-btn-follow" id="settings-services-btn" type="button">
<span data-ga-icon="broadcast"></span>
<span data-i18n="set.serviceManager"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 图片预览 lightbox -->
<div class="lightbox" id="lightbox" hidden>
<div class="lightbox-backdrop" data-close></div>
<button class="lightbox-x" data-close data-i18n-title="lightbox.closeTitle" title="">×</button>
<img class="lightbox-img" id="lightbox-img" alt="">
</div>
<div id="toast-root" class="toast-root" aria-live="polite"></div>
<script src="vendor/marked.min.js"></script>
<!-- KaTeX JS -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<!-- highlight.js -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="phosphor-icons.js?v=2"></script>
<script src="app.js?v=208"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<style>
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI","Microsoft YaHei UI","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
@media(prefers-color-scheme:dark){:root{background:#1a1a1a;color:#eee}}
html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center}
.card{text-align:center;opacity:.85;width:280px}
.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#ga-status{font-size:13px;line-height:1.5}
.bar{display:none;width:240px;height:6px;margin:14px auto 0;background:#9993;border-radius:99px;overflow:hidden}
.bar.show{display:block}
#ga-bar{height:100%;width:0;background:currentColor;border-radius:99px;transition:width .3s ease}
#ga-bar.indet{width:35%;animation:indet 1.1s ease-in-out infinite}
@keyframes indet{0%{margin-left:-35%}100%{margin-left:100%}}
</style>
<script>
// Loading window shows before the main UI, on a different origin than the app, so it can't
// read the app's saved language; fall back to the system/browser locale.
var GA_ZH = (navigator.language || '').toLowerCase().indexOf('zh') === 0;
var GA_MSG = {
starting_app: GA_ZH ? '正在启动 GenericAgent…' : 'Starting GenericAgent…',
start: GA_ZH ? '首次启动,正在准备运行环境…' : 'First run: preparing the runtime…',
venv: GA_ZH ? '正在创建运行环境…' : 'Creating the runtime environment…',
deps: GA_ZH ? '正在安装依赖…' : 'Installing dependencies…',
done: GA_ZH ? '依赖安装完成' : 'Dependencies installed',
starting: GA_ZH ? '正在启动服务…' : 'Starting services…'
};
window.gaProgress = function(pct, key){
var s = document.getElementById('ga-status');
if (s) s.textContent = GA_MSG[key] || key;
var bar = document.querySelector('.bar');
var fill = document.getElementById('ga-bar');
if (bar) bar.classList.add('show');
if (fill){
if (pct < 0){ fill.classList.add('indet'); }
else { fill.classList.remove('indet'); fill.style.width = Math.max(0, Math.min(100, pct)) + '%'; }
}
};
document.addEventListener('DOMContentLoaded', function(){
var s = document.getElementById('ga-status');
if (s) s.textContent = GA_MSG.starting_app;
});
</script>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<div id="ga-status">正在启动 GenericAgent…</div>
<div class="bar"><div id="ga-bar"></div></div>
</div>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
(() => {
const PATHS = {
chatTeardropText:
'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z',
broadcast:
'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z',
chartBar:
'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z',
usersThree:
'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z',
coins:
'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z',
sidebarSimple:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z',
pencilSimple:
'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z',
magnifyingGlass:
'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z',
books:
'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z',
gridFour:
'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z',
robot:
'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z',
dotsThree:
'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z',
folderSimplePlus:
'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z',
folderSimple:
'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z',
bracketsCurly:
'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z',
gear:
'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z',
caretLeft:
'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z',
caretDown:
'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z',
caretRight:
'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z',
paperPlaneTilt:
'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z',
paperclip:
'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z',
lightning:
'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z',
pushPinSimple:
'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z',
trash:
'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z',
x:
'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z',
dotsThreeVertical:
'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z',
plus:
'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z',
copy:
'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z',
check:
'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z',
crosshair:
'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z',
compass:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z',
listChecks:
'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z',
star:
'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z',
fileText:
'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z',
gitFork:
'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z',
hexagon:
'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z',
};
function gaIcon(name, className = '') {
const d = PATHS[name];
if (!d) return '';
const cls = className ? ` class="${className}"` : '';
return `<svg${cls} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" focusable="false"><path d="${d}"/></svg>`;
}
function gaHydrateIcons(root = document) {
root.querySelectorAll('[data-ga-icon]').forEach((node) => {
node.outerHTML = gaIcon(node.dataset.gaIcon, node.className || '');
});
}
window.GA_ICON_PATHS = PATHS;
window.gaIcon = gaIcon;
window.gaHydrateIcons = gaHydrateIcons;
if (typeof document !== 'undefined') {
const hydrate = () => gaHydrateIcons(document);
hydrate();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrate, { once: true });
}
}
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
import asyncio, json, os, sys, threading, time
import requests
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from agentmain import GeneraticAgent
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
from llmcore import mykeys
try:
from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient
from dingtalk_stream.chatbot import ChatbotMessage
except Exception:
print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
sys.exit(1)
agent = GeneraticAgent(); agent.verbose = False
CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip()
CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()}
USER_TASKS = {}
class DingTalkApp(AgentChatMixin):
label, source, split_limit = "DingTalk", "dingtalk", 1800
def __init__(self):
super().__init__(agent, USER_TASKS)
self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
async def _get_access_token(self):
if self.access_token and time.time() < self.token_expiry:
return self.access_token
def _fetch():
resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
resp.raise_for_status()
return resp.json()
last_err = None
for attempt in range(2):
try:
data = await asyncio.to_thread(_fetch)
self.access_token = data.get("accessToken")
self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
return self.access_token
except Exception as e:
last_err = e
if attempt == 0:
await asyncio.sleep(1)
print(f"[DingTalk] token error after retry: {last_err}")
return None
async def _send_batch_message(self, chat_id, msg_key, msg_param):
token = await self._get_access_token()
if not token:
return False
headers = {"x-acs-dingtalk-access-token": token}
if chat_id.startswith("group:"):
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
else:
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
def _post():
resp = requests.post(url, json=payload, headers=headers, timeout=20)
body = resp.text
if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
errcode = result.get("errcode")
if errcode not in (None, 0):
raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
return True
try:
return await asyncio.to_thread(_post)
except Exception as e:
print(f"[DingTalk] send error: {e}")
return False
async def send_text(self, chat_id, content):
for part in split_text(content, self.split_limit):
await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
try:
if not content:
return
if not public_access(ALLOWED) and sender_id not in ALLOWED:
print(f"[DingTalk] unauthorized user: {sender_id}")
return
is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id
print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
if content.startswith("/"):
return await self.handle_command(chat_id, content)
task = asyncio.create_task(self.run_agent(chat_id, content))
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
except Exception:
import traceback
print("[DingTalk] handle_message error")
traceback.print_exc()
async def start(self):
self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
print("[DingTalk] bot starting...")
delay, max_delay = 5, 300
while True:
started_at = time.monotonic()
try:
await self.client.start()
except Exception as e:
print(f"[DingTalk] stream error: {e}")
# any session that lived >=60s is treated as healthy -> reset backoff
if time.monotonic() - started_at >= 60:
delay = 5
print(f"[DingTalk] reconnect in {delay}s...")
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
class _DingTalkHandler(CallbackHandler):
def __init__(self, app):
super().__init__()
self.app = app
async def process(self, message):
try:
chatbot_msg = ChatbotMessage.from_dict(message.data)
text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
extensions = getattr(chatbot_msg, "extensions", None) or {}
recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
if not (text := text.strip()):
text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
sender_id = str(getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown")
sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown"
await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
except Exception as e:
print(f"[DingTalk] callback error: {e}")
return AckMessage.STATUS_OK, "OK"
if __name__ == "__main__":
_LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
threading.Thread(target=agent.run, daemon=True).start()
asyncio.run(DingTalkApp().start())
+78
View File
@@ -0,0 +1,78 @@
"""`/export` command: export last assistant reply / locate full conversation log.
Pure functions, no Qt deps. UI wiring lives in the frontend file.
"""
import os
import re
import sys
from datetime import datetime
from continue_cmd import _pairs, _assistant_text
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
_BACKTICK_RUN_RE = re.compile(r'`+')
def wrap_for_clipboard(text, language='markdown'):
"""Wrap text in a markdown code fence that survives nested fences.
CommonMark closes a fenced block on a line whose backtick run is at least
as long as the opening one. Pick `max(3, longest_inner_run + 1)` so any
backticks inside `text` are strictly shorter than the outer fence.
"""
longest = max((len(m.group(0)) for m in _BACKTICK_RUN_RE.finditer(text)), default=0)
fence = '`' * max(3, longest + 1)
return f"{fence}{language}\n{text}\n{fence}"
def last_assistant_text(agent):
"""Last assistant reply as joined plain text from `agent.log_path`.
Reads the model_responses log, takes the most recent === Response === block,
and joins only the `text` fields (skips thinking/tool_use/tool_result).
Returns None when:
- the LLM backend's history is empty (fresh agent or just-`/new`'d —
log_path may still hold prior-session content that no longer belongs
to the current conversation)
- the log is missing or unreadable
- there's no Response yet, or the last Response holds no text blocks
(e.g. tool-only turn)
OS errors are logged to stderr — small failure radius, the UI falls back
gracefully.
"""
if not (agent.llmclient and agent.llmclient.backend.history):
return None
log = agent.log_path
if not os.path.isfile(log):
return None
try:
with open(log, encoding='utf-8', errors='replace') as f:
content = f.read()
except OSError as e:
print(f"[export_cmd] failed to read {log}: {e}", file=sys.stderr)
return None
pairs = _pairs(content)
if not pairs:
return None
text = _assistant_text(pairs[-1][1])
return text if text.strip() else None
def export_to_temp(text, name):
"""Write text to temp/<name>.md, overwriting on collision. Returns full path.
`name` is sanitized via os.path.basename to keep the write inside temp/.
Empty/whitespace name falls back to a timestamp. `.md` is appended if
the user-supplied name has no extension.
"""
os.makedirs(_TEMP_DIR, exist_ok=True)
safe = os.path.basename((name or '').strip())
if not safe:
safe = f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
if not os.path.splitext(safe)[1]:
safe = safe + '.md'
path = os.path.join(_TEMP_DIR, safe)
with open(path, 'w', encoding='utf-8') as f:
f.write(text)
return path
+880
View File
@@ -0,0 +1,880 @@
import argparse, asyncio, importlib.util, json, os, queue as Q, re, sys, threading, time, uuid
from pathlib import Path
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
os.chdir(PROJECT_ROOT)
import traceback
import lark_oapi as lark
from lark_oapi.api.im.v1 import *
def _ensure_dir(path):
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
return path
def _workspace_root_dir():
root = os.environ.get("GA_WORKSPACE_ROOT")
if root:
return _ensure_dir(Path(root).expanduser().resolve())
return _ensure_dir(Path(PROJECT_ROOT).resolve())
def _workspace_config_dir(root=None):
base = Path(root).expanduser().resolve() if root else _workspace_root_dir()
if base.name == "ga_config":
return _ensure_dir(base)
return _ensure_dir(base / "ga_config")
def _load_dict_config(path):
path = Path(path)
if not path.exists():
return None
try:
if path.suffix == ".py":
mod_name = f"_fs_mykey_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(mod_name, path)
if not spec or not spec.loader:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data = {k: v for k, v in vars(module).items() if not k.startswith("_")}
else:
with open(path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception as e:
print(f"[ERROR] load config failed {path}: {e}")
return None
def _resolve_mykey_path():
workspace_root = _workspace_root_dir()
config_root = _workspace_config_dir(workspace_root)
candidates = [
config_root / "mykey.json",
config_root / "mykey.py",
workspace_root / "mykey.json",
workspace_root / "mykey.py",
Path(PROJECT_ROOT) / "mykey.json",
Path(PROJECT_ROOT) / "mykey.py",
]
for candidate in candidates:
if _load_dict_config(candidate):
return candidate
return candidates[0]
def _ensure_runtime_paths():
workspace_root = _workspace_root_dir()
config_root = _workspace_config_dir(workspace_root)
os.environ.setdefault("GA_WORKSPACE_ROOT", str(workspace_root))
os.environ.setdefault("GA_USER_DATA_DIR", str(config_root))
return str(workspace_root), str(config_root)
_ensure_runtime_paths()
from agentmain import GeneraticAgent
from frontends.chatapp_common import AgentChatMixin, FILE_HINT, split_text
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
_FILE_TYPE_MAP = {
".opus": "opus",
".mp4": "mp4",
".pdf": "pdf",
".doc": "doc",
".docx": "doc",
".xls": "xls",
".xlsx": "xls",
".ppt": "ppt",
".pptx": "ppt",
}
_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"}
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media")
os.makedirs(MEDIA_DIR, exist_ok=True)
_TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数
_DEDUP_TTL_SEC = 10 * 60
_DEDUP_MAX = 2000
_DEDUP_LOCK = threading.Lock()
_SEEN_MESSAGES = {}
def _claim_message_once(message_id):
"""Best-effort cross-platform dedup for Feishu reconnect redeliveries."""
if not message_id:
return True
now = time.time()
with _DEDUP_LOCK:
expired = [mid for mid, ts in _SEEN_MESSAGES.items() if now - ts > _DEDUP_TTL_SEC]
for mid in expired:
_SEEN_MESSAGES.pop(mid, None)
if len(_SEEN_MESSAGES) > _DEDUP_MAX:
for mid, _ in sorted(_SEEN_MESSAGES.items(), key=lambda item: item[1])[:len(_SEEN_MESSAGES) - _DEDUP_MAX]:
_SEEN_MESSAGES.pop(mid, None)
if message_id in _SEEN_MESSAGES:
return False
_SEEN_MESSAGES[message_id] = now
return True
def _clean(text):
for pat in _TAG_PATS:
text = re.sub(pat, "", text or "", flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def _extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def _strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def _display_text(text):
cleaned = _strip_files(_clean(text))
if cleaned:
return cleaned
tail = (text or "").strip()[-_TRUNC_TAIL:]
return "⚠️ 模型输出被截断或为空" + (f"\n{tail}" if tail else "")
def _to_allowed_set(value):
if value is None:
return set()
if isinstance(value, str):
value = [value]
return {str(x).strip() for x in value if str(x).strip()}
def _parse_json(raw):
if not raw:
return {}
try:
return json.loads(raw)
except Exception:
return {}
def _extract_share_card_content(content_json, msg_type):
parts = []
if msg_type == "share_chat":
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
elif msg_type == "share_user":
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
elif msg_type == "interactive":
parts.extend(_extract_interactive_content(content_json))
elif msg_type == "share_calendar_event":
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
elif msg_type == "system":
parts.append("[system message]")
elif msg_type == "merge_forward":
parts.append("[merged forward messages]")
return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]"
def _extract_interactive_content(content):
parts = []
if isinstance(content, str):
try:
content = json.loads(content)
except Exception:
return [content] if content.strip() else []
if not isinstance(content, dict):
return parts
title = content.get("title")
if isinstance(title, dict):
title_text = title.get("content", "") or title.get("text", "")
if title_text:
parts.append(f"title: {title_text}")
elif isinstance(title, str) and title:
parts.append(f"title: {title}")
elements = content.get("elements", [])
if isinstance(elements, list):
for row in elements:
if isinstance(row, dict):
parts.extend(_extract_element_content(row))
elif isinstance(row, list):
for el in row:
parts.extend(_extract_element_content(el))
card = content.get("card", {})
if card:
parts.extend(_extract_interactive_content(card))
header = content.get("header", {})
if isinstance(header, dict):
header_title = header.get("title", {})
if isinstance(header_title, dict):
header_text = header_title.get("content", "") or header_title.get("text", "")
if header_text:
parts.append(f"title: {header_text}")
return [p for p in parts if p]
def _extract_element_content(element):
parts = []
if not isinstance(element, dict):
return parts
tag = element.get("tag", "")
if tag in ("markdown", "lark_md"):
content = element.get("content", "")
if content:
parts.append(content)
elif tag == "div":
text = element.get("text", {})
if isinstance(text, dict):
text_content = text.get("content", "") or text.get("text", "")
if text_content:
parts.append(text_content)
elif isinstance(text, str) and text:
parts.append(text)
for field in element.get("fields", []) or []:
if isinstance(field, dict):
field_text = field.get("text", {})
if isinstance(field_text, dict):
content = field_text.get("content", "") or field_text.get("text", "")
if content:
parts.append(content)
elif tag == "a":
href = element.get("href", "")
text = element.get("text", "")
if href:
parts.append(f"link: {href}")
if text:
parts.append(text)
elif tag == "button":
text = element.get("text", {})
if isinstance(text, dict):
content = text.get("content", "") or text.get("text", "")
if content:
parts.append(content)
url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "")
if url:
parts.append(f"link: {url}")
elif tag == "img":
alt = element.get("alt", {})
if isinstance(alt, dict):
parts.append(alt.get("content", "[image]") or "[image]")
else:
parts.append("[image]")
for child in element.get("elements", []) or []:
parts.extend(_extract_element_content(child))
for col in element.get("columns", []) or []:
for child in (col.get("elements", []) if isinstance(col, dict) else []):
parts.extend(_extract_element_content(child))
return parts
def _extract_post_content(content_json):
def _parse_block(block):
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, []
texts, images = [], []
if block.get("title"):
texts.append(block.get("title"))
for row in block["content"]:
if not isinstance(row, list):
continue
for el in row:
if not isinstance(el, dict):
continue
tag = el.get("tag")
if tag in ("text", "a"):
texts.append(el.get("text", ""))
elif tag == "at":
texts.append(f"@{el.get('user_name', 'user')}")
elif tag == "img" and el.get("image_key"):
images.append(el["image_key"])
text = " ".join([t for t in texts if t]).strip()
return text or None, images
root = content_json
if isinstance(root, dict) and isinstance(root.get("post"), dict):
root = root["post"]
if not isinstance(root, dict):
return "", []
if "content" in root:
text, imgs = _parse_block(root)
if text or imgs:
return text or "", imgs
for key in ("zh_cn", "en_us", "ja_jp"):
if key in root:
text, imgs = _parse_block(root[key])
if text or imgs:
return text or "", imgs
for val in root.values():
if isinstance(val, dict):
text, imgs = _parse_block(val)
if text or imgs:
return text or "", imgs
return "", []
AGENT_TIMEOUT_SEC = 900
agent = None
agent_error = None
agent_thread = None
client, user_tasks, app = None, {}, None
agent_lock = threading.Lock()
def _load_config():
path = _resolve_mykey_path()
if not path or not path.exists():
return {}, str(path or "")
try:
data = _load_dict_config(path)
return data if isinstance(data, dict) else {}, str(path)
except Exception as e:
print(f"[ERROR] load mykey failed {path}: {e}")
return {}, str(path)
def _feishu_config():
cfg, path = _load_config()
app_id = str(cfg.get("fs_app_id", "") or "").strip()
app_secret = str(cfg.get("fs_app_secret", "") or "").strip()
allowed = _to_allowed_set(cfg.get("fs_allowed_users", []))
return app_id, app_secret, allowed, (not allowed or "*" in allowed), path
APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
def get_agent():
global agent, agent_error, agent_thread
with agent_lock:
if agent is not None:
return agent
if agent_error:
raise RuntimeError(agent_error)
try:
agent = GeneraticAgent()
agent_thread = threading.Thread(target=agent.run, daemon=True)
agent_thread.start()
return agent
except Exception as e:
agent_error = str(e)
raise
def create_client():
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
def _mask_secret(value):
value = str(value or "")
if len(value) <= 8:
return "*" * len(value)
return value[:4] + "*" * (len(value) - 8) + value[-4:]
def check_config(init_agent=False):
app_id, app_secret, allowed, public_access, path = _feishu_config()
result = {
"config_path": path,
"app_id": app_id,
"app_secret": _mask_secret(app_secret),
"app_secret_present": bool(app_secret),
"public_access": public_access,
"allowed_users": sorted(allowed),
"ready": bool(app_id and app_secret),
}
if init_agent:
try:
ga = get_agent()
result["agent_ready"] = True
result["llm_count"] = len(ga.list_llms()) if hasattr(ga, "list_llms") else 0
result["current_llm"] = ga.get_llm_name() if getattr(ga, "llmclient", None) else ""
except Exception as e:
result["agent_ready"] = False
result["agent_error"] = str(e)
return result
def _card_raw(elements):
return json.dumps({
"schema": "2.0",
"config": {"streaming_mode": False, "width_mode": "fill"},
"body": {"elements": elements},
}, ensure_ascii=False)
def _card(text):
return _card_raw([{"tag": "markdown", "content": text}])
def _send_raw(receive_id, payload, msg_type, rtype):
try:
body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
).build()
r = client.im.v1.message.create(body)
if r.success():
return r.data.message_id if r.data else None
print(f"发送失败: {r.code}, {r.msg}")
except Exception as e:
print(f"[ERROR] send_message failed: {e}")
traceback.print_exc()
return None
def _patch_card(message_id, card_json):
try:
body = PatchMessageRequest.builder().message_id(message_id).request_body(
PatchMessageRequestBody.builder().content(card_json).build()
).build()
r = client.im.v1.message.patch(body)
if not r.success():
print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
return r.success()
except Exception as e:
print(f"[ERROR] patch_card exception: {e}")
traceback.print_exc()
return False
def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
if use_card:
return _send_raw(receive_id, _card(content), "interactive", receive_id_type)
if msg_type == "text":
return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type)
return _send_raw(receive_id, content, msg_type, receive_id_type)
def update_message(message_id, content):
return _patch_card(message_id, _card(content))
def _upload_image_sync(file_path):
try:
with open(file_path, "rb") as f:
request = CreateImageRequest.builder().request_body(
CreateImageRequestBody.builder().image_type("message").image(f).build()
).build()
response = client.im.v1.image.create(request)
if response.success():
return response.data.image_key
print(f"[ERROR] upload image failed: {response.code}, {response.msg}")
except Exception as e:
print(f"[ERROR] upload image failed {file_path}: {e}")
return None
def _upload_file_sync(file_path):
ext = os.path.splitext(file_path)[1].lower()
file_type = _FILE_TYPE_MAP.get(ext, "stream")
file_name = os.path.basename(file_path)
try:
with open(file_path, "rb") as f:
request = CreateFileRequest.builder().request_body(
CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()
).build()
response = client.im.v1.file.create(request)
if response.success():
return response.data.file_key
print(f"[ERROR] upload file failed: {response.code}, {response.msg}")
except Exception as e:
print(f"[ERROR] upload file failed {file_path}: {e}")
return None
def _download_image_sync(message_id, image_key):
try:
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build()
response = client.im.v1.message_resource.get(request)
if response.success():
data = response.file.read() if hasattr(response.file, "read") else response.file
return data, response.file_name
print(f"[ERROR] download image failed: {response.code}, {response.msg}")
except Exception as e:
print(f"[ERROR] download image failed {image_key}: {e}")
return None, None
def _download_file_sync(message_id, file_key, resource_type="file"):
if resource_type == "audio":
resource_type = "file"
try:
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
response = client.im.v1.message_resource.get(request)
if response.success():
data = response.file.read() if hasattr(response.file, "read") else response.file
return data, response.file_name
print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}")
except Exception as e:
print(f"[ERROR] download {resource_type} failed {file_key}: {e}")
return None, None
def _download_and_save_media(msg_type, content_json, message_id):
data, filename = None, None
if msg_type == "image":
image_key = content_json.get("image_key")
if image_key and message_id:
data, filename = _download_image_sync(message_id, image_key)
if not filename:
filename = f"{image_key[:16]}.jpg"
elif msg_type in ("audio", "file", "media"):
file_key = content_json.get("file_key")
if file_key and message_id:
data, filename = _download_file_sync(message_id, file_key, msg_type)
if not filename:
filename = file_key[:16]
if msg_type == "audio" and filename and not filename.endswith(".opus"):
filename = f"{filename}.opus"
if data and filename:
file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))
with open(file_path, "wb") as f:
f.write(data)
return file_path, filename
return None, None
def _describe_media(msg_type, file_path, filename):
if msg_type == "image":
return f"[image: {filename}]\n[Image: source: {file_path}]"
if msg_type == "audio":
return f"[audio: {filename}]\n[File: source: {file_path}]"
if msg_type in ("file", "media"):
return f"[{msg_type}: {filename}]\n[File: source: {file_path}]"
return f"[{msg_type}]\n[File: source: {file_path}]"
def _send_local_file(receive_id, file_path, receive_id_type="open_id"):
if not os.path.isfile(file_path):
send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type)
return False
ext = os.path.splitext(file_path)[1].lower()
if ext in _IMAGE_EXTS:
image_key = _upload_image_sync(file_path)
if image_key:
send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type)
return True
else:
file_key = _upload_file_sync(file_path)
if file_key:
msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file"
send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)
return True
send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type)
return False
def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"):
for file_path in _extract_files(raw_text):
_send_local_file(receive_id, file_path, receive_id_type)
def _build_user_message(message):
msg_type = message.message_type
message_id = message.message_id
content_json = _parse_json(message.content)
parts, image_paths = [], []
if msg_type == "text":
text = str(content_json.get("text", "") or "").strip()
if text:
parts.append(text)
elif msg_type == "post":
text, image_keys = _extract_post_content(content_json)
if text:
parts.append(text)
for image_key in image_keys:
file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id)
if file_path and filename:
parts.append(_describe_media("image", file_path, filename))
image_paths.append(file_path)
else:
parts.append("[image: download failed]")
elif msg_type in ("image", "audio", "file", "media"):
file_path, filename = _download_and_save_media(msg_type, content_json, message_id)
if file_path and filename:
parts.append(_describe_media(msg_type, file_path, filename))
if msg_type == "image":
image_paths.append(file_path)
else:
parts.append(f"[{msg_type}: download failed]")
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
parts.append(_extract_share_card_content(content_json, msg_type))
else:
parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
return "\n".join([p for p in parts if p]).strip(), image_paths
def _fmt_tool_call(tc):
name = tc.get('tool_name', '?')
args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}
return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})"
def _build_step_detail(resp, tool_calls):
"""从 LLM response + tool_calls 组装单步展开详情(纯函数)。"""
parts = []
thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''
if thinking:
parts.append(f"### 💭 Thinking\n{thinking}")
if tool_calls:
parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls))
content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''
if content and content != '...':
parts.append(f"### 📝 Output\n{content}")
return "\n\n".join(parts)
class _TaskCard:
"""飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。"""
_DETAIL_LIMIT = 8000
def __init__(self, receive_id, rid_type):
self.rid, self.rtype = receive_id, rid_type
self.steps = [] # [(summary, detail), ...]
self.status = "🤔 思考中..."
self.final = None
self.msg_id = None
self.start_fallback_sent = False
self.final_fallback_sent = False
def _step_panel(self, idx, summary, detail):
detail = detail or "_(无输出)_"
if len(detail) > self._DETAIL_LIMIT:
detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)"
return {
"tag": "collapsible_panel", "expanded": False,
"header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}},
"elements": [{"tag": "markdown", "content": detail}],
}
def _build(self):
els = [{"tag": "markdown", "content": f"**{self.status}**"}]
for i, (s, d) in enumerate(self.steps, 1):
els.append(self._step_panel(i, s, d))
if self.final:
els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
return _card_raw(els)
def _push(self):
card = self._build()
if self.msg_id:
ok = _patch_card(self.msg_id, card)
else:
self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
ok = bool(self.msg_id)
return ok
def _fallback_text(self, text, *, final=False):
attr = "final_fallback_sent" if final else "start_fallback_sent"
if getattr(self, attr):
return
setattr(self, attr, True)
send_message(self.rid, text, receive_id_type=self.rtype)
# ── 公开接口 ──
def start(self):
if not self._push():
self._fallback_text("🤔 思考中...")
def step(self, summary, detail=""):
self.steps.append((summary, detail))
self.status = f"⏳ 工作中 · Turn {len(self.steps)}"
self._push()
def done(self, text):
self.status = "✅ 已完成"
self.final = text or "_(无文本输出)_"
if not self._push():
self._fallback_text(_display_text(text), final=True)
def fail(self, msg):
self.status = f"{msg}"
if not self._push():
self._fallback_text(f"{msg}", final=True)
def _make_task_hook(card, task_id, on_final):
"""飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。"""
def hook(ctx):
try:
parent = getattr(ctx.get("self"), "parent", None)
if getattr(parent, "_fs_active_task_id", None) != task_id:
return
if ctx.get('exit_reason'):
resp = ctx.get('response')
raw = resp.content if hasattr(resp, 'content') else str(resp)
on_final(raw)
elif ctx.get('summary'):
detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])
card.step(ctx['summary'], detail)
except Exception as e:
print(f"[fs hook] error: {e}")
return hook
class FeishuApp(AgentChatMixin):
label, source, split_limit = "Feishu", "feishu", 4000
async def send_text(self, chat_id, content, *, receive_id=None, receive_id_type="open_id", **_):
rid = receive_id or chat_id
for part in split_text(content, self.split_limit):
await asyncio.to_thread(send_message, rid, part, "text", False, receive_id_type)
async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type="open_id", **_):
rid = receive_id or chat_id
text = _display_text(raw_text)
await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type)
await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type)
async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_):
if self.user_tasks:
await self.send_text(chat_id, "当前会话已有任务在运行,请等待完成或发送 /stop 后再试。", receive_id=receive_id, receive_id_type=receive_id_type)
return
state = {"running": True}
self.user_tasks[chat_id] = state
rid = receive_id or chat_id
task_id = f"{chat_id}_{uuid.uuid4().hex}"
hook_key = f"fs_{task_id}"
card = _TaskCard(rid, receive_id_type)
result = {"raw": None, "sent": False}
finish_lock = threading.Lock()
def _finish(raw):
with finish_lock:
if result["sent"]:
return
result["raw"] = raw
result["sent"] = True
card.done(_display_text(raw))
_send_generated_files(rid, raw, receive_id_type=receive_id_type)
try:
await asyncio.to_thread(card.start)
if not hasattr(self.agent, '_turn_end_hooks'):
self.agent._turn_end_hooks = {}
self.agent._turn_end_hooks[hook_key] = _make_task_hook(card, task_id, _finish)
self.agent._fs_active_task_id = task_id
dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source, images=images or None)
start = time.time()
while state["running"] and not result["sent"]:
try:
item = await asyncio.to_thread(dq.get, True, 1)
except Q.Empty:
item = None
if item and "done" in item:
await asyncio.to_thread(_finish, item.get("done", ""))
break
if time.time() - start > AGENT_TIMEOUT_SEC:
self.agent.abort()
await asyncio.to_thread(card.fail, "任务超时")
break
if not state["running"] and not result["sent"]:
self.agent.abort()
await asyncio.to_thread(card.fail, "已停止")
except Exception as e:
traceback.print_exc()
await asyncio.to_thread(card.fail, f"错误: {e}")
finally:
if getattr(self.agent, "_fs_active_task_id", None) == task_id:
try:
delattr(self.agent, "_fs_active_task_id")
except AttributeError:
pass
if hasattr(self.agent, '_turn_end_hooks'):
self.agent._turn_end_hooks.pop(hook_key, None)
self.user_tasks.pop(chat_id, None)
def get_app():
global app
if app is None:
app = FeishuApp(get_agent(), user_tasks)
return app
def _run_async(coro):
try:
asyncio.run(coro)
except Exception:
traceback.print_exc()
def handle_message(data):
event, message, sender = data.event, data.event.message, data.event.sender
message_id = getattr(message, "message_id", "") or ""
if not _claim_message_once(message_id):
print(f"忽略重复飞书消息: {message_id}")
return
open_id = sender.sender_id.open_id
chat_id = message.chat_id
if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
print(f"未授权用户: {open_id}")
return
user_input, image_paths = _build_user_message(message)
if not user_input:
if chat_id:
send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id")
else:
send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}")
return
print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}")
receive_id = chat_id or open_id
receive_id_type = "chat_id" if chat_id else "open_id"
chat_key = receive_id
if message.message_type == "text" and user_input.startswith("/"):
threading.Thread(
target=_run_async,
args=(get_app().handle_command(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type),),
daemon=True,
).start()
return
threading.Thread(
target=_run_async,
args=(get_app().run_agent(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type, images=image_paths),),
daemon=True,
).start()
def main():
global client, APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH
APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
if not APP_ID or not APP_SECRET:
print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True)
sys.exit(1)
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
retry_delay = 5
while True:
try:
client = create_client()
cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)
print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n配置: {CONFIG_PATH}\n等待消息...\n" + "=" * 50, flush=True)
cli.start()
retry_delay = 5
except KeyboardInterrupt:
raise
except Exception as e:
print(f"[WARN] 飞书长连接断开或启动失败: {e}", flush=True)
traceback.print_exc()
print(f"[INFO] {retry_delay}s 后重连飞书长连接...", flush=True)
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 120)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A3Agent Feishu frontend")
parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接")
parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM")
args = parser.parse_args()
if args.check or args.check_agent:
print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True)
else:
main()
+375
View File
@@ -0,0 +1,375 @@
import io
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Must run BEFORE importing agentmain — it reconfigures stdout at import time,
# and its submodules may print() during init. We capture the raw binary stdout
# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray
# prints from agentmain/llmcore don't pollute the ACP channel.
if sys.platform == "win32":
import msvcrt
_stdout_fd = os.dup(sys.__stdout__.fileno())
msvcrt.setmode(_stdout_fd, os.O_BINARY)
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
# Mark the ACP fd as non-inheritable so child processes can't write to it.
os.set_inheritable(_stdout_fd, False)
# Redirect the original stdout fd to stderr so child processes
# (tool calls) don't write into the ACP JSON-RPC channel.
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
else:
_stdout_fd = os.dup(sys.__stdout__.fileno())
os.set_inheritable(_stdout_fd, False)
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
class _StdoutToStderrRouter(io.TextIOBase):
"""Redirect text-mode stdout to stderr so agentmain prints don't leak."""
def writable(self): return True
def write(self, s):
if s:
sys.stderr.write(s)
sys.stderr.flush()
return len(s) if s else 0
def flush(self): sys.stderr.flush()
sys.stdout = _StdoutToStderrRouter()
import argparse
import queue
import threading
import traceback
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from agentmain import GeneraticAgent
JSONRPC_VERSION = "2.0"
ACP_PROTOCOL_VERSION = 1
def eprint(*args: Any) -> None:
print(*args, file=sys.stderr, flush=True)
def make_text_block(text: str) -> Dict[str, Any]:
return {"type": "text", "text": text}
def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:
return {
"jsonrpc": JSONRPC_VERSION,
"method": "session/update",
"params": {"sessionId": session_id, "update": update},
}
def compact_json(obj: Dict[str, Any]) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:
stripped = line.strip()
if not stripped:
return None
try:
obj = json.loads(stripped)
except json.JSONDecodeError:
return None
return obj if isinstance(obj, dict) else None
def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:
parts: List[str] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "text":
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
elif block_type == "resource_link":
name = block.get("name") or "resource"
uri = block.get("uri") or ""
desc = block.get("description") or ""
parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip())
elif block_type == "resource":
uri = block.get("uri") or "resource"
text = block.get("text")
if isinstance(text, str) and text:
parts.append(f"[Resource] {uri}\n{text}")
else:
parts.append(f"[Resource] {uri}")
elif block_type == "image":
uri = block.get("uri") or "inline-image"
parts.append(f"[Image omitted] {uri}")
else:
parts.append(f"[Unsupported content block: {block_type}]")
return "\n\n".join(p for p in parts if p).strip()
def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:
err: Dict[str, Any] = {"code": code, "message": message}
if data is not None:
err["data"] = data
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err}
def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result}
@dataclass
class SessionState:
session_id: str
cwd: str
agent: GeneraticAgent
current_prompt_id: Any = None
prompt_lock: threading.Lock = field(default_factory=threading.Lock)
class GenericAgentAcpBridge:
def __init__(self, llm_no: int = 0):
self.llm_no = llm_no
self._json_out = _acp_stdout
self._write_lock = threading.Lock()
self._sessions: Dict[str, SessionState] = {}
self._shutdown = False
def write_message(self, msg: Dict[str, Any]) -> None:
payload = compact_json(msg)
raw = (payload + "\n").encode("utf-8")
method = msg.get("method", msg.get("id", "?"))
eprint(f"[ACP-BRIDGE] >>> {payload[:500]}")
try:
with self._write_lock:
self._json_out.write(raw)
self._json_out.flush()
except Exception as e:
eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}")
def new_agent(self) -> GeneraticAgent:
agent = GeneraticAgent()
agent.next_llm(self.llm_no)
agent.verbose = True
agent.inc_out = True
threading.Thread(target=agent.run, daemon=True).start()
return agent
def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:
requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION)
version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION
result = {
"protocolVersion": version,
"agentCapabilities": {
"loadSession": False,
"mcpCapabilities": {"http": False, "sse": False},
"promptCapabilities": {
"image": False,
"audio": False,
"embeddedContext": False,
},
"sessionCapabilities": {},
},
"agentInfo": {
"name": "genericagent-acp",
"title": "GenericAgent",
"version": "0.1.0",
},
"authMethods": [],
}
self.write_message(jsonrpc_result(req_id, result))
def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:
cwd = params.get("cwd")
if not isinstance(cwd, str) or not cwd:
self.write_message(jsonrpc_error(-32602, "cwd is required", req_id))
return
if not os.path.isabs(cwd):
cwd = os.path.abspath(cwd)
session_id = f"ga_{uuid.uuid4().hex}"
agent = self.new_agent()
session = SessionState(session_id=session_id, cwd=cwd, agent=agent)
self._sessions[session_id] = session
self.write_message(
jsonrpc_result(
req_id,
{
"sessionId": session_id,
"modes": None,
"configOptions": None,
},
)
)
def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:
session_id = params.get("sessionId")
prompt_blocks = params.get("prompt")
session = self._sessions.get(session_id)
if session is None:
self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id))
return
if not isinstance(prompt_blocks, list):
self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id))
return
prompt_text = content_blocks_to_text(prompt_blocks)
if not prompt_text:
self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id))
return
with session.prompt_lock:
if session.current_prompt_id is not None:
self.write_message(
jsonrpc_error(-32603, "session already has an active prompt", req_id)
)
return
session.current_prompt_id = req_id
def run_prompt() -> None:
stop_reason = "end_turn"
try:
dq = session.agent.put_task(prompt_text, source="acp")
self._drain_agent_queue(session, dq)
except Exception as exc:
stop_reason = "end_turn"
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(
f"[Bridge error] {type(exc).__name__}: {exc}"
),
},
)
)
eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc())
finally:
with session.prompt_lock:
finished_req_id = session.current_prompt_id
session.current_prompt_id = None
if finished_req_id is not None:
import time
time.sleep(0.1)
self.write_message(
jsonrpc_result(finished_req_id, {"stopReason": stop_reason})
)
threading.Thread(target=run_prompt, daemon=True).start()
def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None:
sent_any = False
while True:
item = dq.get()
if not isinstance(item, dict):
continue
# With inc_out=True, "next" items are already incremental deltas.
if "next" in item and "done" not in item:
delta = item["next"]
if isinstance(delta, str) and delta:
sent_any = True
try:
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(delta),
},
)
)
except Exception as e:
eprint(f"[ACP-BRIDGE] ERROR writing update: {e}")
if "done" in item:
# "done" text has post-processing (</summary>\n\n insertion)
# that shifts offsets — cannot safely compute a tail delta.
# Only use "done" content if nothing was streamed (error case).
if not sent_any:
done_text = item["done"]
if isinstance(done_text, str) and done_text:
try:
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(done_text),
},
)
)
except Exception as e:
eprint(f"[ACP-BRIDGE] ERROR writing done: {e}")
break
def handle_session_cancel(self, params: Dict[str, Any]) -> None:
session_id = params.get("sessionId")
session = self._sessions.get(session_id)
if session is None:
return
if session.current_prompt_id is not None:
session.agent.abort()
def handle_message(self, msg: Dict[str, Any]) -> None:
method = msg.get("method")
req_id = msg.get("id")
params = msg.get("params") or {}
try:
if method == "initialize":
self.handle_initialize(req_id, params)
elif method == "session/new":
self.handle_session_new(req_id, params)
elif method == "session/prompt":
self.handle_session_prompt(req_id, params)
elif method == "session/cancel":
self.handle_session_cancel(params)
elif method == "session/load":
self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id))
elif method == "session/list":
self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id))
elif method == "session/close":
self.write_message(jsonrpc_result(req_id, {}))
elif method is None:
if req_id is not None:
self.write_message(jsonrpc_error(-32600, "invalid request", req_id))
else:
if req_id is not None:
self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id))
except Exception as exc:
eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc())
if req_id is not None:
self.write_message(
jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id)
)
def serve(self) -> None:
eprint("[GenericAgent ACP] bridge started")
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin
for raw_line in stdin:
msg = parse_jsonrpc_line(raw_line)
if msg is None:
continue
self.handle_message(msg)
if self._shutdown:
break
eprint("[GenericAgent ACP] bridge stopped")
def main() -> int:
parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio")
parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent")
args = parser.parse_args()
bridge = GenericAgentAcpBridge(llm_no=args.llm_no)
bridge.serve()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+72
View File
@@ -0,0 +1,72 @@
"""Cross-platform keyboard-shortcut display formatter.
One job: turn a Textual binding string like ``"ctrl+b"`` into a human-facing
label such as ``"Ctrl+B"`` on Win/Linux or ``"⌃B"`` on macOS.
Binding strings (the *physical* keys Textual captures) are NOT touched —
this module only formats labels for tips / footers / help panels.
Override with env ``GA_KEYSYM_STYLE=auto|mac|ascii`` (default ``auto``).
"""
from __future__ import annotations
import os
import sys
_STYLE = os.environ.get("GA_KEYSYM_STYLE", "auto").lower()
IS_MAC = _STYLE == "mac" or (_STYLE != "ascii" and sys.platform == "darwin")
# Modifier display per style. mac uses Apple HIG glyphs; others use words.
_MOD = {
"ctrl": "" if IS_MAC else "Ctrl",
"shift": "" if IS_MAC else "Shift",
"alt": "" if IS_MAC else "Alt",
"meta": "" if IS_MAC else "Alt",
"super": "" if IS_MAC else "Win",
"cmd": "" if IS_MAC else "Win",
}
# Bare-key display. Arrows / slash are universal; rest mac-glyphs vs words.
_KEY = {
"enter": "" if IS_MAC else "Enter",
"tab": "" if IS_MAC else "Tab",
"escape": "" if IS_MAC else "Esc",
"esc": "" if IS_MAC else "Esc",
"backspace": "" if IS_MAC else "Backspace",
"delete": "" if IS_MAC else "Del",
"space": "" if IS_MAC else "Space",
"up": "", "down": "", "left": "", "right": "",
"slash": "/", "underscore": "_",
}
# Joiner between modifier and key. mac concatenates (⌃B); others use '+'.
_JOIN = "" if IS_MAC else "+"
def fmt_key(combo: str) -> str:
"""``"ctrl+b"`` → ``"⌃B"`` (mac) / ``"Ctrl+B"`` (Win/Linux).
Unknown single-char keys are upper-cased (``"b"`` → ``"B"``);
multi-char names fall back to the original token unchanged.
"""
parts = [p.strip() for p in combo.lower().split("+") if p.strip()]
if not parts:
return combo
mods, key = parts[:-1], parts[-1]
key_disp = _KEY.get(key) or (key.upper() if len(key) == 1 else key)
mod_disp = [_MOD.get(m, m) for m in mods]
if not mod_disp:
return key_disp
return _JOIN.join(mod_disp) + _JOIN + key_disp
def fmt_keys(*combos: str, sep: str = " / ") -> str:
"""Join multiple combos: ``fmt_keys("ctrl+j", "ctrl+enter")`` →
``"Ctrl+J / Ctrl+Enter"`` or ``"⌃J / ⌃⏎"``."""
return sep.join(fmt_key(c) for c in combos)
# Convenience constants for f-string templates.
CTRL = _MOD["ctrl"]
SHIFT = _MOD["shift"]
ALT = _MOD["alt"]
+120
View File
@@ -0,0 +1,120 @@
"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
from typing import Optional, List, Tuple
import requests
def _is_mixin(b) -> bool:
return type(b).__name__ == 'MixinSession'
def _resolve(agent, sub: Optional[int] = None):
"""返回 (真正持有 model 的 session, mixin或None)。"""
b = agent.llmclient.backend
if _is_mixin(b):
return b._sessions[b._cur_idx if sub is None else sub], b
return b, None
def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
"""mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
b = agent.llmclient.backend
if not _is_mixin(b):
return None
return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]
def current_model(agent, sub: Optional[int] = None) -> str:
s, _ = _resolve(agent, sub)
return s.model
def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
"""GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
s, _ = _resolve(agent, sub)
base = s.api_base.rstrip('/')
urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
heads = [{'Authorization': f'Bearer {s.api_key}'}]
if 'Claude' in type(s).__name__:
heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
err = None
for url in urls:
for h in heads:
try:
r = requests.get(url, headers=h, timeout=timeout,
proxies=getattr(s, 'proxies', None),
verify=getattr(s, 'verify', True))
r.raise_for_status()
data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
if ids:
return sorted(ids)
except Exception as e:
err = e
raise err or RuntimeError('no models endpoint')
def set_model(agent, model: str, sub: Optional[int] = None) -> str:
"""运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
s, mixin = _resolve(agent, sub)
old = s.model
s.model = model # mixin 的 model 是只读 property, 必须落子 session
warn = ""
try: # 对齐 agentmain.next_llm 的中文 schema 切换
from agentmain import load_tool_schema
load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
where = f"[{s.name}]" if mixin else s.name
return f"{where}: {old}{model}{warn}"
# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
# reasoning.effort) 原样透传, 由渠道端校验。
EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
def _protocols(agent) -> set:
"""当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
b = agent.llmclient.backend
sessions = b._sessions if _is_mixin(b) else [b]
return {('claude' if 'Claude' in type(s).__name__ else 'openai')
for s in sessions}
def effort_note(level, protocols) -> str:
"""单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
if level and 'claude' in protocols:
if level in ('none', 'minimal'):
return 'Claude 渠道忽略'
if level == 'xhigh':
return 'Claude 对应 max'
return ''
def current_effort(agent) -> str:
return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''
def set_effort(agent, value) -> str:
"""运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
该属性, 立即生效。"""
e = (value or '').strip().lower()
if e in ('', 'off', 'clear', 'unset'):
e = None
elif e not in EFFORT_LEVELS:
return (f"无效 effort: {value!r}"
f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
b = agent.llmclient.backend
old = getattr(b, 'reasoning_effort', None)
b.reasoning_effort = e
note = effort_note(e, _protocols(agent))
tail = f" ({note})" if note else ""
return f"reasoning_effort: {old or '(未设置)'}{e or '(清除)'}{tail}"
+345
View File
@@ -0,0 +1,345 @@
"""Plan / todo state — pure stdlib, no UI framework dependency.
API:
extract(text) → [(content, "open"|"done"), …]
is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref)
resolve_path(agent, messages=None) → live plan.md path (or None)
find_path_in_messages(messages) → most recent plan.md path mentioned
current_step(messages) → latest `当前步骤:…` snippet (or "")
summary(items) → (n_done, n_total)
is_complete(items) → all done (or empty)
Supported task-line shapes (all matched by `extract`):
- [ ] foo ← bullet + open
- [x] foo ← bullet + done
1. [✓] foo ← numbered + done
2. [✓ 2026-05-16] foo ← numbered + timestamped done, content after bracket
3. [✓ 已生成: foo] ← numbered + done with description *inside* bracket
4. [D][P] foo ← two marker groups (delegate + parallel), still open
5. [D] foo ← non-standard marker "D" → open (not done)
"""
from __future__ import annotations
import os, re
from typing import Any, Optional
_DONE_CHARS = set("xX✓✔√☑")
# Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`).
_GLUE_RE = re.compile(r"(?<!\n)((?:[-*+]|\d+\s*[.)、:]) \[)")
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d+\s*[.)、:])\s+")
_BRACKET_RE = re.compile(r"\[([^\]]*)\]")
# Strip `✓ ` / `x ` / timestamp prefix when bracket content is used as title.
_INLINE_STRIP_RE = re.compile(
r"^[" + re.escape("".join(_DONE_CHARS)) + r"]\s*(?:\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?\s*)?"
)
_DEBRIS_RE = re.compile(r'["\\<].*$')
# Strip markdown emphasis since planbar renders rich.Text, not Markdown.
_MD_EMPHASIS_RE = re.compile(
r"\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|__([^_\n]+)__|_([^_\n]+)_|`([^`\n]+)`"
)
def _strip_md(s: str) -> str:
return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s)
def _has_done_glyph(marker: str) -> bool:
return any(c in _DONE_CHARS for c in marker)
def extract(text: str) -> list[tuple[str, str]]:
if not text: return []
norm = text.replace("\\n", "\n") if "\\n" in text else text
norm = _GLUE_RE.sub(r"\n\1", norm)
found: dict[str, str] = {}
for line in norm.splitlines():
head = _BULLET_RE.match(line)
if not head: continue
rest = line[head.end():]
groups: list[str] = []
# Consume any number of consecutive `[...]` groups — covers `[D][P]`
# task-type chains as well as the plain `[ ]` / `[x]` single form.
while True:
b = _BRACKET_RE.match(rest)
if not b: break
groups.append(b.group(1))
rest = rest[b.end():]
if not groups: continue
is_done = any(_has_done_glyph(g) for g in groups)
inline = rest.strip()
if inline:
content = inline
elif is_done:
# `[✓ description]` shape — description lives inside the bracket
# next to the glyph. Strip the glyph + optional timestamp.
done_g = next(g for g in groups if _has_done_glyph(g))
content = _INLINE_STRIP_RE.sub("", done_g).strip()
else:
continue
k = _strip_md(_DEBRIS_RE.sub("", content).strip())
if not k: continue
status = "done" if is_done else "open"
# Same content seen twice — done wins over open.
if k not in found or status == "done":
found[k] = status
return list(found.items())
def _stashed_plan_path(agent) -> str:
# First non-empty `working['in_plan_mode']` from (handler, agent).
for src in (getattr(agent, "handler", None), agent):
p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip()
if p: return p
return ""
def _resolve_stashed(p: str) -> Optional[str]:
if not p: return None
rel = p.lstrip("./\\")
cwd = os.getcwd()
for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
if os.path.isfile(c) and os.path.getsize(c) > 0: return c
return None
# Strict per-session discovery — scan this session's own messages only.
_PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""")
def _slice(messages, start_idx: int):
if not messages: return []
if start_idx <= 0: return list(messages)
return list(messages)[start_idx:]
def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]:
"""Latest existing `plan_XXX/plan.md` referenced after `start_idx`.
Items can be `ChatMessage`-like (`.content`) or plain strings;
only paths that exist on disk are returned."""
sliced = _slice(messages, start_idx)
if not sliced: return None
for m in reversed(sliced):
text = getattr(m, "content", None)
if text is None: text = m if isinstance(m, str) else ""
if not text or "plan.md" not in text: continue
for hit in reversed(_PATH_RE.findall(text)):
p = _resolve_stashed(hit.strip().strip("\"'"))
if p: return p
return None
# Prefer concise `<summary>` narrative over the long plan-item echo;
# treat `❌ 当前步骤:` as "step done", not "current step".
_SUMMARY_STEP_RE = re.compile(
r"<summary>[^<]*?当前步骤[:]\s*([^<\n]{1,160})</summary>", re.DOTALL)
_STEP_RE = re.compile(r"📌\s*当前步骤[:]\s*([^\n。!!?]{1,160})")
_DONE_STEP_RE = re.compile(r"\s*当前步骤[:]")
def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
"""Latest `当前步骤:…` snippet; `<summary>` form preferred, `❌`-prefixed
skipped. Trimmed to `max_len` chars so it fits the 5-row plan card."""
sliced = _slice(messages, start_idx)
if not sliced: return ""
def _clean(s: str) -> str:
return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" :—-"))
def _cap(s: str) -> str:
s = _clean(s)
if len(s) <= max_len: return s
return s[:max_len - 1].rstrip() + ""
for m in reversed(sliced):
text = getattr(m, "content", None)
if text is None: text = m if isinstance(m, str) else ""
if not text or "当前步骤" not in text: continue
hits = _SUMMARY_STEP_RE.findall(text)
if hits: return _cap(hits[-1])
for raw in reversed(_STEP_RE.findall(text)):
if _DONE_STEP_RE.search(raw): continue
return _cap(raw)
return ""
def is_active(agent, messages=None, start_idx: int = 0,
restored_path: str = "") -> bool:
"""Plan mode is on. Primary: `working['in_plan_mode']`. Then
`restored_path` — a path recovered from the transcript's structured
`enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
unlike the message scan it cannot be spoofed by a path typed in chat.
Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
(no global scan) — only consulted when `messages` is passed."""
if _stashed_plan_path(agent): return True
if restored_path and _resolve_stashed(restored_path): return True
return find_path_in_messages(messages, start_idx) is not None
def resolve_path(agent, messages=None, start_idx: int = 0,
restored_path: str = "") -> Optional[str]:
p = _resolve_stashed(_stashed_plan_path(agent))
if p: return p
if restored_path:
p = _resolve_stashed(restored_path)
if p: return p
return find_path_in_messages(messages, start_idx)
def summary(items: list[tuple[str, str]]) -> tuple[int, int]:
return sum(1 for _, st in items if st == "done"), len(items)
def is_complete(items: list[tuple[str, str]]) -> bool:
return not items or all(st == "done" for _, st in items)
# --- Desktop bridge only (APIs above unchanged) ---
_ENTER_PLAN_RE = re.compile(r"""enter_plan_mode\s*\(\s*["']([^"']+)["']""", re.I)
def _msg_content(m) -> str:
if isinstance(m, str): return m
c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
return c if isinstance(c, str) else ""
def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]:
for m in reversed(_slice(messages, start_idx)):
text = _msg_content(m)
if not text: continue
if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)):
return hit.group(1).strip().strip("\"'")
if "plan.md" in text and (hits := _PATH_RE.findall(text)):
return hits[-1].strip().strip("\"'")
return None
def _resolve_stashed_at(p: str, root: str) -> Optional[str]:
if not p or not root: return None
rel = p.lstrip("./\\")
cwd = root.rstrip("/\\")
for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
if os.path.isfile(c) and os.path.getsize(c) > 0: return c
return None
def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]:
for m in reversed(_slice(messages, start_idx)):
text = _msg_content(m)
if not text or "plan.md" not in text: continue
for hit in reversed(_PATH_RE.findall(text)):
if p := _resolve_stashed_at(hit.strip().strip("\"'"), root): return p
return None
def default_session_plan_path(session_id: str) -> str:
sid = (session_id or "sess").replace("/", "_")
return f"temp/plan_{sid}/plan.md"
def is_session_scoped_plan_path(path: str, session_id: str) -> bool:
"""Desktop: only bind/adopt paths under this session's plan_{id}/ tree."""
if not path:
return False
sid = (session_id or "sess").replace("/", "_")
norm = path.replace("\\", "/").lstrip("./")
return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md")
def is_plan_preset_prompt(prompt: str) -> bool:
p = (prompt or "").lower()
return "plan_sop" in p or "plan 模式" in p or "plan mode" in p
def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str:
"""Bind plan card to this session only (avoids sharing plan_demo/ across sessions)."""
rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "")
sess.plan_path = rel
msgs = list(getattr(sess, "messages", []) or [])
sess.plan_scan_baseline = len(msgs)
return rel
def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None:
"""If agent emits enter_plan_mode(...), keep session bound path in sync."""
if not text:
return
m = _ENTER_PLAN_RE.search(text)
if not m:
return
raw = m.group(1).strip().strip("\"'")
if not raw:
return
sess.plan_path = raw.lstrip("./")
def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool:
"""Desktop: active when this session has a bound plan_path (not global plan_*/ scan)."""
bound = (getattr(sess, "plan_path", None) or "").strip()
if not bound:
return False
if _stashed_plan_path(agent):
return True
if _resolve_stashed_at(bound, root):
return True
if getattr(sess, "status", "") == "running":
return True
# Plan preset bound this session — keep placeholder until plan.md appears or path changes
if is_session_scoped_plan_path(bound, getattr(sess, "id", "")):
return True
return False
def session_plan_resolve(bound: str, root: str) -> Optional[str]:
if not bound:
return None
return _resolve_stashed_at(bound.strip(), root)
def _desktop_resolve_plan_file(sess: Any, bound: str, root: str, agent, messages, base: int) -> Optional[str]:
"""Desktop read path: bound → agent stash → per-session message scan."""
path = session_plan_resolve(bound, root)
if path:
return path
if agent and (stash := _stashed_plan_path(agent)):
if p := _resolve_stashed_at(stash, root):
return p
return _find_path_at(messages, base, root)
def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict:
"""Per-session plan card — bound plan_path + tuiapp_v2-style item/placeholder/complete."""
raw = list(getattr(sess, "messages", []) or [])
base = max(0, int(getattr(sess, "plan_scan_baseline", 0) or 0))
if base > len(raw):
base = len(raw)
root = (getattr(sess, "cwd", None) or ga_root or "").strip()
agent = getattr(sess, "agent", None)
partial = getattr(sess, "partial", None)
if isinstance(partial, dict) and isinstance(partial.get("content"), str):
sync_plan_path_from_text(sess, partial["content"], root)
sid = getattr(sess, "id", "") or ""
if not (getattr(sess, "plan_path", None) or "").strip():
mentioned = plan_path_mention_in_messages(raw, base)
if mentioned and is_session_scoped_plan_path(mentioned, sid):
sess.plan_path = mentioned.lstrip("./")
else:
return {"active": False}
if not session_plan_active(sess, agent, raw, base, root):
return {"active": False}
bound = getattr(sess, "plan_path", "") or ""
path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base)
items = []
if path:
try:
items = [{"content": c, "status": st} for c, st in extract(open(path, encoding="utf-8", errors="replace").read())]
except OSError:
pass
step_msgs = list(raw[base:])
if isinstance(p := getattr(sess, "partial", None), dict) and isinstance(c := p.get("content"), str) and c:
step_msgs.append({"content": c})
step = current_step(step_msgs, start_idx=0)
if not items:
hp = getattr(sess, "plan_path", "") or _stashed_plan_path(agent) or ""
hint = "/".join(hp.replace("\\", "/").rstrip("/").split("/")[-2:]) if hp else "plan.md"
return {"active": True, "placeholder": True, "done": 0, "total": 0, "complete": False, "step": step, "pathHint": hint, "items": []}
pairs = [(x["content"], x["status"]) for x in items]
n_done, n_total = summary(pairs)
return {"active": True, "placeholder": False, "done": n_done, "total": n_total, "complete": is_complete(pairs), "step": step, "items": items}
+130
View File
@@ -0,0 +1,130 @@
import asyncio, os, sys, threading, time
from collections import deque
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from agentmain import GeneraticAgent
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
from llmcore import mykeys
try:
import botpy
from botpy.message import C2CMessage, GroupMessage
except Exception:
print("Please install qq-botpy to use QQ module: pip install qq-botpy")
sys.exit(1)
agent = GeneraticAgent(); agent.verbose = False
APP_ID = str(mykeys.get("qq_app_id", "") or "").strip()
APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()}
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
def _next_msg_seq():
global MSG_SEQ
with SEQ_LOCK:
MSG_SEQ += 1
return MSG_SEQ
def _build_intents():
try:
return botpy.Intents(public_messages=True, direct_message=True)
except Exception:
intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
if hasattr(intents, attr):
try:
setattr(intents, attr, True)
except Exception:
pass
return intents
def _make_bot_class(app):
class QQBot(botpy.Client):
def __init__(self):
super().__init__(intents=_build_intents(), ext_handlers=False)
async def on_ready(self):
print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
async def on_c2c_message_create(self, message: C2CMessage):
await app.on_message(message, is_group=False)
async def on_group_at_message_create(self, message: GroupMessage):
await app.on_message(message, is_group=True)
async def on_direct_message_create(self, message):
await app.on_message(message, is_group=False)
return QQBot
class QQApp(AgentChatMixin):
label, source, split_limit = "QQ", "qq", 1500
def __init__(self):
super().__init__(agent, USER_TASKS)
self.client = None
async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
if not self.client:
return
api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
key = "group_openid" if is_group else "openid"
for part in split_text(content, self.split_limit):
seq = _next_msg_seq()
try:
await api(**{key: chat_id, "msg_type": 2, "markdown": {"content": part}, "msg_id": msg_id, "msg_seq": seq})
except Exception:
await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": seq})
async def on_message(self, data, is_group=False):
try:
msg_id = getattr(data, "id", None)
if msg_id in PROCESSED_IDS:
return
PROCESSED_IDS.append(msg_id)
content = (getattr(data, "content", "") or "").strip()
if not content:
return
author = getattr(data, "author", None)
user_id = str(getattr(author, "member_openid" if is_group else "user_openid", "") or getattr(author, "id", "") or "unknown")
chat_id = str(getattr(data, "group_openid", "") or user_id) if is_group else user_id
if not public_access(ALLOWED) and user_id not in ALLOWED:
print(f"[QQ] unauthorized user: {user_id}")
return
print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
if content.startswith("/"):
return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)
asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))
except Exception:
import traceback
print("[QQ] handle_message error")
traceback.print_exc()
async def start(self):
self.client = _make_bot_class(self)()
delay, max_delay = 5, 300
while True:
started_at = time.monotonic()
try:
print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
await self.client.start(appid=APP_ID, secret=APP_SECRET)
except Exception as e:
print(f"[QQ] bot error: {e}")
if time.monotonic() - started_at >= 60:
delay = 5
print(f"[QQ] reconnect in {delay}s...")
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
if __name__ == "__main__":
_LOCK_SOCK = ensure_single_instance(19528, "QQ")
require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
threading.Thread(target=agent.run, daemon=True).start()
asyncio.run(QQApp().start())
+2478
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""`/review` 命令:in-session adversarial code reviewer。
用户输入整段作为 user_request 注入 inline prompt;主 agent 在当前 session 内按 prompt
协议自取审阅范围(用户点名的文件 / git diff)并 echo 报告,不开 subagent、不写落盘文件。
prompt 与 SOP 仅来自 `memory/review_sop/`,作为独立公共入口,不读取其他工作流的私有 prompt。
"""
from __future__ import annotations
import os
from typing import Optional
CODE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PROMPT_DIR = 'review_sop'
_INLINE_PROMPT_ZH = 'review_inline_prompt.txt'
_INLINE_PROMPT_EN = 'review_inline_prompt.en.txt'
_STUB_FALLBACK = (
'[/review in-session] (⚠️ prompt 文件缺失: {fpath}{err})\n\n'
'# 本轮用户请求\n{user_request}\n\n'
'请按 memory/code_review_principles.md 评审,直接 echo 报告到对话。\n'
'不要写 review.md,不要打 [ROUND END]。'
)
def _render_prompt(user_request: str) -> str:
"""加载 /review inline prompt 并注入 user_request + ga_root。"""
lang = os.environ.get('GA_LANG', '').strip().lower()
fname = _INLINE_PROMPT_EN if lang == 'en' else _INLINE_PROMPT_ZH
fpath = os.path.join(CODE_ROOT, 'memory', _PROMPT_DIR, fname)
ga_root = CODE_ROOT.replace('\\', '/')
try:
with open(fpath, 'r', encoding='utf-8') as f:
return f.read().format(user_request=user_request, ga_root=ga_root)
except Exception as e:
return _STUB_FALLBACK.format(fpath=fpath, err=e, user_request=user_request)
def _help_text() -> str:
return (
'**/review 用法**: in-session adversarial code reviewer\n\n'
'`/review ` # 默认审本次 uncommitted 改动(主 agent 跑 git diff)\n'
'`/review <自然语言请求> ` # 主 agent 按你描述的范围去审\n\n'
'例:\n'
' `/review`\n'
' `/review 我刚改了 review_cmd.py 和 tuiapp_v2.py,关注 prompt 注入`\n'
' `/review 审 frontends 目录下所有改过的文件`\n\n'
'产出:直接对话 markdown(不写文件、不开 subagent)。\n'
'协议: `memory/review_sop/review_inline_prompt.txt` + `memory/code_review_principles.md`'
)
_DEFAULT_REQUEST_ZH = '(无具体请求 — 默认审本次 uncommitted 改动:用 code_run 跑 `git diff --stat HEAD` 与 `git diff HEAD`)'
_DEFAULT_REQUEST_EN = '(no specific request — default to uncommitted diff: run `git diff --stat HEAD` and `git diff HEAD`)'
_HEADER_ZH = '> 🔍 /review (in-session) → 主 agent 当场审,直接 echo 报告\n\n'
_HEADER_EN = '> 🔍 /review (in-session) → main agent reviews here, echoes the report inline\n\n'
def handle(agent, body: str, display_queue) -> Optional[str]:
"""body 是已剥离 `/review` 前缀的纯参数文本(由 install 剥离)。
help → 推 done;否则注入 user_request 到 inline prompt return 给主 agent。
不发任何 'done' message(否则前端 `if 'done': break + finally:agent.abort` 会干掉主 agent)。
"""
if body in ('help', '?', '-h', '--help'):
display_queue.put({'done': _help_text(), 'source': 'system'})
return None
en = os.environ.get('GA_LANG', '').strip().lower() == 'en'
user_request = body or (_DEFAULT_REQUEST_EN if en else _DEFAULT_REQUEST_ZH)
header = _HEADER_EN if en else _HEADER_ZH
return header + _render_prompt(user_request)
def install(cls):
"""`/review` 一律接管,前缀剥离在此完成,handle 只接 body(职责单一)。"""
orig = cls._handle_slash_cmd
if getattr(orig, '_review_patched', False): return
def patched(self, raw_query, display_queue):
s = (raw_query or '').strip()
if s == '/review':
body = ''
elif s.startswith('/review ') or s.startswith('/review\t'):
body = s[len('/review'):].strip()
else:
return orig(self, raw_query, display_queue)
r = handle(self, body, display_queue)
return None if r is None else r
patched._review_patched = True
cls._handle_slash_cmd = patched
+105
View File
@@ -0,0 +1,105 @@
"""Persistent display names for `/continue`-able sessions.
JSON sidecar at `temp/model_responses/session_names.json` maps log-file
basename → user name. Touched only by `/rename` and `/continue <name>`.
"""
import glob, json, os, re, threading
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'temp', 'model_responses')
_REG_PATH = os.path.join(_LOG_DIR, 'session_names.json')
_LOG_RE = re.compile(r'^model_responses_(\d+)\.txt$')
_lock = threading.Lock()
def _load() -> dict:
try:
with open(_REG_PATH, encoding='utf-8') as f:
d = json.load(f)
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save(d: dict) -> None:
os.makedirs(_LOG_DIR, exist_ok=True)
tmp = _REG_PATH + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(d, f, ensure_ascii=False, indent=2)
os.replace(tmp, _REG_PATH)
def _resolve_basename(basename: str):
# Registered file may be cleared by `continue_cmd._snapshot_current_log`
# on /new or /continue; fall back to the newest non-empty snapshot of the
# same PID so a mid-session rename survives the rotation.
p = os.path.join(_LOG_DIR, basename)
if os.path.isfile(p) and os.path.getsize(p) > 0:
return p
m = _LOG_RE.match(basename)
if m:
snaps = glob.glob(os.path.join(_LOG_DIR, f'model_responses_snapshot_{m.group(1)}_*.txt'))
snaps.sort(key=os.path.getmtime, reverse=True)
for s in snaps:
if os.path.getsize(s) > 0:
return s
return None
def set_name(log_path: str, name: str) -> None:
"""Persist `name` for `log_path`. Empty name removes the entry."""
key = os.path.basename(log_path)
with _lock:
d = _load()
if name: d[key] = name
else: d.pop(key, None)
_save(d)
def migrate(old_path: str, new_path: str) -> None:
"""Move the entry from old basename to new basename after /continue."""
if old_path == new_path: return
old_key, new_key = os.path.basename(old_path), os.path.basename(new_path)
with _lock:
d = _load()
if old_key in d:
d[new_key] = d.pop(old_key)
_save(d)
def name_for(log_path: str) -> str:
return _load().get(os.path.basename(log_path), '')
def has_name(name: str, exclude_basename: str = None) -> bool:
"""True when any other entry already owns `name` (case-insensitive)."""
target = (name or '').strip().lower()
if not target: return False
return any(v.lower() == target for k, v in _load().items() if k != exclude_basename)
def gc() -> int:
"""Drop entries whose log file is gone or empty. Returns count removed."""
with _lock:
d = _load()
bad = [k for k in d if _resolve_basename(k) is None]
for k in bad: d.pop(k)
if bad: _save(d)
return len(bad)
def path_for(name: str, exclude_basename: str = None):
"""Resolve `name` → newest resolvable log path. Exact-match then unique-prefix."""
target = (name or '').strip().lower()
if not target: return None
d = _load()
matches = [(k, v) for k, v in d.items() if v.lower() == target]
if not matches:
matches = [(k, v) for k, v in d.items() if v.lower().startswith(target)]
if len(matches) > 1: matches = []
if exclude_basename is not None:
matches = [m for m in matches if m[0] != exclude_basename]
resolved = [(p, k) for p, k in ((_resolve_basename(k), k) for k, _ in matches) if p]
if not resolved: return None
resolved.sort(key=lambda pk: os.path.getmtime(pk[0]), reverse=True)
return resolved[0][0]
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+63
View File
@@ -0,0 +1,63 @@
{
"name": "Boy",
"version": "1.0.0",
"author": "pzuh",
"source": "https://pzuh.itch.io/temple-run-game-sprites",
"description": "Boy 角色皮肤",
"style": "pixel",
"format": "sprite",
"size": {
"width": 80,
"height": 122
},
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 64,
"frameHeight": 98,
"frameCount": 10,
"columns": 40,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 64,
"frameHeight": 98,
"frameCount": 10,
"columns": 40,
"fps": 3,
"startFrame": 20
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 64,
"frameHeight": 98,
"frameCount": 10,
"columns": 40,
"fps": 10,
"startFrame": 20
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 64,
"frameHeight": 98,
"frameCount": 10,
"columns": 40,
"fps": 24,
"startFrame": 20
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

+60
View File
@@ -0,0 +1,60 @@
{
"name": "Dinosaur",
"version": "1.0.0",
"author": "voidcord54",
"source": "https://voidcord54.itch.io/",
"description": "像素风小恐龙 Dinosaur",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 128,
"frameHeight": 128,
"frameCount": 2,
"columns": 5,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 128,
"frameHeight": 128,
"frameCount": 2,
"columns": 5,
"fps": 4,
"startFrame": 2
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 128,
"frameHeight": 128,
"frameCount": 2,
"columns": 5,
"fps": 8,
"startFrame": 2
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 128,
"frameHeight": 128,
"frameCount": 2,
"columns": 5,
"fps": 16,
"startFrame": 2
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

+61
View File
@@ -0,0 +1,61 @@
{
"name": "Doux",
"version": "1.0.0",
"author": "arks",
"source": "https://arks.itch.io/dino-characters",
"license": "CC0",
"description": "像素风小恐龙 Doux",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 4,
"columns": 24,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 6,
"startFrame": 5
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 8,
"columns": 24,
"fps": 16,
"startFrame": 6
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 16,
"startFrame": 17
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+60
View File
@@ -0,0 +1,60 @@
{
"name": "Glube",
"version": "1.0.0",
"author": "SketchesWithKevin",
"source": "https://sketcheswithkevin.itch.io/glube-platformer",
"description": "像素风小怪兽 Glube",
"style": "pixel",
"format": "sprite",
"size": { "width": 65, "height": 38 },
"animations": {
"idle": {
"file": "idle.png",
"loop": true,
"sprite": {
"frameWidth": 44,
"frameHeight": 31,
"frameCount": 6,
"columns": 6,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "walk.png",
"loop": true,
"sprite": {
"frameWidth": 65,
"frameHeight": 32,
"frameCount": 8,
"columns": 8,
"fps": 6,
"startFrame": 0
}
},
"run": {
"file": "run.png",
"loop": true,
"sprite": {
"frameWidth": 65,
"frameHeight": 32,
"frameCount": 8,
"columns": 8,
"fps": 12,
"startFrame": 0
}
},
"sprint": {
"file": "run.png",
"loop": true,
"sprite": {
"frameWidth": 65,
"frameHeight": 32,
"frameCount": 8,
"columns": 8,
"fps": 24,
"startFrame": 0
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+6
View File
@@ -0,0 +1,6 @@
License is CC0 - https://creativecommons.org/public-domain/cc0/
YOU CAN:
-> You can do whatever you want with this asset, including modifying it for commercial use.
-> Credit is not required, but is greatly appreciated!
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+60
View File
@@ -0,0 +1,60 @@
{
"name": "Line",
"version": "1.0.0",
"author": "itch.io",
"source": "https://itch.io",
"description": "Line 角色皮肤",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 156,
"frameHeight": 185,
"frameCount": 4,
"columns": 28,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 156,
"frameHeight": 185,
"frameCount": 8,
"columns": 28,
"fps": 6,
"startFrame": 4
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 156,
"frameHeight": 185,
"frameCount": 8,
"columns": 28,
"fps": 10,
"startFrame": 12
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 156,
"frameHeight": 185,
"frameCount": 8,
"columns": 28,
"fps": 24,
"startFrame": 12
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

+61
View File
@@ -0,0 +1,61 @@
{
"name": "Mort",
"version": "1.0.0",
"author": "arks",
"source": "https://arks.itch.io/dino-characters",
"license": "CC0",
"description": "像素风小恐龙 Mort",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 4,
"columns": 24,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 6,
"startFrame": 5
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 8,
"columns": 24,
"fps": 16,
"startFrame": 6
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 16,
"startFrame": 17
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

+61
View File
@@ -0,0 +1,61 @@
{
"name": "Tard",
"version": "1.0.0",
"author": "arks",
"source": "https://arks.itch.io/dino-characters",
"license": "CC0",
"description": "像素风小恐龙 Tard",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 4,
"columns": 24,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 6,
"startFrame": 5
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 8,
"columns": 24,
"fps": 16,
"startFrame": 6
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 16,
"startFrame": 17
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

+61
View File
@@ -0,0 +1,61 @@
{
"name": "Vita",
"version": "1.0.0",
"author": "arks",
"source": "https://arks.itch.io/dino-characters",
"license": "CC0",
"description": "像素风小恐龙 Vita",
"style": "pixel",
"format": "sprite",
"size": { "width": 128, "height": 128 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 4,
"columns": 24,
"fps": 6,
"startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 6,
"startFrame": 5
}
},
"run": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 8,
"columns": 24,
"fps": 16,
"startFrame": 6
}
},
"sprint": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24,
"frameHeight": 24,
"frameCount": 6,
"columns": 24,
"fps": 16,
"startFrame": 17
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+597
View File
@@ -0,0 +1,597 @@
"""Slash-command prompt builders + scheduler-task discovery.
Goal of this module: keep TUI files (tuiapp_v2.py / tui_v3.py) thin. They only
need to forward `/update`, `/autorun`, `/morphling`, `/goal`, `/hive`
to the corresponding `build_*_prompt(args)` here, and ask
`list_scheduler_tasks()` / `start_scheduler_task()` for the `/scheduler` picker.
Design (per user 2026-05-27):
- All non-/scheduler commands are *prompt injection*: we craft a system-style
request and feed it to the main agent as a normal user message (the TUI is
free to display the raw `/cmd ...` as the visible bubble). This keeps the
agent in-session, lets it use every tool/SOP it normally would, and means
this file owns zero LLM logic.
- `/scheduler` is the only exception — it touches local FS state directly via
`sche_tasks/*.json` and the existing scheduler daemon, no LLM needed.
- All prompts deliberately *name* the relevant SOP file so the agent re-reads
it before acting (per CONSTITUTION rule 2: SOP-first).
This module has zero TUI imports — both frontends can depend on it without
either depending on the other.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import subprocess
import time
from pathlib import Path
from typing import Optional
_USER_SHELL: tuple[list[str], str] | None = None
COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent <bot@gaagent.ai>" as the final line of the commit message.'
def detect_user_shell() -> tuple[list[str], str]:
"""Return `([executable, ...flags_for_-c], display_name)` for the user's
interactive shell. Cached after first call.
`!cmd` in tui_v2 / tui_v3 invokes this so commands like `ls`, pipes,
globs, and shell builtins behave the way the user expects in whatever
shell launched the app, instead of hardcoding cmd.exe / /bin/sh.
Resolution order:
1. `$SHELL` if it points to an existing file (Unix, Git Bash, WSL)
2. Windows only: Git Bash at the canonical install paths
3. `bash` anywhere on PATH (WSL bash, Cygwin, MSYS2, etc.)
4. Windows only: `pwsh` then `powershell.exe` on PATH
5. Unix `/bin/sh` / Windows `%COMSPEC%` (cmd.exe) — last resort
"""
global _USER_SHELL
if _USER_SHELL is not None:
return _USER_SHELL
s = os.environ.get("SHELL")
if s and os.path.exists(s):
name = os.path.basename(s)
if name.lower().endswith(".exe"):
name = name[:-4]
_USER_SHELL = ([s, "-c"], name)
return _USER_SHELL
if sys.platform == "win32":
for p in (
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
):
if os.path.exists(p):
_USER_SHELL = ([p, "-c"], "bash")
return _USER_SHELL
bash = shutil.which("bash")
if bash:
_USER_SHELL = ([bash, "-c"], "bash")
return _USER_SHELL
for name in ("pwsh", "powershell"):
p = shutil.which(name)
if p:
# -NoProfile keeps each `!cmd` snappy + reproducible.
_USER_SHELL = ([p, "-NoProfile", "-Command"], name)
return _USER_SHELL
cmd = os.environ.get("COMSPEC", "cmd.exe")
_USER_SHELL = ([cmd, "/d", "/s", "/c"], "cmd")
return _USER_SHELL
_USER_SHELL = (["/bin/sh", "-c"], "sh")
return _USER_SHELL
# Repo root = parent of frontends/. Avoid hard-coding; both TUIs live next to
# this file and share the same anchor.
_ROOT = Path(__file__).resolve().parent.parent
# Language resolution is owned here (not passed in as a formal arg) so every
# prompt builder stays single-parameter and TUI call sites don't need to know
# which prompt happens to be bilingual. Source of truth, in order:
# 1. `GA_LANG` env var (scriptable override; matches tui_v3 convention)
# 2. tui_v3's persisted settings file (same path as tui_v3.py:_SETTINGS_PATH)
# 3. system locale (zh* → 'zh', else 'en')
# When the user switches language inside tui_v3 (set_lang persists), the next
# call here picks it up automatically -- no formal coupling, just a shared file.
_SETTINGS_PATH = _ROOT / "temp" / "tui_v3_settings.json"
def _current_lang() -> str:
env = (os.environ.get("GA_LANG") or "").strip().lower()
if env in ("zh", "en"):
return env
try:
with open(_SETTINGS_PATH, "r", encoding="utf-8") as f:
saved = (json.load(f) or {}).get("lang")
if saved in ("zh", "en"):
return saved
except Exception:
pass
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
v = os.environ.get(var, "")
if v:
return "zh" if v.lower().startswith("zh") else "en"
return "en"
# ----- prompt builders (pure functions, no I/O) ---------------------------
# SOP paths are written inline as literal strings in each builder below: a
# literal is self-documenting and locally readable, and a stale path is a
# zero-radius failure (the prompt is a hint to an intelligent agent, which
# re-reads the dir / asks if a SOP moved) — so we deliberately do NOT wrap it
# in a registry + existence-check machinery.
def _tail(args_text: str, label: str = "额外指示") -> str:
"""Append user-supplied args after a slash command as a free-form suffix.
User pattern (2026-05-27): the base prompt is a fixed injection that names
the SOP path; anything the user types after `/cmd ` is appended verbatim so
they can add per-invocation hints (e.g. `/morphling https://github.com/...`
or `/goal 调研 X,预算 50k token`).
"""
extra = (args_text or "").strip()
return f"\n\n{label}: {extra}" if extra else ""
def build_update_prompt(args_text: str = "") -> str:
"""Prompt-only /update orchestration; actual git work stays in-agent.
The TUI owns zero git/LLM logic. This prompt asks the normal agent loop to
do a user-friendly preflight (upstream commits + diff) before pulling.
Language follows `_current_lang()` so a /language switch in tui_v3 (or a
`GA_LANG=...` shell override) automatically flips this prompt too.
"""
if _current_lang() == "en":
return (
"Update this GenericAgent checkout from the official upstream "
"https://github.com/Lsdefine/GenericAgent .\n"
"1. `git fetch upstream`; note the current branch and any local commits ahead.\n"
"2. Preview: upstream commits not yet local (short hash + subject + date) "
"plus a changed-files summary.\n"
"3. Align history, upstream first: with local commits ahead, `git merge upstream/main` "
"(conflicts favor upstream); otherwise `git reset --mixed upstream/main`. "
"Never create a commit.\n"
"4. Reconcile the WORKING TREE — step 3 moves only history and the index, so files "
"holding uncommitted local edits keep shadowing upstream. For each file in "
"`git diff --name-only upstream/main`, decide upstream-first:\n"
" - Stale leftovers take upstream: back the file up, then "
"`git checkout upstream/main -- <file>`.\n"
" - Genuine local enhancements upstream lacks (local config, key templates, "
"fork-only features) stay, re-applied on top of upstream's version.\n"
" - Files with a `Fork-only local overlay` marker comment (e.g. `.gitignore`) are "
"always a MERGE: take upstream as the base, then re-append everything under the "
"marker verbatim — never drop that block.\n"
" Never `git add -A`, never blanket-checkout the branch, never blindly keep everything.\n"
"5. Verify every overlay marker still exists (grep for it); re-append any that got lost.\n"
"6. Summarize: branch HEAD, distance vs upstream, per-file outcome, backup location.\n"
"\n"
"After a successful update, say: \"Congratulations! 🎉 You have successfully updated "
"GenericAgent!\" Then you may ask: \"If you found this helpful, would you like to star "
"the GenericAgent repository? It helps the project grow! ⭐\""
f"{_tail(args_text, 'Extra instructions')}"
)
return (
"请更新当前 GenericAgent 仓库,官方上游为 https://github.com/Lsdefine/GenericAgent 。\n"
"1. `git fetch upstream`;确认当前分支及是否有领先上游的本地 commit。\n"
"2. 预览:本地尚未包含的上游提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
"3. 对齐提交历史,上游优先:有本地 commit 则 `git merge upstream/main`(冲突取上游);"
"否则 `git reset --mixed upstream/main`。全程不创建任何 commit。\n"
"4. 调和【工作区】——第 3 步只动历史与索引,带未提交改动的文件仍遮蔽上游最新版。对 "
"`git diff --name-only upstream/main` 列出的每个文件按上游优先裁决:\n"
" - 过时残留取上游:先备份,再 `git checkout upstream/main -- <file>`。\n"
" - 上游没有的真本地增强(本机配置、密钥模板、fork 专属功能)保留,并在上游最新版上重新适配。\n"
" - 带 `Fork-only local overlay` 标记注释的文件(如 `.gitignore`)一律【合并】:以上游为底,"
"把标记之下的内容逐字追加回去——绝不丢弃该块。\n"
" 禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
"5. 校验每个 overlay 标记仍存在(grep 标记);丢失即重新追加。\n"
"6. 小结:分支 HEAD、与上游差距、逐文件处理结果、备份位置。\n"
"\n"
"更新成功后,请说:\"Congratulations! 🎉 你已成功更新 GenericAgent\"随后可邀请:"
"\"如果觉得有帮助,要不要给 GenericAgent 仓库点个 Star?这会让项目成长更快!⭐\""
f"{_tail(args_text)}"
)
def build_autorun_prompt(args_text: str = "") -> str:
return (
"请进入「自主探索 / autonomous 模式」:先读 "
"memory/autonomous_operation_sop.md。"
"全程自驱,不可逆 / 高风险动作先 ask_user "
"结案给一份简明回执(做了什么 / 产物在哪 / 下一步)。"
f"{_tail(args_text, '任务种子')}"
)
def build_morphling_prompt(args_text: str = "") -> str:
return (
"请启用 Morphling 模式吞噬 / 蒸馏外部项目到本仓库:先读 "
"memory/morphling_sop.md。"
"没有目标先 ask_user 取 GitHub 仓库 / 本地路径 / 能力描述。"
f"{_tail(args_text, '目标技能/仓库')}"
)
def build_goal_prompt(args_text: str = "") -> str:
return (
"请进入 Goal 模式:先读 memory/goal_mode_sop.md。"
"若未给目标,先 ask_user 一次性问清:一句话目标 + condition 约束。"
f"{_tail(args_text, '用户目标')}"
)
def build_hive_prompt(args_text: str = "") -> str:
return (
"请进入 Goal Hive 模式(多 worker 协作版 goal):先读 "
"memory/goal_hive_sop.md。"
"集群目标 / worker 配额 / 终止条件未明确时先 ask_user 补齐再启动。"
f"{_tail(args_text, '集群目标')}"
)
def build_conductor_prompt(args_text: str = "") -> str:
"""`/conductor <task>` → run `frontends/conductor.py` on the task.
Upstream `memory/` ships no conductor SOP, so we deliberately keep the
prompt short: name the entrypoint and forward the task verbatim. The
agent is expected to know how to drive `conductor.py` (or consult a
local SOP if one happens to be installed).
"""
args_text = (args_text or "").strip()
if args_text:
return f"请调用 frontends/conductor.py 执行:{args_text}"
return (
"请调用 frontends/conductor.py,根据后续指令完成多 subagent 编排。"
"若任务描述缺失,先 ask_user 一次性补齐。"
)
# ----- /scheduler reflect-task discovery + launch -------------------------
def list_reflect_tasks() -> list[dict]:
"""Return [{name, path, doc}] for every reflect/*.py task script.
`doc` is the module docstring's first line (best-effort) so the picker can
show a one-liner next to each name. Empty list if reflect/ doesn't exist.
"""
out: list[dict] = []
refl = _ROOT / "reflect"
if not refl.is_dir():
return out
for p in sorted(refl.glob("*.py")):
if p.name.startswith("_"):
continue
doc = ""
try:
# Cheap docstring sniff: read first ~40 lines, look for """...""".
head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
joined = "\n".join(head)
for q in ('"""', "'''"):
i = joined.find(q)
if i != -1:
j = joined.find(q, i + 3)
if j != -1:
doc = joined[i + 3:j].strip().splitlines()[0].strip()
break
except Exception:
pass
out.append({"name": p.stem, "path": str(p), "doc": doc})
return out
# ----- hub.pyw parity: every launchable service ---------------------------
_HUB_EXCLUDES = {"goal_mode.py", "chatapp_common.py", "tuiapp.py"}
def _sniff_doc(p) -> str:
"""Best-effort first line of a module docstring (cheap ~40-line read)."""
try:
head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
joined = "\n".join(head)
for q in ('"""', "'''"):
i = joined.find(q)
if i != -1:
j = joined.find(q, i + 3)
if j != -1:
body = joined[i + 3:j].strip()
if body:
return body.splitlines()[0].strip()
except Exception:
pass
return ""
def list_launchable_services() -> list[dict]:
"""Mirror hub.pyw's discover_services() so `/scheduler` shows the *same*
set of launchable services as the GUI launcher.
Sources (hub.pyw EXCLUDES = goal_mode.py / chatapp_common.py / tuiapp.py):
• reflect/*.py (not '_'-prefixed, not excluded)
→ cmd = [python, agentmain.py, --reflect, reflect/<f>]
• frontends/*app*.py (not excluded)
'stapp' → `python -m streamlit run … --server.headless=true`
others → `python frontends/<f>`
Returns [{name, cmd, doc, kind}] where `name` is the hub-style path
('reflect/foo.py' / 'frontends/bar.py') and doubles as the picker value.
"""
out: list[dict] = []
refl = _ROOT / "reflect"
if refl.is_dir():
for p in sorted(refl.glob("*.py")):
if p.name.startswith("_") or p.name in _HUB_EXCLUDES:
continue
rel = "reflect/" + p.name
out.append({
"name": rel,
"cmd": [sys.executable, "agentmain.py", "--reflect", rel],
"doc": _sniff_doc(p),
"kind": "reflect",
})
fe = _ROOT / "frontends"
if fe.is_dir():
for p in sorted(fe.glob("*.py")):
if "app" not in p.name or p.name in _HUB_EXCLUDES:
continue
rel = "frontends/" + p.name
if "stapp" in p.name:
cmd = [sys.executable, "-m", "streamlit", "run", rel,
"--server.headless=true"]
else:
cmd = [sys.executable, rel]
out.append({"name": rel, "cmd": cmd, "doc": _sniff_doc(p),
"kind": "frontend"})
return out
def start_service(name: str) -> tuple[bool, str]:
"""Launch a service from list_launchable_services(), detached & window-less
(CONSTITUTION rule 14: creationflags at the launch layer only, never via
subprocess.Popen monkeypatch).
`name` accepts the hub-style path ('reflect/foo.py') or a bare reflect stem
('foo') for backward-compat with `/scheduler start <stem>`.
"""
svcs = list_launchable_services()
svc = next((s for s in svcs if s["name"] == name), None)
if svc is None: # bare reflect stem fallback
cand = "reflect/" + name + ".py"
svc = next((s for s in svcs if s["name"] == cand), None)
if svc is None:
return False, f"未知服务: {name}"
try:
flags = 0
if os.name == "nt":
flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
proc = subprocess.Popen(
svc["cmd"],
cwd=str(_ROOT),
creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
# Poll-and-confirm: if the child dies immediately (bad path, import
# error, port-in-use, etc) Popen still returns happily — without this
# check the picker would tick "✅ started" while nothing is running,
# which is exactly the bug#4 the user hit. 0.4s is the smallest
# window that catches "explodes at import" without making the UI
# feel laggy on healthy starts.
time.sleep(0.4)
rc = proc.poll()
if rc is not None:
return False, f"启动失败 (退出码 {rc}): {svc['name']}"
invalidate_running_cache()
return True, f"已启动 {svc['name']} (pid={proc.pid})"
except Exception as e:
return False, f"启动失败: {type(e).__name__}: {e}"
# ----- running-state introspection (bug#4) --------------------------------
# Why psutil cmdline-scan instead of a launched-by-us pid registry?
# • Services launched by a previous TUI run, or by hub.pyw, must also be
# recognised — otherwise /scheduler would happily start a duplicate.
# • A registry tied to this process dies when the TUI restarts, but the
# services keep running (CREATE_NEW_PROCESS_GROUP). Cmdline scan is the
# only single source of truth across launchers, surviving restarts.
# Trade-off: it costs ~30ms per /scheduler open, and matches by cmdline tail,
# so two checkouts of GA can collide. We accept that — running two GAs out
# of two clones is already an unsupported configuration.
def _match_service(cmdline: list[str], svc: dict) -> bool:
"""Does this OS process belong to `svc`? Match on the trailing script
arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps),
which is invariant across `python` vs `pythonw` vs venv shims.
Reflect detection used to require BOTH `agentmain.py` AND the reflect
path in cmdline. That rejected tasks launched directly (`python
reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier
TUI run that used a different launcher — they showed unticked in
/scheduler even when alive. Path-only match handles both styles; the
Python-process pre-filter in `running_services` keeps false positives
(greps, editors with the file open) from sneaking in."""
if not cmdline:
return False
rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py'
rel_norm = rel.replace("/", os.sep)
return any(rel_norm in (a or "") or rel in (a or "")
for a in cmdline)
# 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm.
# cmdline() is the per-proc cost; only pay it for python-ish survivors.
_RUNNING_CACHE: tuple[float, dict[str, int]] | None = None
_RUNNING_TTL = 2.0
def invalidate_running_cache() -> None:
"""Drop the snapshot. Call after start/stop so the next read is fresh."""
global _RUNNING_CACHE
_RUNNING_CACHE = None
def running_services(use_cache: bool = True) -> dict[str, int]:
"""{service_name: pid} for live services. {} if psutil missing."""
global _RUNNING_CACHE
if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL:
return dict(_RUNNING_CACHE[1])
try:
import psutil # type: ignore
except Exception:
return {}
svcs = list_launchable_services()
out: dict[str, int] = {}
me = os.getpid()
for proc in psutil.process_iter(["pid", "name"]):
try:
if proc.info["pid"] == me:
continue
nm = (proc.info.get("name") or "").lower()
if "python" not in nm and "py.exe" not in nm:
continue
cmd = proc.cmdline()
except Exception:
continue
for svc in svcs:
if svc["name"] not in out and _match_service(cmd, svc):
out[svc["name"]] = proc.info["pid"]
break
_RUNNING_CACHE = (time.time(), dict(out))
return out
def stop_service(name: str) -> tuple[bool, str]:
"""Terminate the service `name` if running. Returns (ok, message).
Sends SIGTERM-equivalent (Popen.terminate on Windows = TerminateProcess),
waits up to 3s, then escalates to kill. Also reaps obvious children
(e.g. `python -m streamlit` spawns the actual streamlit worker) so we
don't leave orphans behind.
"""
try:
import psutil # type: ignore
except Exception:
return False, "未安装 psutil,无法停止服务"
running = running_services()
pid = running.get(name)
if pid is None:
return False, f"{name} 未在运行"
try:
parent = psutil.Process(pid)
kids = parent.children(recursive=True)
for p in [parent, *kids]:
try:
p.terminate()
except Exception:
pass
gone, alive = psutil.wait_procs([parent, *kids], timeout=3.0)
for p in alive:
try:
p.kill()
except Exception:
pass
invalidate_running_cache()
return True, f"已停止 {name} (pid={pid})"
except psutil.NoSuchProcess:
invalidate_running_cache()
return True, f"{name} 已退出"
except Exception as e:
return False, f"停止失败: {type(e).__name__}: {e}"
def list_scheduler_tasks() -> list[dict]:
"""Return [{name, path, schedule, enabled}] for every sche_tasks/*.json.
Used by the /scheduler picker so users can also toggle traditional cron
tasks, not just reflect.* scripts.
"""
out: list[dict] = []
sd = _ROOT / "sche_tasks"
if not sd.is_dir():
return out
for p in sorted(sd.glob("*.json")):
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
out.append({
"name": p.stem,
"path": str(p),
"schedule": data.get("schedule") or data.get("cron") or data.get("every") or "",
"enabled": bool(data.get("enabled", True)),
})
return out
def start_reflect_task(name: str) -> tuple[bool, str]:
"""Spawn `python reflect/<name>.py` detached. Returns (ok, message).
Detached because reflect tasks are long-running; we don't want them to die
with the TUI. On Windows we use CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW
so no console pops up (per CONSTITUTION rule 14: only at launch layer, no
monkeypatching subprocess.Popen).
"""
script = _ROOT / "reflect" / f"{name}.py"
if not script.is_file():
return False, f"reflect/{name}.py 不存在"
try:
flags = 0
if os.name == "nt":
flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
subprocess.Popen(
[sys.executable, str(script)],
cwd=str(_ROOT),
creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
return True, f"已启动 reflect/{name}.py"
except Exception as e:
return False, f"启动失败: {type(e).__name__}: {e}"
# ----- dispatch table for the TUI to register against ---------------------
# (cmd, arg_hint, desc) — kept identical between v2 and v3 so the palette
# stays consistent across frontends.
PALETTE_ENTRIES: list[tuple[str, str, str]] = [
("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"),
("/autorun", "[seed]", "进入 autonomous_operation 自主模式"),
("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"),
("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt)"),
]
def prompt_for(cmd: str, args_text: str) -> Optional[str]:
"""Return the injected user-message for a given slash command, or None if
the command isn't one of ours (e.g. /scheduler — handled by TUI directly).
Language is resolved inside the builders that care about it (see
`_current_lang()`); callers never thread it through, so both TUIs keep a
single uniform call site.
"""
table = {
"/update": build_update_prompt,
"/autorun": build_autorun_prompt,
"/morphling": build_morphling_prompt,
"/goal": build_goal_prompt,
"/hive": build_hive_prompt,
"/conductor": build_conductor_prompt,
}
fn = table.get(cmd)
return fn(args_text) if fn else None
+408
View File
@@ -0,0 +1,408 @@
import os, sys, subprocess
from urllib.request import urlopen
from urllib.parse import quote
if sys.stdout is None: sys.stdout = open(os.devnull, "w")
if sys.stderr is None: sys.stderr = open(os.devnull, "w")
try: sys.stdout.reconfigure(errors='replace')
except: pass
try: sys.stderr.reconfigure(errors='replace')
except: pass
script_dir = os.path.dirname(__file__)
sys.path.append(os.path.abspath(os.path.join(script_dir, '..')))
sys.path.append(os.path.abspath(script_dir))
import streamlit as st
import time, json, re, threading, queue
from datetime import timedelta
from agentmain import GeneraticAgent
import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
from btw_cmd import handle_frontend_command as btw_handle_frontend
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed")
st.markdown("""
<style>
[data-testid="stBottom"]{position:fixed!important;bottom:0!important;left:0!important;right:0!important;width:100vw!important;z-index:999;background:var(--background-color,#fff)}
@media (min-width:768px){[data-testid="stSidebar"][aria-expanded="true"]~div [data-testid="stBottom"]{left:300px!important;width:calc(100vw - 300px)!important}}
.stMainBlockContainer{padding-bottom:10rem!important}
</style>
""", unsafe_allow_html=True)
LANG = os.environ.get('GA_LANG', 'zh')
if LANG not in ('zh', 'en'): LANG = 'zh'
I18N = {
'zh': {
'force_stop': '强行停止任务',
'desktop_pet': '🐱 桌面宠物',
'suggest_btn': '🎯 给我找点事做',
'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣',
'auto_start': '开始空闲自主行动',
'auto_pause': '⏸️ 禁止自主行动',
'auto_enable': '▶️ 允许自主行动',
'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行',
'auto_off_cap': '🔴 自主行动已停止',
'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。',
},
'en': {
'force_stop': 'Force Stop',
'desktop_pet': '🐱 Desktop Pet',
'suggest_btn': '🎯 Suggest tasks',
'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.',
'auto_start': 'Start idle auto-action',
'auto_pause': '⏸️ Pause auto-action',
'auto_enable': '▶️ Enable auto-action',
'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle',
'auto_off_cap': '🔴 Auto-action disabled',
'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.',
},
}
def T(key): return I18N.get(LANG, I18N['zh']).get(key, key)
@st.cache_resource
def init():
agent = GeneraticAgent()
if agent.llmclient is None:
st.error("⚠️ Please set mykey.py!")
st.stop()
else: threading.Thread(target=agent.run, daemon=True).start()
return agent
agent = init()
def build_prompt(objective):
return f"""读取 {agent.log_path} 尾部,获取 agent 的最新输出。
用户的 loop 诉求:<objective>{objective}</objective>
判断该 agent 是否偷懒、是否真正完成诉求,用 <next_prompt></next_prompt> 输出要追加给它的指令:
一般复述 objective,或不超过 10 字的**督促**,如:别停,继续 / 这就叫最优?你优化到位了吗 / 看我要求,你达成了吗 / 你好好看清楚 / 你能不能看看记忆 / 把关键发现和阶段性成果落盘,然后继续
不允许促进 agent 停止或代替宣告任务完成,只允许催促不要对原任务进行评价,特别**禁止**“任务已完成,结束”这种让agent结束的指令,你的任务是让agent继续loop而非停止。
只输出 <next_prompt>…</next_prompt>,若需要停止则不要输出此tag。
"""
@st.cache_resource
def get_controller():
b = {'ev': threading.Event(), 'obj': '', 'out': None, 'ready': False}
def loop():
ag = GeneraticAgent(); ag.verbose = False; ag.log_path = False
threading.Thread(target=ag.run, daemon=True).start()
while True:
b['ev'].wait(); b['ev'].clear()
if ag.llm_no != agent.llm_no: ag.next_llm(agent.llm_no)
dq = ag.put_task(build_prompt(b['obj']), source="controller")
while 'done' not in (it := dq.get()): pass
ms = re.findall(r'<next_prompt>(.*?)</next_prompt>', it['done'], re.S)
b['out'] = ms[-1].strip() if ms else None; b['ready'] = True
threading.Thread(target=loop, daemon=True).start(); return b
st.title("🖥️ Cowork")
st.session_state.setdefault('autonomous_enabled', False)
@st.fragment
def render_sidebar():
st.session_state.setdefault('autonomous_enabled', False)
llm_options = agent.list_llms()
current_idx = agent.llm_no
llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options}
st.caption(f"LLM Core: {llm_labels.get(current_idx, str(current_idx))}")
selected_idx = st.selectbox("LLM", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, label_visibility="collapsed", key="sidebar_llm_select")
if selected_idx != current_idx:
agent.next_llm(selected_idx); st.rerun(scope="fragment")
if st.button(T('force_stop')):
agent.abort(); st.toast("Stop signal sended"); st.rerun()
if st.button(T('desktop_pet')):
kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}
pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw')
if not os.path.exists(pet_script):
st.error("desktop_pet_v2.pyw not found")
return
subprocess.Popen([sys.executable, pet_script], **kwargs)
def _pet_req(q):
def _do():
try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2)
except Exception: pass
threading.Thread(target=_do, daemon=True).start()
agent._pet_req = _pet_req
if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}
def _pet_hook(ctx):
parts = [f"Turn {ctx.get('turn','?')}"]
if ctx.get('summary'): parts.append(ctx['summary'])
if ctx.get('exit_reason'): parts.append('DONE')
_pet_req(f'msg={quote(chr(10).join(parts))}')
if ctx.get('exit_reason'): _pet_req('state=idle')
agent._turn_end_hooks['pet'] = _pet_hook
st.toast("Desktop pet started")
if st.button(T('suggest_btn')):
st.session_state['_inject_prompt'] = T('suggest_prompt')
st.rerun(scope="app")
st.divider()
st.markdown("""<style>
[data-testid="stSidebar"] .stTextArea textarea {
field-sizing: content; min-height: 1.6em !important; height: auto !important;
}
</style>""", unsafe_allow_html=True)
st.text_area("Loop prompt", value=st.session_state.get('loop_prompt_input', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1)
if st.session_state.get('loop_enabled'):
if st.button("⏹️ Stop Loop"):
st.session_state.loop_enabled = False
st.toast("⏹️ Loop stopped"); st.rerun(scope="app")
st.caption("🔁 Looping")
else:
if st.button("🔁 Loop!"):
st.session_state.loop_enabled = True
get_controller()
st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt_input', '')
st.toast("🔁 Looping"); st.rerun(scope="app")
st.divider()
if st.button(T('auto_start')):
st.session_state.last_reply_time = int(time.time()) - 1800
st.session_state.autonomous_enabled = True
st.rerun(scope="app")
if st.session_state.autonomous_enabled:
if st.button(T('auto_pause')):
st.session_state.autonomous_enabled = False
st.toast(T('auto_pause')); st.rerun(scope="app")
st.caption(T('auto_on_cap'))
else:
if st.button(T('auto_enable'), type="primary"):
st.session_state.autonomous_enabled = True
st.toast(""); st.rerun(scope="app")
st.caption(T('auto_off_cap'))
with st.sidebar: render_sidebar()
def fold_turns(text):
"""Return list of segments: [{'type':'text','content':...}, {'type':'fold','title':...,'content':...}]"""
# 先把4+反引号块替换为占位符,避免误切子agent嵌套的 LLM Running
_ph = []
safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], text, flags=re.DOTALL)
# 流式中间态:末尾可能有未闭合的4+反引号块,也需保护
safe = re.sub(r'`{4,}[^`].*$', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], safe, flags=re.DOTALL)
parts = re.split(r'(\**LLM Running \(Turn \d+\) \.\.\.\*\**)', safe)
parts = [re.sub(r'\x00PH(\d+)\x00', lambda m: _ph[int(m.group(1))], p) for p in parts]
if len(parts) < 4: return [{'type': 'text', 'content': text}]
segments = []
if parts[0].strip(): segments.append({'type': 'text', 'content': parts[0]})
turns = []
for i in range(1, len(parts), 2):
marker = parts[i]
content = parts[i+1] if i+1 < len(parts) else ''
turns.append((marker, content))
for idx, (marker, content) in enumerate(turns):
if idx < len(turns) - 1:
_c = re.sub(r'`{3,}.*?`{3,}|<thinking>.*?</thinking>', '', content, flags=re.DOTALL)
matches = re.findall(r'<summary>\s*((?:(?!<summary>).)*?)\s*</summary>', _c, re.DOTALL)
if matches:
title = matches[0].strip()
title = title.split('\n')[0]
if len(title) > 50: title = title[:50] + '...'
else:
_plain = _c.strip().split('\n', 1)[0]
title = (_plain[:50] + '...') if len(_plain) > 50 else (_plain or marker.strip('*'))
segments.append({'type': 'fold', 'title': title, 'content': content})
else: segments.append({'type': 'text', 'content': marker + content})
return segments
_SUMMARY_TAG_RE = re.compile(r'<summary>.*?</summary>\s*', re.DOTALL)
def render_segments(segments, suffix=''):
# 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。
# heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁;
# 但 container/markdown 本身是 API 调用,StopException 仍会被抛出(abort 照常起作用)。
for seg in segments:
if seg['type'] == 'fold':
with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])
else:
st.markdown(seg['content'] + suffix)
def agent_backend_stream(prompt=None):
"""Drain main task display_queue.
- prompt given: start a fresh task; new dq is kept in session_state.
- prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw).
Per-chunk progress is mirrored to session_state.partial_response so the rendered
bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button."""
if prompt is not None:
st.session_state.display_queue = agent.put_task(prompt, source="user")
st.session_state.partial_response = ''
dq = st.session_state.get('display_queue')
if dq is None: return
# Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial
# ended right at a turn boundary with no content yet — otherwise the resume
# bubble flashes as a marker-only gray line. The marker reappears with
# content on the next chunk (raw_resp is cumulative).
response = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$',
'', st.session_state.get('partial_response', '')).rstrip()
try:
while True:
try: item = dq.get(timeout=1)
except queue.Empty:
yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
continue
if 'next' in item:
response = item['next']
st.session_state.partial_response = response
yield response
if 'done' in item:
st.session_state.display_queue = None
st.session_state.partial_response = ''
yield item['done']; break
finally:
agent.abort()
try:
st.session_state.display_queue = None
st.session_state.partial_response = ''
except BaseException:
pass
def render_main_stream(prompt=None):
"""Render the assistant bubble for the main task (new or resumed). Saves final to messages."""
with st.chat_message("assistant"):
frozen = 0; live = st.empty(); response = ''
CURSOR = ''
for response in agent_backend_stream(prompt):
segs = fold_turns(response)
n_done = max(0, len(segs) - 1)
while frozen < n_done:
with live.container(): render_segments([segs[frozen]])
live = st.empty(); frozen += 1
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
segs = fold_turns(response)
for i in range(frozen, len(segs)):
with live.container(): render_segments([segs[i]])
if i < len(segs) - 1: live = st.empty()
if response:
st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.last_reply_time = int(time.time())
# ── 循环回调:回答完成戳醒 controller 决策(去程,现取最新objective) ──
if st.session_state.get('loop_enabled'):
b = get_controller()
b['obj'] = st.session_state.get('loop_prompt_input', ''); b['ready'] = False; b['ev'].set()
if not hasattr(agent, "_ui_messages"): agent._ui_messages = st.session_state.get("messages", [])
if "messages" not in st.session_state: st.session_state.messages = agent._ui_messages
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
# 用 slot=st.empty() + with slot.container(): ... 的外壳,DOM 路径和流式渲染完全一致,跨 rerun 对齐
slot = st.empty()
with slot.container():
if msg["role"] == "assistant": render_segments(fold_turns(msg["content"]))
else: st.markdown(msg["content"])
# Scroll-height ghost fix: during streaming, expander open/close mid-animation can leave
# phantom height → scrollbar long but can't scroll to bottom. Periodically detect & reflow.
try:
from streamlit import iframe as _st_iframe # 1.56+
_embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()})
except (ImportError, AttributeError):
from streamlit.components.v1 import html as _embed_html # ≤1.55
# IME composition fix (macOS only) - prevents Enter from submitting during CJK input
_js_ime_fix = ("" if os.name == 'nt' else
"!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;"
"var d=window.parent.document,c=0;"
"d.addEventListener('compositionstart',()=>c=1,!0);"
"d.addEventListener('compositionend',()=>c=0,!0);"
"function f(){d.querySelectorAll('textarea[data-testid=stChatInputTextArea]')"
".forEach(t=>{t.__imeFix||(t.__imeFix=1,t.addEventListener('keydown',e=>{"
"e.key==='Enter'&&!e.shiftKey&&(e.isComposing||c||e.keyCode===229)&&"
"(e.stopImmediatePropagation(),e.preventDefault())},!0))})}"
"f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()")
_embed_html(f'<script>{_js_ime_fix}</script>', height=0)
_injected = st.session_state.pop('_inject_prompt', None)
prompt = st.chat_input("any task?") or _injected
if prompt:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
cmd = (prompt or "").strip()
def _reset_and_rerun():
st.session_state.streaming = False
st.session_state.stopping = False
st.session_state.display_queue = None
st.session_state.partial_response = ""
st.session_state.reply_ts = ""
st.session_state.current_prompt = ""
st.session_state.last_reply_time = int(time.time())
st.rerun()
if cmd == "/new":
st.session_state.messages = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}]
_reset_and_rerun()
if cmd.startswith("/continue"):
m = re.match(r'/continue\s+(\d+)\s*$', cmd.strip())
sessions = list_sessions(exclude_pid=os.getpid()) if m else []
idx = int(m.group(1)) - 1 if m else -1
# Resolve target path BEFORE handle (which snapshots current log, shifting indices).
target = sessions[idx][0] if 0 <= idx < len(sessions) else None
result = handle_frontend_command(agent, cmd)
history = extract_ui_messages(target) if target and result.startswith('') else None
tail = [{"role": "assistant", "content": result, "time": ts}]
if history: st.session_state.messages = history + tail
else: st.session_state.messages = list(st.session_state.messages)+[{"role": "user", "content": cmd, "time": ts}]+tail
_reset_and_rerun()
if cmd.startswith("/btw"):
answer = btw_handle_frontend(agent, cmd) # sync; bypasses put_task → main agent.run() untouched
st.session_state.messages = list(st.session_state.messages) + [
{"role": "user", "content": prompt, "time": ts},
{"role": "assistant", "content": answer, "time": ts},
]
st.rerun() # preserve display_queue/partial_response so resume path drains the running main task
if cmd.startswith("/export"):
parts = cmd.split(maxsplit=1)
sub = parts[1].strip() if len(parts) > 1 else ""
sub_lower = sub.lower()
if not sub:
result = (
"**选择导出方式:**\n\n"
"- `/export clip` — 整理到代码块中\n"
"- `/export <文件名>` — 导出到 `temp/<文件名>`(默认 .md 后缀)\n"
"- `/export all` — 显示完整对话日志路径"
)
elif sub_lower == "all":
log = agent.log_path
result = (f"📂 完整对话日志:\n\n`{log}`" if os.path.isfile(log)
else f"❌ 当前会话尚无日志文件")
else:
text = last_assistant_text(agent)
if not text:
result = "❌ 还没有模型回复可导出"
elif sub_lower in ("clip", "copy"):
result = f"📋 最后一轮回复(点代码块右上角 📋 复制):\n\n{wrap_for_clipboard(text)}"
else:
try:
path = export_to_temp(text, sub)
result = f"✅ 已导出:\n\n`{path}`"
except Exception as e:
result = f"❌ 导出失败: {e}"
st.session_state.messages = list(st.session_state.messages) + [
{"role": "user", "content": cmd, "time": ts},
{"role": "assistant", "content": result, "time": ts},
]
_reset_and_rerun()
# Regular prompt: any in-flight task will be aborted by the finally block in
# agent_backend_stream when StopException interrupts the prior generator.
st.session_state.messages.append({"role": "user", "content": prompt})
if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')
with st.chat_message("user"): st.markdown(prompt)
render_main_stream(prompt)
elif st.session_state.get('display_queue') is not None:
# No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.
render_main_stream()
# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ──
@st.fragment(run_every=timedelta(minutes=1))
def _idle_checker():
if st.session_state.get('loop_enabled'):
b = get_controller()
if b['ready']:
b['ready'] = False
if b['out'] and '停止循环' not in b['out']: st.session_state['_inject_prompt'] = b['out']
else: st.session_state.loop_enabled = False
st.rerun(scope="app")
return
if not st.session_state.get('autonomous_enabled'): return
if st.session_state.get('display_queue') is not None: return # 正在运行中
last = st.session_state.get('last_reply_time', int(time.time()))
if time.time() - last > 1800:
st.session_state['_inject_prompt'] = T('auto_prompt')
st.session_state['last_reply_time'] = int(time.time()) # 防重入
st.rerun(scope="app")
_idle_checker()
+1049
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+6050
View File
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
"""Textual terminal UI for GenericAgent.
Run from the project root:
python frontends/tuiapp.py
Useful options:
python frontends/tuiapp.py --help
MVP design notes:
- One TUI manages multiple GenericAgent instances.
- GenericAgent.put_task() returns a per-task display_queue; the TUI records a task_id for every submit.
- Agent.run() and display_queue.get() run in daemon threads; UI updates are posted via App.call_from_thread().
- Multiple sessions may run concurrently, but GenericAgent still shares project temp/memory/tool globals.
"""
from __future__ import annotations
import argparse
import os
import queue
import re
import sys
import threading
import time
from dataclasses import dataclass, field
from itertools import count
from typing import Any, Callable, Optional
try:
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widgets import Footer, Header, RichLog, Static, TextArea
except ModuleNotFoundError as exc: # pragma: no cover - exercised by manual missing-dep path
if exc.name == "textual":
print("Textual is required. Install with: pip install textual", file=sys.stderr)
else:
print(f"Missing dependency: {exc.name}", file=sys.stderr)
raise SystemExit(2) from exc
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
AgentFactory = Callable[[], Any]
@dataclass
class ChatMessage:
role: str
content: str
task_id: Optional[int] = None
done: bool = True
_rendered_panel: Any = field(default=None, repr=False)
@dataclass
class AgentSession:
agent_id: int
name: str
agent: Any
thread: Optional[threading.Thread] = None
status: str = "idle"
messages: list[ChatMessage] = field(default_factory=list)
task_seq: int = 0
current_task_id: Optional[int] = None
current_display_queue: Optional[queue.Queue] = None
buffer: str = ""
def fold_turns(text: str) -> list[dict[str, str]]:
"""Split GenericAgent turn output into text/fold segments.
Completed turns become ``{'type': 'fold', 'title': ..., 'content': ...}``.
The latest/incomplete turn remains ``type='text'`` for streaming refresh.
"""
placeholders: list[str] = []
def stash(match: re.Match[str]) -> str:
placeholders.append(match.group(0))
return f"\x00PH{len(placeholders) - 1}\x00"
# Line-anchored fence matcher — see tuiapp_v2.fold_turns for rationale.
# Unanchored variant mis-paired backticks embedded in file_read output
# with later real fences, swallowing turn markers and ballooning the
# final "text" segment to MBs (1.85s markdown render on /continue).
safe = re.sub(r"^`{4,}.*?^`{4,}\n?", stash, text, flags=re.DOTALL | re.MULTILINE)
parts = re.split(r"(\**LLM Running \(Turn \d+\) \.\.\.\**)", safe)
def restore(part: str) -> str:
return re.sub(r"\x00PH(\d+)\x00", lambda m: placeholders[int(m.group(1))], part)
parts = [restore(p) for p in parts]
if len(parts) < 4:
return [{"type": "text", "content": text}]
segments: list[dict[str, str]] = []
if parts[0].strip():
segments.append({"type": "text", "content": parts[0]})
turns: list[tuple[str, str]] = []
for i in range(1, len(parts), 2):
marker = parts[i]
content = parts[i + 1] if i + 1 < len(parts) else ""
turns.append((marker, content))
for idx, (marker, content) in enumerate(turns):
if idx < len(turns) - 1:
cleaned = re.sub(r"`{3,}.*?`{3,}|<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
matches = re.findall(r"<summary>\s*((?:(?!<summary>).)*?)\s*</summary>", cleaned, re.DOTALL)
if matches:
title = matches[0].strip().split("\n", 1)[0]
else:
title = cleaned.strip().split("\n", 1)[0] or marker.strip("*")
# Strip trailing args portion from tool-call lines
title = re.sub(r",?\s*args:.*$", "", title)
if len(title) > 72:
title = title[:72] + "..."
segments.append({"type": "fold", "title": title, "content": content})
else:
segments.append({"type": "text", "content": marker + content})
return segments
def render_folded_text(text: str) -> str:
"""Render fold segments as terminal-friendly Markdown text.
Textual's interactive Collapsible widgets are best for static layouts; the MVP uses
a RichLog and re-renders compact summaries for completed turns to keep streaming cheap.
"""
rendered: list[str] = []
for seg in fold_turns(text):
if seg["type"] == "fold":
rendered.append(f"\n{seg.get('title') or 'completed turn'}\n\n")
else:
rendered.append(seg.get("content", ""))
return "".join(rendered)
def parse_local_command(raw: str) -> tuple[str, list[str]] | None:
"""Return (command, args) for TUI-owned slash commands; unknown slash is passthrough."""
text = (raw or "").strip()
if not text.startswith("/"):
return None
name, *rest = text.split(maxsplit=1)
cmd = name[1:].lower()
args = rest[0].split() if rest else []
if cmd in {"help", "status", "new", "switch", "sessions", "stop", "llm", "branch", "rewind", "clear", "close", "quit", "exit"}:
return cmd, args
return None
def default_agent_factory() -> Any:
from agentmain import GenericAgent
agent = GenericAgent()
agent.inc_out = True
return agent
class PromptInput(TextArea):
"""Multi-line input: Enter submits, Ctrl+Enter (ctrl+j) inserts newline, paste never auto-submits."""
BINDINGS = [
Binding("ctrl+j", "newline", "Newline", show=False),
]
class Submitted(Message):
"""Posted when the user presses Enter to submit."""
def __init__(self, value: str) -> None:
super().__init__()
self.value = value
def __init__(self, placeholder: str = "", **kwargs) -> None:
super().__init__(language=None, show_line_numbers=False, compact=True, placeholder=placeholder, **kwargs)
def _on_key(self, event: events.Key) -> None:
if event.key == "enter":
# Enter → submit
event.stop()
event.prevent_default()
value = self.text.rstrip()
self.clear()
self.post_message(self.Submitted(value))
elif event.key == "ctrl+j":
# Ctrl+Enter (ctrl+j) → insert newline
event.stop()
event.prevent_default()
start, end = self.selection
self._replace_via_keyboard("\n", start, end)
else:
super()._on_key(event)
class GenericAgentTUI(App[None]):
"""Textual app that manages multiple GenericAgent sessions."""
CSS = """
Screen { layout: vertical; }
#body { height: 1fr; }
#sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; overflow-x: hidden; }
#main { width: 1fr; }
#status { height: 3; border: solid $primary; padding: 0 1; }
#log { height: 1fr; border: solid $primary; padding: 0 1; }
#prompt { dock: bottom; height: auto; min-height: 1; max-height: 8; margin-bottom: 1; }
.hint { color: $text-muted; }
"""
BINDINGS = [
("ctrl+n", "new_session", "New session"),
("ctrl+s", "stop_current", "Stop"),
("ctrl+f", "toggle_fold", "Fold/Unfold"),
("ctrl+q", "quit", "Quit"),
Binding("ctrl+left", "prev_session", "←Prev", show=True, priority=True),
Binding("ctrl+right", "next_session", "Next→", show=True, priority=True),
]
def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None:
super().__init__()
self.agent_factory: AgentFactory = agent_factory or default_agent_factory
self.sessions: dict[int, AgentSession] = {}
self.current_id: Optional[int] = None
self._ids = count(1)
self.fold_mode: bool = True
self._last_stream_refresh: float = 0.0
self._stream_throttle_ms: float = 0.15 # seconds between streaming UI refreshes
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="body"):
yield Static("", id="sidebar")
with Vertical(id="main"):
yield Static("", id="status")
yield RichLog(id="log", wrap=True, highlight=True, markup=True)
yield PromptInput(placeholder="Message, or /help /new /branch /rewind /switch /clear /close /stop /llm /resume", id="prompt")
yield Footer()
def on_mount(self) -> None:
self.add_session("main")
self._system("Welcome to GenericAgent TUI. Type /help for commands.")
self.query_one("#prompt", PromptInput).focus()
def on_resize(self, event) -> None:
narrow = self.size.width < 70
self.query_one("#sidebar").styles.display = "none" if narrow else "block"
@property
def current(self) -> AgentSession:
if self.current_id is None:
raise RuntimeError("no active session")
return self.sessions[self.current_id]
def add_session(self, name: Optional[str] = None) -> AgentSession:
agent_id = next(self._ids)
agent = self.agent_factory()
try:
agent.inc_out = True
except Exception:
pass
session = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
thread.start()
session.thread = thread
self.sessions[agent_id] = session
self.current_id = agent_id
self._refresh_all()
return session
def action_prev_session(self) -> None:
"""Switch to previous session."""
ids = sorted(self.sessions.keys())
if len(ids) <= 1:
return
idx = ids.index(self.current_id)
self.current_id = ids[(idx - 1) % len(ids)]
self._refresh_all()
def action_next_session(self) -> None:
"""Switch to next session."""
ids = sorted(self.sessions.keys())
if len(ids) <= 1:
return
idx = ids.index(self.current_id)
self.current_id = ids[(idx + 1) % len(ids)]
self._refresh_all()
def action_switch_session(self, n: int) -> None:
"""Switch to session by id (used by /switch command)."""
if n in self.sessions:
self.current_id = n
self._refresh_all()
else:
self.notify(f"Session #{n} does not exist.", severity="warning")
def action_new_session(self) -> None:
self.add_session()
self._system(f"Created and switched to session #{self.current_id}.")
def action_stop_current(self) -> None:
self._cmd_stop([])
def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
value = event.value.rstrip()
if not value:
self._system("Empty input ignored. Type /help for commands.")
return
parsed = parse_local_command(value)
if parsed:
cmd, args = parsed
self._dispatch_command(cmd, args)
return
self.submit_user_message(value)
def _dispatch_command(self, cmd: str, args: list[str]) -> None:
handlers = {
"help": self._cmd_help,
"status": self._cmd_status,
"new": self._cmd_new,
"switch": self._cmd_switch,
"sessions": self._cmd_sessions,
"stop": self._cmd_stop,
"llm": self._cmd_llm,
"branch": self._cmd_branch,
"rewind": self._cmd_rewind,
"clear": self._cmd_clear,
"close": self._cmd_close,
"quit": lambda _args: self.exit(),
"exit": lambda _args: self.exit(),
}
handlers[cmd](args)
def submit_user_message(self, text: str) -> int:
session = self.current
if session.status == "running":
self._system(f"Session #{session.agent_id} is already running; wait or /stop before submitting another task.")
return -1
session.task_seq += 1
task_id = session.task_seq
session.current_task_id = task_id
session.buffer = ""
session.status = "running"
session.messages.append(ChatMessage("user", text))
session.messages.append(ChatMessage("assistant", "", task_id=task_id, done=False))
self._refresh_all()
try:
display_queue = session.agent.put_task(text, source="user")
except Exception as exc:
session.status = "error"
self._set_assistant_message(session.agent_id, task_id, f"[ERROR] put_task failed: {exc}", done=True)
return task_id
session.current_display_queue = display_queue
threading.Thread(
target=self._consume_display_queue,
args=(session.agent_id, task_id, display_queue),
name=f"ga-tui-consumer-{session.agent_id}-{task_id}",
daemon=True,
).start()
return task_id
def _consume_display_queue(self, agent_id: int, task_id: int, display_queue: queue.Queue) -> None:
buffer = ""
while True:
try:
item = display_queue.get(timeout=0.25)
except queue.Empty:
continue
if "next" in item:
buffer += str(item.get("next") or "")
self.call_from_thread(self._on_stream_update, agent_id, task_id, buffer, False)
if "done" in item:
done_text = str(item.get("done") or buffer)
self.call_from_thread(self._on_stream_update, agent_id, task_id, done_text, True)
return
def _on_stream_update(self, agent_id: int, task_id: int, text: str, done: bool) -> None:
session = self.sessions.get(agent_id)
if not session:
return
if session.current_task_id != task_id:
session.messages.append(ChatMessage("system", f"Stale update ignored for task {task_id}.", done=True))
return
session.buffer = text
if done:
session.status = "idle"
session.current_display_queue = None
self._set_assistant_message(agent_id, task_id, text, done=done)
def _set_assistant_message(self, agent_id: int, task_id: int, text: str, *, done: bool) -> None:
session = self.sessions.get(agent_id)
if not session:
return
for msg in reversed(session.messages):
if msg.role == "assistant" and msg.task_id == task_id:
msg.content = text
msg.done = done
break
else:
session.messages.append(ChatMessage("assistant", text, task_id=task_id, done=done))
if agent_id == self.current_id:
self._refresh_all()
else:
self._refresh_sidebar()
def _cmd_help(self, args: list[str]) -> None:
self._system(
"Commands:\n"
"/help - show this help\n"
"/new [name] - create and switch to a new agent session\n"
"/branch [name] - fork current session (copies LLM history + display)\n"
"/rewind - list rewindable turns; /rewind <n> to truncate history\n"
"/switch <id|name> - switch active session\n"
"/sessions - list sessions\n"
"/status - show current/all status\n"
"/stop - abort current session task\n"
"/clear - clear chat display (keeps LLM history)\n"
"/close - close current session (cannot close last)\n"
"/llm - list models for current session\n"
"/llm <n> - switch model for current session\n"
"/quit - exit TUI\n\n"
"Unknown slash commands (for example /session.x=... or /resume) are sent to GenericAgent."
)
def _cmd_new(self, args: list[str]) -> None:
name = " ".join(args).strip() or None
session = self.add_session(name)
self._system(f"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.")
def _cmd_branch(self, args: list[str]) -> None:
import copy
old_session = self.current
name = " ".join(args).strip() or f"{old_session.name}-branch"
new_session = self.add_session(name)
# Copy LLM backend history
try:
new_session.agent.llmclient.backend.history = copy.deepcopy(
old_session.agent.llmclient.backend.history
)
except Exception as e:
self._system(f"Branch warning: failed to copy history: {e}")
return
# Copy TUI display messages
new_session.messages = copy.deepcopy(old_session.messages)
new_session.task_seq = old_session.task_seq
n = len(new_session.agent.llmclient.backend.history)
self._system(f"Branched from #{old_session.agent_id} → #{new_session.agent_id} ({n} messages inherited).")
def _cmd_rewind(self, args: list[str]) -> None:
session = self.current
if session.status == "running":
self._system("Cannot rewind while running. /stop first.")
return
history = session.agent.llmclient.backend.history
# Find real user turn boundaries — skip tool_result messages
turns = [] # list of (index_in_history, preview_text)
for i, msg in enumerate(history):
if msg.get("role") != "user":
continue
content = msg.get("content")
# Pure string content is always a real user message
if isinstance(content, str):
turns.append((i, content[:60]))
continue
if isinstance(content, list):
# Skip if content is purely tool_result blocks
has_tool_result = any(b.get("type") == "tool_result" for b in content if isinstance(b, dict))
if has_tool_result:
continue
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
if texts and any(t.strip() for t in texts):
turns.append((i, (texts[0] or "")[:60]))
if not turns:
self._system("No rewindable turns in history.")
return
# Reverse numbering: 1 = most recent turn, 2 = second most recent, etc.
# /rewind without args: show list
if not args:
lines = [f"Rewindable turns ({len(turns)} total, showing last 10):"]
show = turns[-10:]
for offset, (_, preview) in enumerate(reversed(show), 1):
lines.append(f" {offset}) {preview!r}")
lines.append("/rewind <n> to rewind n turns (1 = undo last turn).")
self._system("\n".join(lines))
return
# /rewind <n>: truncate last n turns
try:
n = int(args[0])
except ValueError:
self._system("Usage: /rewind <n> (1 = undo last turn)")
return
if n < 1 or n > len(turns):
self._system(f"Invalid: range is 1-{len(turns)}")
return
# cut_at = index of the n-th turn from the end
cut_at = turns[-n][0]
removed = len(history) - cut_at
history[:] = history[:cut_at]
# Sync TUI messages: keep only messages before the corresponding user message
real_user_indices = [i for i, msg in enumerate(session.messages) if msg.role == "user"]
if n <= len(real_user_indices):
cut_msg = real_user_indices[-n]
session.messages = session.messages[:cut_msg]
# Mark rewind in agentmain's working memory history
try: session.agent.history.append(f"[USER]: /rewind {n}")
except Exception: pass
self._system(f"Rewound {n} turn(s). Removed {removed} history entries.")
def _cmd_clear(self, args: list[str]) -> None:
self.current.messages.clear(); self._refresh_all()
def _cmd_close(self, args: list[str]) -> None:
if len(self.sessions) <= 1:
self._system("Cannot close the last session."); return
del self.sessions[self.current_id]
self.current_id = next(iter(self.sessions))
self._refresh_all()
def _cmd_switch(self, args: list[str]) -> None:
if not args:
self._system("Usage: /switch <id|name>")
return
key = " ".join(args)
target: Optional[int] = None
if key.isdigit() and int(key) in self.sessions:
target = int(key)
else:
for sid, session in self.sessions.items():
if session.name == key:
target = sid
break
if target is None:
self._system(f"No session found for {key!r}.")
return
self.current_id = target
self._refresh_all()
self._system(f"Switched to session #{target}.")
def _cmd_sessions(self, args: list[str]) -> None:
lines = []
for sid, session in self.sessions.items():
mark = "*" if sid == self.current_id else " "
lines.append(f"{mark} #{sid} {session.name} [{session.status}] messages={len(session.messages)} task={session.current_task_id}")
self._system("Sessions:\n" + "\n".join(lines))
def _cmd_status(self, args: list[str]) -> None:
self._cmd_sessions(args)
def _cmd_stop(self, args: list[str]) -> None:
session = self.current
try:
session.agent.abort()
session.status = "stopping" if session.status == "running" else session.status
self._system(f"Stop signal sent to session #{session.agent_id}.")
except Exception as exc:
self._system(f"Stop failed: {exc}")
self._refresh_all()
def _cmd_llm(self, args: list[str]) -> None:
session = self.current
if args:
try:
session.agent.next_llm(int(args[0]))
self._system(f"Switched model to #{int(args[0])}.")
except Exception as exc:
self._system(f"Model switch failed: {exc}")
return
try:
rows = session.agent.list_llms()
self._system("Models:\n" + "\n".join(f"{'*' if cur else ' '} {i}: {name}" for i, name, cur in rows))
except Exception as exc:
self._system(f"Listing models failed: {exc}")
def _system(self, text: str) -> None:
if self.current_id is not None and self.current_id in self.sessions:
self.current.messages.append(ChatMessage("system", text))
self._refresh_all()
def _refresh_all(self) -> None:
if not self.is_mounted:
return
self._refresh_sidebar()
self._refresh_status()
self._refresh_log()
def _session_last_user_query(self, session: AgentSession) -> str:
"""Return the last user message content, truncated for sidebar display."""
for msg in reversed(session.messages):
if msg.role == "user":
text = msg.content.strip().replace("\n", " ")
return self._truncate_display(text, 20)
return ""
def _session_last_summary(self, session: AgentSession) -> str:
"""Extract the last <summary> from the most recent assistant message."""
for msg in reversed(session.messages):
if msg.role == "assistant" and msg.content:
matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", msg.content, re.DOTALL)
if matches:
text = matches[-1].strip().split("\n", 1)[0].replace("\n", " ")
return self._truncate_display(text, 20)
return ""
@staticmethod
def _truncate_display(text: str, max_width: int) -> str:
"""Truncate text by display width (CJK chars count as 2)."""
import unicodedata
width = 0
result = []
for ch in text:
w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
if width + w > max_width:
result.append("")
break
result.append(ch)
width += w
return "".join(result)
def _refresh_sidebar(self) -> None:
sidebar = self.query_one("#sidebar", Static)
max_w = 26 # 30 - 2(border) - 2(padding)
lines: list[str] = ["[b]Sessions[/b]", ""]
for sid, session in self.sessions.items():
mark = "" if sid == self.current_id else " "
last_q = self._session_last_user_query(session)
last_s = self._session_last_summary(session)
status_style = "green" if session.status == "running" else "dim"
# Header line: "▶ #1 name status" — truncate name if needed
prefix = f"{mark} #{sid} "
suffix = f" {session.status}"
name_max = max_w - len(prefix) - len(suffix)
name_disp = self._truncate_display(session.name, max(name_max, 4))
lines.append(f"{prefix}{name_disp} [{status_style}]{session.status}[/{status_style}]")
if last_q:
lines.append(f" [dim]Q:{last_q}[/dim]")
if last_s:
lines.append(f" [dim]S:{last_s}[/dim]")
lines.append("")
lines.append("[dim]/new, /switch, Ctrl+N[/dim]")
lines.append("[dim]I have memory, just say what you want[/dim]")
sidebar.update("\n".join(lines))
def _refresh_status(self) -> None:
status = self.query_one("#status", Static)
if self.current_id is None:
status.update("No session")
return
session = self.current
try:
model = session.agent.get_llm_name(model=True)
except Exception:
model = "unknown"
status.update(
f"[b]#{session.agent_id} {session.name}[/b] status={session.status} task={session.current_task_id} model={model}\n"
"Enter message or /help. Per-task queue streaming is enabled (inc_out=True)."
)
def action_toggle_fold(self) -> None:
self.fold_mode = not self.fold_mode
# Invalidate cached panels for assistant messages since fold state changed
if self.current_id is not None:
for msg in self.current.messages:
if msg.role == "assistant":
msg._rendered_panel = None
self._refresh_log()
mode_label = "folded" if self.fold_mode else "expanded"
self.notify(f"Display mode: {mode_label} (Ctrl+F to toggle)")
def _refresh_log(self) -> None:
log = self.query_one("#log", RichLog)
log.clear()
if self.current_id is None:
return
all_msgs = self.current.messages
# Limit to last 150 messages for performance
if len(all_msgs) > 150:
display_msgs = all_msgs[-150:]
log.write(Text(f"{len(all_msgs) - 150} older messages hidden ↑", style="dim italic"))
else:
display_msgs = all_msgs
# Collect recent task_ids to only expand the latest 3 tasks
recent_task_ids: set[int] = set()
if not self.fold_mode:
seen: list[int] = []
for msg in reversed(display_msgs):
if msg.role == "assistant" and msg.task_id not in seen:
seen.append(msg.task_id)
if len(seen) == 5:
break
recent_task_ids = set(seen)
for msg in display_msgs:
if msg.role == "user":
if msg._rendered_panel is None:
msg._rendered_panel = Panel(Markdown(msg.content), title="You", border_style="blue")
log.write(msg._rendered_panel)
elif msg.role == "assistant":
if msg.done and msg._rendered_panel is not None:
log.write(msg._rendered_panel)
else:
suffix = "" if msg.done else "\n"
# Fold older tasks even in unfold mode to reduce render cost
should_fold = self.fold_mode or (msg.task_id not in recent_task_ids)
content = render_folded_text(msg.content) if should_fold else msg.content
panel = Panel(Markdown(content + suffix), title=f"Agent task {msg.task_id}", border_style="green")
if msg.done:
msg._rendered_panel = panel
log.write(panel)
else:
if msg._rendered_panel is None:
msg._rendered_panel = Panel(Text(msg.content), title="System", border_style="yellow")
log.write(msg._rendered_panel)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Textual TUI for GenericAgent")
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_arg_parser().parse_args(argv)
app = GenericAgentTUI()
app.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, hashlib, math
from pathlib import Path
from urllib.parse import quote
import requests, qrcode
from Crypto.Cipher import AES
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent
# ── AuthExpired (errcode -14 from getUpdates) ──
class AuthExpired(Exception):
"""Bot token expired or invalid (errcode=-14)."""
pass
# ── Per-user abort flags (shared between on_message invocations) ──
_task_aborted: dict = {} # uid -> True (set by /stop, read by _handle)
# ── WxBotClient (inline from wx_bot_client.py) ──
for _k in ('HTTPS_PROXY', 'https_proxy'):
os.environ.pop(_k, None) # avoid inherited proxy breaking WeChat long-poll SSL
API = 'https://ilinkai.weixin.qq.com'
TOKEN_FILE = Path.home() / '.wxbot' / 'token.json'
TOKEN_FILE.parent.mkdir(exist_ok=True)
VER, MSG_USER, MSG_BOT, ITEM_TEXT, STATE_FINISH = '2.1.10', 1, 2, 1, 2
ILINK_APP_ID = 'bot'
ILINK_APP_CLIENT_VERSION = (2 << 16) | (1 << 8) | 10
UA = f'openclaw-weixin/{VER}'
ITEM_IMAGE, ITEM_FILE, ITEM_VIDEO = 2, 4, 5
CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'
def _uin():
return base64.b64encode(str(struct.unpack('>I', os.urandom(4))[0]).encode()).decode()
class WxBotClient:
def __init__(self, token=None, token_file=None):
self._tf = Path(token_file) if token_file else TOKEN_FILE
self.token = token
self.bot_id = None
self._buf = ''
if not self.token: self._load()
def _load(self):
if self._tf.exists():
d = json.loads(self._tf.read_text('utf-8'))
self.token, self.bot_id, self._buf = d.get('bot_token',''), d.get('ilink_bot_id',''), d.get('updates_buf','')
def _save(self, **kw):
d = {'bot_token': self.token or '', 'ilink_bot_id': self.bot_id or '',
'updates_buf': self._buf or '', **kw}
self._tf.write_text(json.dumps(d, ensure_ascii=False, indent=2), 'utf-8')
def _post(self, ep, body, timeout=15):
data = json.dumps(body, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
h = {'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token',
'Content-Length': str(len(data)), 'X-WECHAT-UIN': _uin(),
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': str(ILINK_APP_CLIENT_VERSION),
'User-Agent': UA}
tok = (self.token or '').strip()
if tok: h['Authorization'] = f'Bearer {tok}'
r = requests.post(f'{API}/{ep}', data=data, headers=h, timeout=timeout)
r.raise_for_status()
return r.json()
def login_qr(self, poll_interval=2):
# 获取二维码:对限流/缺字段(无 'qrcode')退避重试,避免崩溃→重启→更狠地打接口的死亡螺旋
d = {}
for attempt in range(6):
try:
r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)
r.raise_for_status()
d = r.json()
except requests.exceptions.RequestException as e:
print(f'[QR登录] 获取二维码失败({e}),{2 ** attempt}s 后重试...'); time.sleep(2 ** attempt); continue
if d.get('qrcode') and d.get('qrcode_img_content'): # 二维码 ID + 可扫图都就绪才算成功
break
print(f'[QR登录] 二维码未就绪(可能被限流,ret={d.get("ret")}),{2 ** attempt}s 后重试...')
time.sleep(2 ** attempt)
if not (d.get('qrcode') and d.get('qrcode_img_content')):
raise RuntimeError('多次重试仍未获取到可扫二维码(疑似限流),请稍后重试')
qr_id, url = d['qrcode'], d.get('qrcode_img_content', '')
print(f'[QR登录] ID: {qr_id}')
if url:
# 先打 ASCII 二维码(纯文本,无需 PIL;容器/无头环境靠它扫码)
qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True)
# 再尝试存 PNG 兜底——依赖 PIL,缺失/失败不应让登录崩溃
try:
qrcode.make(url).save(str(self._tf.parent / 'wx_qr.png'))
except Exception as e:
print(f'[QR登录] PNG 兜底保存失败({e}),用上方 ASCII 二维码扫码即可')
last = ''
while True:
time.sleep(poll_interval)
# 轮询状态:对所有网络异常 / 非 JSON(被限流时常返回 HTML)容错重试,
# 不让单次抖动把进程打崩——配合 restart:unless-stopped 否则会死亡螺旋
try:
s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()
except requests.exceptions.RequestException:
continue
except ValueError: # 响应非 JSON(限流/网关页)
time.sleep(poll_interval); continue
st = s.get('status', '')
if st != last: print(f' 状态: {st}'); last = st
if st == 'confirmed':
self.token, self.bot_id = s.get('bot_token', ''), s.get('ilink_bot_id', '')
self._save(login_time=time.strftime('%Y-%m-%d %H:%M:%S'))
print(f'[QR登录] 成功! bot_id={self.bot_id}')
return s
if st == 'expired': raise RuntimeError('二维码过期')
def get_updates(self, timeout=30):
try:
resp = self._post('ilink/bot/getupdates',
{'get_updates_buf': self._buf or '',
'base_info': {'channel_version': VER}},
timeout=timeout + 5)
except requests.exceptions.ReadTimeout:
return []
if resp.get('errcode'):
print(f'[getUpdates] err: {resp.get("errcode")} {resp.get("errmsg","")}')
if resp['errcode'] == -14:
self._buf = ''; self.token = ''; self.bot_id = ''
self._save(bot_token='', ilink_bot_id='')
raise AuthExpired(resp.get('errmsg',''))
return []
nb = resp.get('get_updates_buf', '')
if nb: self._buf = nb; self._save()
return resp.get('msgs') or []
def send_text(self, to_user_id, text, context_token=''):
msg = {'from_user_id': '', 'to_user_id': to_user_id,
'client_id': f'pyclient-{uuid.uuid4().hex[:16]}',
'message_type': MSG_BOT, 'message_state': STATE_FINISH,
'item_list': [{'type': ITEM_TEXT, 'text_item': {'text': text}}]}
if context_token: msg['context_token'] = context_token
return self._post('ilink/bot/sendmessage', {'msg': msg, 'base_info': {'channel_version': VER}})
def send_typing(self, to_user_id, typing_ticket='', cancel=False):
return self._post('ilink/bot/sendtyping', {
'ilink_user_id': to_user_id, 'typing_ticket': typing_ticket,
'status': 2 if cancel else 1,
'base_info': {'channel_version': VER}})
def get_typing_ticket(self, to_user_id, context_token=''):
payload = {'ilink_user_id': to_user_id}
if context_token: payload['context_token'] = context_token
return self._post('ilink/bot/getconfig', payload).get('typing_ticket', '')
def _enc(self, raw, aes_key):
pad = 16 - (len(raw) % 16)
return AES.new(aes_key, AES.MODE_ECB).encrypt(raw + bytes([pad] * pad))
def _upload(self, filekey, upload_param, raw, aes_key, timeout=120, upload_url=''):
url = upload_url.strip() if upload_url else f'{CDN_BASE}/upload?encrypted_query_param={quote(upload_param)}&filekey={filekey}'
data = self._enc(raw, aes_key)
last_err = None
for attempt in range(1, 4):
try:
r = requests.post(url, data=data, headers={'Content-Type': 'application/octet-stream', 'User-Agent': UA}, timeout=timeout)
if 400 <= r.status_code < 500:
msg = r.headers.get('x-error-message') or r.text[:300]
raise RuntimeError(f'CDN upload client error {r.status_code}: {msg}')
if r.status_code != 200:
msg = r.headers.get('x-error-message') or f'status {r.status_code}'
raise RuntimeError(f'CDN upload server error: {msg}')
eq = r.headers.get('x-encrypted-param', '')
if not eq: raise RuntimeError('CDN upload response missing x-encrypted-param header')
return {'encrypt_query_param': eq,
'aes_key': base64.b64encode(aes_key.hex().encode()).decode(), 'encrypt_type': 1}
except Exception as e:
last_err = e
if 'client error' in str(e) or attempt >= 3: break
print(f'[WX] CDN upload retry {attempt}: {e}', file=sys.__stdout__)
raise last_err
def _send_media(self, to_user_id, file_path, media_type, item_type, item_key, context_token=''):
fp = Path(file_path)
raw = fp.read_bytes()
filekey = uuid.uuid4().hex
aes_key = os.urandom(16)
ciphertext_size = ((len(raw) // 16) + 1) * 16
thumb_raw = b''; thumb_w = thumb_h = 0; thumb_ciphertext_size = 0
if item_key == 'image_item':
from io import BytesIO
from PIL import Image
im = Image.open(fp); im.thumbnail((240, 240))
thumb_w, thumb_h = im.size
if im.mode not in ('RGB', 'L'):
im = im.convert('RGB')
bio = BytesIO(); im.save(bio, format='JPEG', quality=85)
thumb_raw = bio.getvalue()
thumb_ciphertext_size = ((len(thumb_raw) // 16) + 1) * 16
body = {
'filekey': filekey, 'media_type': media_type, 'to_user_id': to_user_id,
'rawsize': len(raw), 'rawfilemd5': hashlib.md5(raw).hexdigest(),
'filesize': ciphertext_size,
'no_need_thumb': item_key not in ('image_item', 'video_item'),
'aeskey': aes_key.hex(), 'base_info': {'channel_version': VER}}
if thumb_raw:
body.update({'thumb_rawsize': len(thumb_raw),
'thumb_rawfilemd5': hashlib.md5(thumb_raw).hexdigest(),
'thumb_filesize': thumb_ciphertext_size})
resp = self._post('ilink/bot/getuploadurl', body)
upload_param = resp.get('upload_param', '')
upload_url = resp.get('upload_full_url', '')
if not (upload_param or upload_url): raise RuntimeError(f'getuploadurl failed: {resp}')
media = self._upload(filekey, upload_param, raw, aes_key=aes_key, upload_url=upload_url)
item = {'media': media}
if item_key == 'file_item':
item.update({'file_name': fp.name, 'len': str(len(raw))})
elif item_key == 'image_item':
thumb_param = resp.get('thumb_upload_param', '')
thumb_url = resp.get('thumb_upload_full_url', '')
if thumb_param or thumb_url:
thumb_media = self._upload(filekey, thumb_param, thumb_raw, aes_key=aes_key, upload_url=thumb_url)
thumb_size = thumb_ciphertext_size
else:
# Some getuploadurl responses only return a single upload_full_url for IMAGE.
# Keep ImageItem structurally complete by reusing the original CDN media as thumb_media.
thumb_media = media
thumb_size = ciphertext_size
item.update({'mid_size': ciphertext_size, 'thumb_media': thumb_media,
'thumb_size': thumb_size,
'thumb_width': thumb_w, 'thumb_height': thumb_h})
elif item_key == 'video_item':
item.update({'video_size': ciphertext_size})
msg = {'from_user_id': '', 'to_user_id': to_user_id,
'client_id': f'pyclient-{uuid.uuid4().hex[:16]}',
'message_type': MSG_BOT, 'message_state': STATE_FINISH,
'item_list': [{'type': item_type, item_key: item}]}
if context_token: msg['context_token'] = context_token
return self._post('ilink/bot/sendmessage', {'msg': msg, 'base_info': {'channel_version': VER}})
def send_file(self, to_user_id, file_path, context_token=''):
return self._send_media(to_user_id, file_path, 3, ITEM_FILE, 'file_item', context_token)
def send_image(self, to_user_id, file_path, context_token=''):
return self._send_media(to_user_id, file_path, 1, ITEM_IMAGE, 'image_item', context_token)
def send_video(self, to_user_id, file_path, context_token=''):
return self._send_media(to_user_id, file_path, 2, ITEM_VIDEO, 'video_item', context_token)
@staticmethod
def extract_text(msg):
return '\n'.join(it['text_item'].get('text', '')
for it in msg.get('item_list', [])
if it.get('type') == ITEM_TEXT and it.get('text_item'))
@staticmethod
def is_user_msg(msg): return msg.get('message_type') == MSG_USER
def run_loop(self, on_message, poll_timeout=30):
print(f'[Bot] 监听中... (bot_id={self.bot_id})')
seen = set()
while True:
try:
for msg in self.get_updates(poll_timeout):
mid = msg.get('message_id', 0)
if not self.is_user_msg(msg) or mid in seen: continue
seen.add(mid)
if len(seen) > 5000: seen = set(list(seen)[-2000:])
try: on_message(self, msg)
except Exception as e: print(f'[Bot] 回调异常: {e}')
except KeyboardInterrupt: print('[Bot] 退出'); break
except AuthExpired: raise
except Exception as e: print(f'[Bot] 异常: {e}5s重试'); time.sleep(5)
# ── Unified media download (IMAGE/VIDEO/FILE/VOICE) ──
_MEDIA_KEYS = {'image_item': '.jpg', 'video_item': '.mp4', 'file_item': '', 'voice_item': '.silk'}
def _dl_media(items):
"""Download & decrypt all media items → list of local file paths."""
paths = []
for item in items:
for key, ext in _MEDIA_KEYS.items():
sub = item.get(key)
if not sub: continue
eq = (sub.get('media') or {}).get('encrypt_query_param')
if not eq: continue
ak = (sub.get('media') or {}).get('aes_key', '') or sub.get('aeskey', '')
if not ak: continue
try:
aes_key = (bytes.fromhex(base64.b64decode(ak).decode())
if sub.get('media', {}).get('aes_key') else bytes.fromhex(ak))
ct = requests.get(f'{CDN_BASE}/download?encrypted_query_param={quote(eq)}', headers={'User-Agent': UA}, timeout=60).content
pt = AES.new(aes_key, AES.MODE_ECB).decrypt(ct); pt = pt[:-pt[-1]]
fname = sub.get('file_name') or f'{uuid.uuid4().hex[:8]}{ext or ".bin"}'
p = os.path.join(_TEMP_DIR, fname); open(p, 'wb').write(pt)
paths.append(p); print(f'[WX] media saved: {fname}', file=sys.__stdout__)
except Exception as e:
print(f'[WX] media dl err ({key}): {e}', file=sys.__stdout__)
break # one media per item
return paths
agent = GeneraticAgent()
agent.verbose = False
_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'tool_use')]
_TAG_PATS.append(r'<file_content>.*?</file_content>')
def _strip_md(t):
"""Filter markdown for WeChat rich-text rendering.
WeChat natively renders: code fences, inline code, bold, italic,
H1-H4 headings, horizontal rules, tables. We only strip unsupported syntax."""
def _trunc_code(m):
full = m.group()
fence = re.match(r'`{3,}', full).group()
rest = full[len(fence):-len(fence)]
if '\n' not in rest: return full # single-line, keep as-is
lang_line, _, body = rest.partition('\n')
lines = body.split('\n')
if len(lines) > 10:
return f'{fence}{lang_line}\n' + '\n'.join(lines[:10]) + '\n...\n' + fence
return full # keep intact
t = re.sub(r'(`{3,})[\s\S]*?\1', _trunc_code, t)
# inline code: keep (WeChat renders it)
# bold/italic (*/**/***): keep (WeChat renders it)
t = re.sub(r'!\[.*?\]\(.*?\)', '', t) # images: remove
t = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', t) # links: text only
t = re.sub(r'^#{5,6}\s+', '', t, flags=re.M) # H5-H6: strip (H1-H4 kept)
t = re.sub(r'^\s*[-*+]\s+', '', t, flags=re.M) # unordered list: bullet
t = re.sub(r'^\s*\d+\.\s+', '', t, flags=re.M) # ordered list: strip num
t = re.sub(r'^\s*>\s?', '', t, flags=re.M) # blockquote: strip
# horizontal rules (---): keep (WeChat renders it)
return re.sub(r'\n{3,}', '\n\n', t).strip()
def _clean(t):
t = re.sub(r'^\s*LLM Running \(Turn \d+\) \.{3}\s*$', '', t, flags=re.M)
t = re.sub(r'^\s*🛠️\s*[A-Za-z_][A-Za-z0-9_]*\(.*$', '', t, flags=re.M)
for p in _TAG_PATS:
t = re.sub(p, '', t, flags=re.DOTALL)
t = re.sub(r'</?summary>', '', t)
return re.sub(r'\n{3,}', '\n\n', _strip_md(t)).strip()
def on_message(bot, msg):
text = bot.extract_text(msg).strip()
uid = msg.get('from_user_id', '')
ctx = msg.get('context_token', '')
media_paths = _dl_media(msg.get('item_list', []))
if not text and not media_paths: return
if media_paths:
text = (text + '\n' if text else '') + '\n'.join(f'[用户发送文件: {p}]' for p in media_paths)
print(f'[WX] 收到: {text[:80]}', file=sys.__stdout__)
# Commands
if text in ('/stop', '/abort'):
agent.abort()
_task_aborted[uid] = True
print(f'[WX] /stop set _task_aborted[{uid}]', file=sys.__stdout__)
return
if text.startswith('/llm'):
args = text.split()
if len(args) > 1:
try:
n = int(args[1]); agent.next_llm(n)
bot.send_text(uid, f'切换到 [{agent.llm_no}] {agent.get_llm_name()}', context_token=ctx)
except (ValueError, IndexError):
bot.send_text(uid, f'用法: /llm <0-{len(agent.list_llms())-1}>', context_token=ctx)
else:
lines = [f"{'' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()]
bot.send_text(uid, 'LLMs:\n' + '\n'.join(lines), context_token=ctx)
return
def _handle():
prompt = text if text.startswith('/') else f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}"
dq = agent.put_task(prompt, source="wechat")
_typing_stop = threading.Event()
def _keep_typing():
ticket = bot.get_typing_ticket(uid, ctx)
if not ticket: return
while not _typing_stop.is_set():
try: bot.send_typing(uid, ticket)
except: pass
_typing_stop.wait(2.0)
threading.Thread(target=_keep_typing, daemon=True).start()
result = ''; sent = 0; mi = 0; last_send = 0; item = {}
def _wx_send(text):
s = text.strip(); t0 = time.time()
try:
bot.send_text(uid, s, context_token=ctx)
print(f'[WX] send ok len={len(s)} dt={time.time()-t0:.1f}s', file=sys.__stdout__)
return True
except Exception as e:
print(f'[WX] send err len={len(s)} dt={time.time()-t0:.1f}s {type(e).__name__}: {e}', file=sys.__stdout__)
return False
def _send(show):
nonlocal mi, last_send
now = time.time()
if mi >= 9 or not show.strip(): return False
if mi and now - last_send < 6 * mi: return None
if _wx_send(show[:3000]): mi += 1; last_send = time.time(); return True
return False
try:
done = []; turn = 1
while True:
item = dq.get(timeout=300)
if 'done' in item: break
if item.get('turn', turn) > turn:
outputs = item.get('outputs', [])
lastdone = outputs[-2] if len(outputs) >= 2 else ''
turn = item['turn']; done.append(lastdone)
if len(done) > sent:
merged = _clean('\n\n'.join(done[sent:]))
print(f'[WX] turns={len(done)}/{len(done)+1} sent={sent} sending={len(done)-sent}', file=sys.__stdout__)
if _send(merged): sent = len(done)
except queue.Empty: result = '[超时]'
_typing_stop.set()
if 'done' in item: result, done = item['done'], item.get('outputs', [])
aborted = _task_aborted.pop(uid, False)
tag = '[已停止]' if aborted else '[任务已完成]'
rest = _clean('\n\n'.join(done[sent:] + ['\n\n' + tag]).strip())
if rest: _wx_send(rest[-3000:])
files = re.findall(r'\[FILE:([^\]]+)\]', result)
bad = {'filepath', '<filepath>', 'path', '<path>', 'file_path', '<file_path>', '...'}
files = [f for f in files if f.strip().lower() not in bad and (f if os.path.isabs(f) else os.path.join(_TEMP_DIR, f)) not in media_paths]
for fpath in set(files):
if not os.path.isabs(fpath): fpath = os.path.join(_TEMP_DIR, fpath)
try:
if not os.path.exists(fpath): raise FileNotFoundError(f"文件不存在: {fpath}")
ext = os.path.splitext(fpath)[1].lower()
sender = bot.send_video if ext in {'.mp4', '.mov', '.m4v', '.webm'} else \
bot.send_image if ext in {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'} else bot.send_file
sender(uid, fpath, context_token=ctx)
print(f'[WX] sent media: {fpath}', file=sys.__stdout__)
except Exception as e: print(f'[WX] send media err: {e}', file=sys.__stdout__)
threading.Thread(target=_handle, daemon=True).start()
if __name__ == '__main__':
_do_relogin = '--relogin' in sys.argv
try: _lock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); _lock.bind(('127.0.0.1', 19531))
except OSError: print('[WeChat] Another instance running, exiting.'); sys.exit(1)
_logf = open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp', 'wechatapp.log'), 'a', encoding='utf-8', buffering=1)
sys.stdout = sys.stderr = _logf
print(f'[NEW] Process starting {time.strftime("%m-%d %H:%M")}')
bot = WxBotClient()
if _do_relogin or not bot.token:
# QR 登录在无 TTY 的容器里也可用:把二维码打到真实 stdoutdocker logs
# 可见),而不是日志文件——之前在重定向后才判 isatty(),文件句柄恒 false
# 导致容器内必然退出,无法首次登录。PNG 仍存 ~/.wxbot/wx_qr.png 作兜底。
sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display (real stdout / container log)
try:
bot.login_qr()
finally:
sys.stdout = sys.stderr = _logf
threading.Thread(target=agent.run, daemon=True).start()
print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__)
try:
bot.run_loop(on_message)
except AuthExpired:
print('[Bot] token expired, exit.', file=sys.__stdout__)
sys.exit(2)
+351
View File
@@ -0,0 +1,351 @@
import asyncio, os, select, sys, threading, time, traceback
from collections import deque
from datetime import datetime
from typing import Any, Callable, Dict, Optional, TypedDict
class TurnContext(TypedDict, total=False):
"""Hook callback receives agent locals() — these are the keys we rely on."""
exit_reason: Optional[str]
response: Any
summary: Optional[str]
tool_calls: Optional[list]
turn: int
TurnHookFn = Callable[[TurnContext], None]
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from agentmain import GeneraticAgent
from chatapp_common import (AgentChatMixin, FILE_HINT, build_done_text, clean_reply,
ensure_single_instance, extract_files, public_access,
redirect_log, require_runtime, split_text, strip_files)
from llmcore import mykeys
try:
from wecom_aibot_sdk import WSClient, generate_req_id
except Exception:
print("Please install wecom_aibot_sdk: pip install wecom_aibot_sdk")
sys.exit(1)
# ── Config ──────────────────────────────────────────────────────────
BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip()
SECRET = str(mykeys.get("wecom_secret", "") or "").strip()
WELCOME = str(mykeys.get("wecom_welcome_message", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("wecom_allowed_users", []) if str(x).strip()}
PORT = 19531 # single-instance lock port
TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
MEDIA_DIR = os.path.join(TEMP_DIR, "media")
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}
# ── Helpers ─────────────────────────────────────────────────────────
def _ts():
return datetime.now().strftime("%H:%M:%S")
def _tprint(*a, **kw):
kw.setdefault("file", sys.__stdout__)
print(*a, **kw)
if hasattr(sys.__stdout__, "flush"):
sys.__stdout__.flush()
def _fmt_tool(tc):
name = tc.get("tool_name", "?")
args = {k: v for k, v in (tc.get("args") or {}).items() if not k.startswith("_")}
return f"{name}({str(args)[:120]})"
# ── WeComApp ────────────────────────────────────────────────────────
class WeComApp(AgentChatMixin):
label, source, split_limit = "WeCom", "wecom", 1200 # split_limit: wecom single-msg char cap
def __init__(self, agent):
self.agent = agent
if not hasattr(agent, '_turn_end_hooks'):
agent._turn_end_hooks = {}
super().__init__(agent, {})
self._allowed = ALLOWED
self.client = None
self.chat_frames = {} # chat_id → latest frame (for reply)
self._seen = deque(maxlen=1000)
self._stats = {"received": 0, "completed": 0}
# ── hook management ──────────────────────────────────────────────
def _register_hook(self, key: str, fn: TurnHookFn) -> None:
"""Register a turn-end callback on the agent."""
self.agent._turn_end_hooks[key] = fn
def _unregister_hook(self, key: str) -> None:
"""Remove a turn-end callback."""
self.agent._turn_end_hooks.pop(key, None)
# ── frame accept: dedup → auth → register ───────────────────────
def _accept(self, frame):
"""Parse incoming frame. Returns (body, sender_id, chat_id) or None."""
body = frame.body if hasattr(frame, "body") else frame.get("body", frame) if isinstance(frame, dict) else {}
msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}_{id(frame)}"
if msg_id in self._seen:
return None
self._seen.append(msg_id)
sender_id = str((body.get("from") or {}).get("userid", "") or "unknown")
chat_id = str(body.get("chatid", "") or sender_id)
if not public_access(ALLOWED) and sender_id not in ALLOWED:
print(f"[WeCom] unauthorized: {sender_id}")
return None
self.chat_frames[chat_id] = frame
self._stats["received"] += 1
return body, sender_id, chat_id
async def _save_media(self, url, aes_key, default_name):
"""Download encrypted media → save to MEDIA_DIR → return local path."""
os.makedirs(MEDIA_DIR, exist_ok=True)
result = await self.client.download_file(url, aes_key or None)
buf = result["buffer"]
fname = result.get("filename") or default_name
path = os.path.join(MEDIA_DIR, fname)
with open(path, "wb") as f:
f.write(buf)
_tprint(f"[{_ts()}] 💾 Saved: {path} ({len(buf)} bytes)")
return path
# ── send ────────────────────────────────────────────────────────
async def send_text(self, chat_id, content, **_):
if not self.client or chat_id not in self.chat_frames:
return
frame = self.chat_frames[chat_id]
for part in split_text(content, self.split_limit):
await self.client.reply_stream(frame, generate_req_id("stream"), part, finish=True)
async def send_media(self, chat_id, file_path):
if not self.client or not os.path.isfile(file_path):
return
ext = os.path.splitext(file_path)[1].lower()
media_type = "image" if ext in IMAGE_EXTS else "file"
with open(file_path, "rb") as f:
data = f.read()
try:
result = await self.client.upload_media(data, type=media_type, filename=os.path.basename(file_path))
frame = self.chat_frames.get(chat_id)
if frame:
await self.client.reply_media(frame, media_type, result["media_id"])
else:
await self.client.send_media_message(chat_id, media_type, result["media_id"])
_tprint(f"[{_ts()}] 📤 Sent {media_type}: {os.path.basename(file_path)}")
except Exception as e:
print(f"[WeCom] send_media error: {e}")
await self.send_text(chat_id, f"📎 {os.path.basename(file_path)}(发送失败: {e}")
async def send_done(self, chat_id, raw_text):
"""Send final result: text + extracted file attachments."""
files = extract_files(raw_text)
if not files:
return await self.send_text(chat_id, build_done_text(raw_text))
clean = clean_reply(strip_files(raw_text))
if clean and clean != "...":
await self.send_text(chat_id, clean)
for fp in files:
if not os.path.isabs(fp) and not os.path.isfile(fp):
resolved = os.path.join(TEMP_DIR, fp)
if os.path.isfile(resolved):
fp = resolved
await self.send_media(chat_id, fp)
# ── agent execution (single-channel via turn hook) ──────────────
async def run_agent(self, chat_id, text, **_):
state = {"running": True}
self.user_tasks[chat_id] = state
done_event = threading.Event()
result = {}
loop = asyncio.get_running_loop()
hook_key = f"wecom_{chat_id}" # namespace: wecom_ + chat_id, matches _turn_end_hooks convention
def _on_turn(ctx):
"""Turn-end callback injected into agent. ctx = locals() from ga.py."""
try:
if ctx.get("exit_reason"):
resp = ctx.get("response")
result["raw"] = resp.content if hasattr(resp, "content") else str(resp)
result["summary"] = ctx.get("summary")
done_event.set()
return
summary = ctx.get("summary")
if not summary:
return
turn = ctx.get("turn", "?")
tools = ctx.get("tool_calls") or []
parts = [f"⏳ Turn {turn}: {summary}"]
if tools:
parts.append(f"🛠 {', '.join(_fmt_tool(tc) for tc in tools[:3])}")
_tprint(f"[{_ts()}] {parts[0]}")
asyncio.run_coroutine_threadsafe(self.send_text(chat_id, "\n".join(parts)), loop)
except Exception as e:
print(f"[WeCom hook] {e}")
traceback.print_exc()
try:
await self.send_text(chat_id, "🤔 思考中...")
self._register_hook(hook_key, _on_turn)
self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
# Wait for: hook signals done / user stops / agent crashes
t0 = time.time()
while state["running"] and not done_event.is_set():
await asyncio.sleep(1)
elapsed = time.time() - t0
if elapsed > 10 and not self.agent.is_running:
await asyncio.sleep(3) # grace period for hook delivery
if not done_event.is_set():
break
if result.get("raw") is not None:
self._stats["completed"] += 1
await self.send_done(chat_id, result["raw"])
label = result.get("summary") or f'{len(result["raw"])}'
_tprint(f"[{_ts()}] ✅ Done ({chat_id}) — {label}")
elif not state["running"]:
_tprint(f"[{_ts()}] ⏹️ 停止 ({chat_id})")
await self.send_text(chat_id, "⏹️ 已停止")
else:
_tprint(f"[{_ts()}] ⚠️ 异常退出 ({chat_id})")
await self.send_text(chat_id, "⚠️ Agent 异常退出,请重试")
except Exception as e:
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}")
finally:
self._unregister_hook(hook_key)
self.user_tasks.pop(chat_id, None)
# ── message handlers ────────────────────────────────────────────
async def on_text(self, frame):
parsed = self._accept(frame)
if not parsed:
return
body, sender_id, chat_id = parsed
content = str((body.get("text", {}) or {}).get("content", "") or "").strip()
if not content:
return
_tprint(f"[{_ts()}] 📩 {sender_id}: {content}")
if content.startswith("/"):
_tprint(f"[{_ts()}] 🔧 命令 {content} from {sender_id}")
return await self.handle_command(chat_id, content)
asyncio.create_task(self.run_agent(chat_id, content))
async def _on_media(self, frame, key, icon):
"""Common handler for image/file messages."""
parsed = self._accept(frame)
if not parsed:
return
body, sender_id, chat_id = parsed
info = body.get(key) or {}
url = info.get("url", "")
if not url:
return
fname = info.get("file_name") or info.get("filename") or ""
msgid = body.get("msgid", "x")[:16]
default = f"img_{msgid}.jpg" if key == "image" else (fname or f"file_{msgid}")
try:
_tprint(f"[{_ts()}] {icon} {key.title()} from {sender_id}" + (f": {fname}" if fname else ""))
path = await self._save_media(url, info.get("aeskey", ""), default)
label = "一张图片" if key == "image" else f"文件 {os.path.basename(path)}"
asyncio.create_task(self.run_agent(chat_id, f"[用户发送了{label},已保存到: {path}]"))
except Exception as e:
print(f"[WeCom] on_{key} error: {e}")
await self.send_text(chat_id, f"{key}处理失败: {e}")
async def on_image(self, frame):
await self._on_media(frame, "image", "🖼️")
async def on_file(self, frame):
await self._on_media(frame, "file", "📎")
# ── lifecycle ───────────────────────────────────────────────────
async def on_enter_chat(self, frame):
if WELCOME and self.client:
try:
await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}})
except Exception as e:
print(f"[WeCom] welcome error: {e}")
async def on_connected(self, *_): _tprint("[WeCom] connected")
async def on_authenticated(self, *_): _tprint("[WeCom] authenticated, 等待消息中...\n")
async def on_disconnected(self, *_): _tprint("[WeCom] disconnected")
async def on_error(self, frame): _tprint(f"[WeCom] error: {frame}")
# ── Terminal CLI (runs in background thread) ─────────────────────
def _terminal_loop(self):
"""Blocking CLI loop — run in a daemon thread."""
while True:
try:
if not select.select([sys.stdin], [], [], 1.0)[0]:
continue
cmd = sys.stdin.readline().strip().lower()
except Exception:
break
if not cmd:
continue
if cmd == "help":
_tprint(" status — 查看状态")
_tprint(" stop [user] — 停止任务(多任务时需指定 user)")
_tprint(" exit — 退出进程")
elif cmd == "status":
_tprint(f"[{_ts()}] 📊 收到 {self._stats['received']} 条 | 完成 {self._stats['completed']} 条 | 活跃 {len(self.user_tasks)}")
for uid, st in self.user_tasks.items():
_tprint(f"{uid}: running={st.get('running')}")
_tprint(f" Agent running: {self.agent.is_running} | 允许: {self._allowed or '全部'}")
elif cmd.startswith("stop"):
parts = cmd.split(None, 1)
tasks = self.user_tasks
if not tasks:
_tprint(" 没有活跃任务")
elif len(parts) > 1:
uid = parts[1]
if uid in tasks:
tasks[uid]["running"] = False
_tprint(f" ⏹️ 已停止 {uid}")
else:
_tprint(f" 未找到: {uid}")
elif len(tasks) == 1:
uid = next(iter(tasks))
tasks[uid]["running"] = False
_tprint(f" ⏹️ 已停止 {uid}")
else:
_tprint(" 多个任务,请指定: stop <user_id>")
for uid in tasks:
_tprint(f"{uid}")
elif cmd == "exit":
_tprint(f"[{_ts()}] 👋 退出...")
os._exit(0)
else:
_tprint(" 可用命令: help | status | stop | exit")
async def start(self, client=None):
self.client = client or WSClient(BOT_ID, SECRET, reconnect_interval=1000,
max_reconnect_attempts=-1, heartbeat_interval=30000)
for ev, fn in {
"connected": self.on_connected, "authenticated": self.on_authenticated,
"disconnected": self.on_disconnected, "error": self.on_error,
"message.text": self.on_text, "message.image": self.on_image,
"message.file": self.on_file, "event.enter_chat": self.on_enter_chat,
}.items():
self.client.on(ev, fn)
_tprint("[WeCom] starting ...")
await self.client.connect()
while True:
await asyncio.sleep(1)
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
agent = GeneraticAgent(); agent.verbose = False
_LOCK = ensure_single_instance(PORT, "WeCom")
require_runtime(agent, "WeCom", wecom_bot_id=BOT_ID, wecom_secret=SECRET)
redirect_log(__file__, "wecomapp.log", "WeCom", ALLOWED)
_tprint("\n═══════════════════════════════════════════")
_tprint(" 企业微信 Agent (长连接模式)")
_tprint(f" 端口锁: {PORT} | 允许用户: {ALLOWED or '全部'}")
_tprint("═══════════════════════════════════════════")
_tprint(" 终端命令: help | status | stop | exit")
app = WeComApp(agent)
threading.Thread(target=agent.run, daemon=True).start()
threading.Thread(target=app._terminal_loop, daemon=True).start()
asyncio.run(app.start())
+478
View File
@@ -0,0 +1,478 @@
"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
设计要点(详见对话设计稿):
* **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
前端在
`<repo>/temp/projects/<name>` 建一个指向用户真实绝对路径的目录联接(junction),
并可按需写激活锚 `<repo>/temp/.active_project.<pid>`。project_mode 插件
照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
(与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
* **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `<repo>/temp`
绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `<repo>/temp`(frontends/ 的上一级
即 repo 根),两边独立计算但结果一致,互不 import。
* **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用
`os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。
* **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。
同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的
普通项目目录相撞。
* **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!);
删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction
且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import subprocess
import sys
import time
from typing import Optional
# --------------------------------------------------------------------------- #
# 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致)
# --------------------------------------------------------------------------- #
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _temp_root() -> str:
return os.path.join(_REPO_ROOT, "temp")
def _projects_root() -> str:
return os.path.join(_temp_root(), "projects")
def _anchor_path() -> str:
"""激活锚,pid 键控,与插件 `_ANCHOR` 同。"""
return os.path.join(_temp_root(), f".active_project.{os.getpid()}")
def _registry_path() -> str:
return os.path.join(_temp_root(), "workspaces.json")
_REGISTRY_VERSION = 1
# --------------------------------------------------------------------------- #
# 命名
# --------------------------------------------------------------------------- #
def _norm_abspath(p: str) -> str:
"""规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 ->
同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。"""
return os.path.normcase(os.path.abspath(p))
def _ws_name(abs_path: str) -> str:
base = os.path.basename(abs_path.rstrip("/\\")) or "ws"
digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _link_path(name: str) -> str:
return os.path.join(_projects_root(), name)
# --------------------------------------------------------------------------- #
# junction / symlink 跨平台封装(reparse 安全)
# --------------------------------------------------------------------------- #
def make_dir_link(target_abs: str, link_path: str) -> bool:
"""建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。
成功返回 True;失败打印到 stderr 并返回 False。"""
target_abs = os.path.abspath(target_abs)
parent = os.path.dirname(link_path)
try:
os.makedirs(parent, exist_ok=True)
except OSError as e:
sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n")
return False
if os.name == "nt":
# mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号,
# 兼容含空格/中文的路径。
try:
r = subprocess.run(
["cmd", "/c", "mklink", "/J", link_path, target_abs],
capture_output=True, text=True,
)
except OSError as e:
sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n")
return False
if r.returncode != 0 or not os.path.exists(link_path):
sys.stderr.write(f"[workspace] mklink /J failed: "
f"{(r.stderr or r.stdout or '').strip()}\n")
return False
return True
# POSIX
try:
os.symlink(target_abs, link_path, target_is_directory=True)
return True
except OSError as e:
sys.stderr.write(f"[workspace] symlink failed: {e}\n")
return False
def is_dir_link(path: str) -> bool:
"""是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction
返回 False。改看 reparse point 属性 + reparse tag。"""
try:
if os.path.islink(path): # POSIX symlink、Windows 符号链接
return True
except OSError:
return False
if os.name != "nt":
return False
try:
st = os.lstat(path)
except OSError:
return False
attrs = getattr(st, "st_file_attributes", 0)
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
if not (attrs & reparse):
return False
# 进一步认 tag:挂载点(junction)或符号链接
tag = getattr(st, "st_reparse_tag", 0)
mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C)
if tag:
return tag in (mount, syml)
return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链)
def link_target(path: str) -> Optional[str]:
"""读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。"""
try:
t = os.readlink(path)
except OSError:
return None
for pre in ("\\??\\", "\\\\?\\"):
if t.startswith(pre):
t = t[len(pre):]
break
return t
def remove_dir_link(path: str) -> bool:
"""只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir,
POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**"""
try:
if os.name == "nt":
os.rmdir(path)
else:
os.unlink(path)
return True
except OSError as e:
sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n")
return False
# --------------------------------------------------------------------------- #
# 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写)
# --------------------------------------------------------------------------- #
def registry_load() -> dict:
try:
with open(_registry_path(), encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
items = data.get("items")
if isinstance(items, dict):
return items
except (OSError, ValueError):
pass
return {}
def _registry_save(items: dict) -> None:
path = _registry_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"version": _REGISTRY_VERSION, "items": items},
fh, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
except OSError as e:
sys.stderr.write(f"[workspace] registry save failed: {e}\n")
# --------------------------------------------------------------------------- #
# 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先
# 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径,
# ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册
# 表原子写、不加锁。
# --------------------------------------------------------------------------- #
def _session_map_path() -> str:
return os.path.join(_temp_root(), "session_workspaces.json")
def _session_map_load() -> dict:
try:
with open(_session_map_path(), encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
items = data.get("items")
if isinstance(items, dict):
return items
except (OSError, ValueError):
pass
return {}
def _session_map_save(items: dict) -> None:
path = _session_map_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"version": _REGISTRY_VERSION, "items": items},
fh, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
except OSError as e:
sys.stderr.write(f"[workspace] session map save failed: {e}\n")
def session_ws_set(log_path: str, target: str) -> None:
"""记录会话绑定的 workspace 路径;target="" 表示该会话已 off。"""
key = os.path.basename(log_path or "")
if not key:
return
items = _session_map_load()
items[key] = target or ""
_session_map_save(items)
def session_ws_get(log_path: str):
"""路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。"""
key = os.path.basename(log_path or "")
return _session_map_load().get(key) if key else None
def session_map_prune() -> None:
"""删掉日志文件已不存在的孤儿条目(启动时调一次)。"""
items = _session_map_load()
logdir = os.path.join(_temp_root(), "model_responses")
alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))}
if len(alive) != len(items):
_session_map_save(alive)
def registry_upsert(name: str, abs_path: str) -> None:
items = registry_load()
items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())}
_registry_save(items)
def registry_remove(name: str) -> None:
items = registry_load()
if items.pop(name, None) is not None:
_registry_save(items)
def _mem_lines(link: str) -> int:
"""project_memory.md 行数(经 junction 读真实文件);读不到返回 0。"""
mp = os.path.join(link, "project_memory.md")
try:
with open(mp, encoding="utf-8", errors="replace") as fh:
return sum(1 for _ in fh)
except OSError:
return 0
def registry_list() -> list[dict]:
"""供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。"""
out = []
for name, ent in registry_load().items():
path = (ent or {}).get("path") or ""
out.append({
"name": name,
"path": path,
"last_used": int((ent or {}).get("last_used") or 0),
"mem_lines": _mem_lines(_link_path(name)) if path else 0,
"dangling": not (path and os.path.isdir(path)),
})
out.sort(key=lambda x: x["last_used"], reverse=True)
return out
# --------------------------------------------------------------------------- #
# 校验
# --------------------------------------------------------------------------- #
def validate_path(abs_path: str) -> tuple[bool, str]:
if not abs_path or not abs_path.strip():
return False, "路径为空"
p = abs_path.strip().strip('"').strip("'")
if not os.path.isabs(p):
return False, "需要绝对路径"
if os.name == "nt" and p.startswith("\\\\"):
return False, "不支持网络路径(UNC):junction 无法指向网络位置"
if not os.path.exists(p):
return False, f"路径不存在: {p}"
if not os.path.isdir(p):
return False, "不是目录"
if _norm_abspath(p).startswith(_norm_abspath(_temp_root())):
return False, "该路径已在 temp 内,无需 workspace"
return True, ""
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def prepare(abs_path: str) -> dict:
"""准备 workspace,但不写进程级激活锚。返回:
{ok, name, link, target, mem_text, warning, error}。
流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。
TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。"""
p = abs_path.strip().strip('"').strip("'") if abs_path else ""
ok, msg = validate_path(p)
if not ok:
return {"ok": False, "error": msg}
target = os.path.abspath(p)
name = _ws_name(target)
link = _link_path(name)
warning = ""
# 幂等建链
if os.path.lexists(link):
if is_dir_link(link):
cur = link_target(link)
if cur and _norm_abspath(cur) == _norm_abspath(target):
pass # 已指向同一目标 -> 复用
else:
remove_dir_link(link)
if not make_dir_link(target, link):
return {"ok": False, "error": "重建 junction 失败(见 stderr)"}
else:
# 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。
return {"ok": False,
"error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"}
else:
if not make_dir_link(target, link):
return {"ok": False, "error": "创建 junction 失败(见 stderr)"}
# 确保 project_memory.md 存在(经 junction 落到真实仓库根)
mem_path = os.path.join(link, "project_memory.md")
if not os.path.isfile(mem_path):
try:
open(mem_path, "a", encoding="utf-8").close()
except OSError as e:
warning = f"无法创建 project_memory.md: {e}"
mem_text = ""
try:
with open(mem_path, encoding="utf-8", errors="replace") as fh:
mem_text = fh.read()
except OSError:
pass
registry_upsert(name, target)
return {"ok": True, "name": name, "link": link, "target": target,
"mem_text": mem_text, "warning": warning, "error": ""}
def activate(abs_path: str) -> dict:
"""设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。"""
r = prepare(abs_path)
if not r.get("ok"):
return r
try:
with open(_anchor_path(), "w", encoding="utf-8") as fh:
fh.write(r["name"])
except OSError as e:
r = dict(r)
r.update({"ok": False, "error": f"写激活锚失败: {e}"})
return r
def deactivate() -> bool:
"""仅删激活锚;junction 与文件保留。返回是否原本处于激活态。"""
anchor = _anchor_path()
if os.path.isfile(anchor):
try:
os.remove(anchor)
return True
except OSError as e:
sys.stderr.write(f"[workspace] deactivate failed: {e}\n")
return False
def current() -> Optional[dict]:
"""当前激活的 workspace:{name, path};未激活返回 None。"""
anchor = _anchor_path()
try:
name = open(anchor, encoding="utf-8").read().strip()
except OSError:
return None
if not name:
return None
ent = registry_load().get(name) or {}
return {"name": name, "path": ent.get("path") or ""}
def is_dangling(name: str) -> bool:
"""junction 指向的真实目标是否已失效(被删/盘断开)。"""
link = _link_path(name)
if not is_dir_link(link):
return True
t = link_target(link)
return not (t and os.path.isdir(t))
def remove(name: str) -> None:
"""显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。"""
link = _link_path(name)
if is_dir_link(link):
remove_dir_link(link)
registry_remove(name)
cur = current()
if cur and cur["name"] == name:
deactivate()
# project_mode 插件注入的标记:`[PROJECT MODE: <name>]`(见 _build_injection)。
# 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时
# 在哪个 workspace。
_PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]")
def workspace_from_log(log_path: str) -> Optional[dict]:
"""扫一份 model_responses 日志,返回它最后激活的 workspace {name, path};
仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回——
普通 SOP 项目(无 hash、不在注册表)一律忽略。"""
try:
with open(log_path, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return None
names = _PM_RE.findall(content)
if not names:
return None
name = names[-1].strip() # 最后一次激活胜出
ent = registry_load().get(name)
if not ent or not ent.get("path"):
return None
return {"name": name, "path": ent["path"]}
def cleanup() -> None:
"""v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。
安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。"""
proot = _projects_root()
if not os.path.isdir(proot):
return
registered = set(registry_load().keys())
try:
entries = os.listdir(proot)
except OSError:
return
for nm in entries:
link = os.path.join(proot, nm)
if not is_dir_link(link):
continue # 真实目录 -> 不碰
t = link_target(link)
dangling = not (t and os.path.isdir(t))
if dangling or nm not in registered:
remove_dir_link(link)
File diff suppressed because it is too large Load Diff