chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""`/btw` 命令:side question — 不打断主 Agent 的临时 subagent 问答。
|
||||
|
||||
- 持锁 deepcopy backend.history → 后台线程 backend.raw_ask 单次拉答
|
||||
- 主 agent backend.history 零写入;不入 task_queue
|
||||
- 答案 → display_queue 'done'(install 路径)或同步 return(frontend 路径)
|
||||
|
||||
复用 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
|
||||
|
After Width: | Height: | Size: 493 KiB |
@@ -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)
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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_info(agent下轮可见)
|
||||
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的输出,把**值得用户关注的内容**报告给用户或转化成"可点击执行"的待批TODO(approval)。
|
||||
先读L2记忆中User相关,推荐的动作和措辞要符合用户画像。
|
||||
要求:
|
||||
1. 不要只凭采集摘要;重要事实要核实,需要判断时先派subagent补做必要调查,再下结论。
|
||||
2. 没有值得用户点击执行的动作就直接结束,不要打扰;尤其不要对执行回执/完成确认/纯闲聊报"无需关注"。
|
||||
3. 判断标准:私聊默认重要,群聊除非@用户否则忽略。
|
||||
4. 只有真正需要用户的内容才报告或形成TODO。不要推"去看看/研究一下"这种半成品。TODO必须是最后一步可直接执行的动作(发某段微信回复、回复某封邮件草稿、处理某PR、整理某文件等)。
|
||||
5. 如果形成用户TODO,POST /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};requests,GET /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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,7 @@
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "genericagent-web2",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
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"]
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) }));
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 871 B |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 568 B |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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!
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 567 B |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 580 B |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 581 B |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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 的容器里也可用:把二维码打到真实 stdout(docker 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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||