wechatbridge-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wechatbridge/__init__.py +2 -0
- wechatbridge/__main__.py +4 -0
- wechatbridge/agy.py +630 -0
- wechatbridge/config.py +266 -0
- wechatbridge/grok.py +682 -0
- wechatbridge/ilink.py +849 -0
- wechatbridge/main.py +755 -0
- wechatbridge/runner_common.py +1003 -0
- wechatbridge/update_check.py +175 -0
- wechatbridge_cli-1.3.0.dist-info/METADATA +29 -0
- wechatbridge_cli-1.3.0.dist-info/RECORD +15 -0
- wechatbridge_cli-1.3.0.dist-info/WHEEL +5 -0
- wechatbridge_cli-1.3.0.dist-info/entry_points.txt +2 -0
- wechatbridge_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- wechatbridge_cli-1.3.0.dist-info/top_level.txt +1 -0
wechatbridge/__init__.py
ADDED
wechatbridge/__main__.py
ADDED
wechatbridge/agy.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agy CLI runner with per-user session isolation, output cleanup, and timeout protection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .config import config
|
|
16
|
+
from .runner_common import (
|
|
17
|
+
sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized,
|
|
18
|
+
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
19
|
+
sanitize_env, terminate_process, update_active_prefs,
|
|
20
|
+
format_error, format_cli_error, EMPTY_REPLY,
|
|
21
|
+
ANSI_RE, HTML_TAG_RE, validate_add_dir,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("agy_runner")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_artifacts(text: str) -> list[tuple[str, str]]:
|
|
28
|
+
"""Extract (name, absolute_path) tuples from markdown file:/// links.
|
|
29
|
+
|
|
30
|
+
Uses regex ``\\[([^\\]]+)\\](file:///([^)]+))`` to find agy-generated
|
|
31
|
+
artifact references in stdout. Returns deduplicated, order-preserved list.
|
|
32
|
+
"""
|
|
33
|
+
if not text:
|
|
34
|
+
return []
|
|
35
|
+
seen = set()
|
|
36
|
+
result = []
|
|
37
|
+
for match in re.finditer(r"\[([^\]]+)\]\(file:///([^)]+)\)", text):
|
|
38
|
+
name = match.group(1).split("#")[0]
|
|
39
|
+
abs_path = "/" + match.group(2).split("#")[0]
|
|
40
|
+
key = (name, abs_path)
|
|
41
|
+
if key not in seen:
|
|
42
|
+
seen.add(key)
|
|
43
|
+
result.append(key)
|
|
44
|
+
if result:
|
|
45
|
+
logger.debug("Extracted %d artifacts: %s", len(result), [n for n, _ in result[:3]])
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ensure_user_gemini(user_id: str) -> str:
|
|
50
|
+
"""Ensure per-user .gemini directory with auth token and default persona.
|
|
51
|
+
|
|
52
|
+
Creates session/.gemini/antigravity-cli/ for agy auth and conversations.
|
|
53
|
+
Copies global auth token and default GEMINI.md (persona) on first use.
|
|
54
|
+
Returns session_dir path (for use as HOME when running agy).
|
|
55
|
+
"""
|
|
56
|
+
session_dir = ensure_session_dir(user_id)
|
|
57
|
+
gemini_dir = os.path.join(session_dir, ".gemini")
|
|
58
|
+
antigravity_dir = os.path.join(gemini_dir, "antigravity-cli")
|
|
59
|
+
os.makedirs(antigravity_dir, exist_ok=True)
|
|
60
|
+
try:
|
|
61
|
+
os.chmod(gemini_dir, 0o700)
|
|
62
|
+
os.chmod(antigravity_dir, 0o700)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Copy global auth token if not yet present
|
|
67
|
+
# agy standard auth token path, managed by agy CLI
|
|
68
|
+
token_src = os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token")
|
|
69
|
+
token_dst = os.path.join(antigravity_dir, "antigravity-oauth-token")
|
|
70
|
+
if not os.path.exists(token_dst) and os.path.exists(token_src):
|
|
71
|
+
try:
|
|
72
|
+
shutil.copy(token_src, token_dst)
|
|
73
|
+
os.chmod(token_dst, 0o600)
|
|
74
|
+
except OSError as e:
|
|
75
|
+
logger.warning("Failed to copy auth token for %s: %s", user_id, e)
|
|
76
|
+
|
|
77
|
+
# Copy global GEMINI.md persona as default if not yet present
|
|
78
|
+
# agy standard persona file path, managed by agy CLI
|
|
79
|
+
agents_src = os.path.expanduser("~/.gemini/GEMINI.md")
|
|
80
|
+
agents_dst = os.path.join(gemini_dir, "GEMINI.md")
|
|
81
|
+
if not os.path.exists(agents_dst) and os.path.exists(agents_src):
|
|
82
|
+
try:
|
|
83
|
+
shutil.copy(agents_src, agents_dst)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
logger.warning("Failed to copy default GEMINI.md for %s: %s", user_id, e)
|
|
86
|
+
|
|
87
|
+
return session_dir
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# agy CLI execution
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str, list]:
|
|
95
|
+
"""Execute agy CLI for a given user message.
|
|
96
|
+
|
|
97
|
+
- Creates per-user session directory under config.session_base_dir
|
|
98
|
+
- Applies per-user preferences (model, effort, mode, add_dirs) as CLI flags
|
|
99
|
+
- Runs ``agy [flags] -p <prompt>`` for first message,
|
|
100
|
+
``agy [flags] -c -p <prompt>`` for subsequent messages
|
|
101
|
+
- Extracts artifacts (file:/// links) from stdout and scratch diff
|
|
102
|
+
- Cleans ANSI / HTML tags from display output
|
|
103
|
+
- Kills process group on timeout and returns a friendly message
|
|
104
|
+
- Never adds system prompts or personality instructions
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
tuple[str, list]: (cleaned_display_text, list_of_(name, abs_path)_artifacts)
|
|
108
|
+
"""
|
|
109
|
+
if timeout is None:
|
|
110
|
+
timeout = config.agy_timeout
|
|
111
|
+
|
|
112
|
+
t0 = time.time()
|
|
113
|
+
session_dir = ensure_user_gemini(user_id)
|
|
114
|
+
|
|
115
|
+
# Audit logging
|
|
116
|
+
logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
|
|
117
|
+
if is_dangerous(prompt):
|
|
118
|
+
logger.warning(
|
|
119
|
+
"[AUDIT] dangerous keyword in prompt from user=%s", user_id
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
first = is_first_message(session_dir)
|
|
123
|
+
|
|
124
|
+
# Build command: agy [--model X] [--effort Y] [--mode Z] [--add-dir W ...] [-c] -p <prompt>
|
|
125
|
+
# --dangerously-skip-permissions 保留:可信小圈子用户需 agy 能自动调工具,风险由服务层(root)+输入来源(可信用户)承担
|
|
126
|
+
cmd = [config.agy_binary_path, "--dangerously-skip-permissions"]
|
|
127
|
+
prefs = load_prefs(user_id)
|
|
128
|
+
model = prefs.get("model")
|
|
129
|
+
effort = prefs.get("effort")
|
|
130
|
+
if model:
|
|
131
|
+
base_model, embedded_effort = parse_model_effort(model)
|
|
132
|
+
if embedded_effort and effort:
|
|
133
|
+
# model has effort suffix AND user wants a different effort
|
|
134
|
+
# -> use base model name + --effort from prefs (no conflict)
|
|
135
|
+
cmd += ["--model", base_model, "--effort", effort]
|
|
136
|
+
elif embedded_effort:
|
|
137
|
+
# model has effort suffix, no explicit effort -> model carries it
|
|
138
|
+
cmd += ["--model", model]
|
|
139
|
+
else:
|
|
140
|
+
# plain model name -> pass effort if set
|
|
141
|
+
cmd += ["--model", model]
|
|
142
|
+
if effort:
|
|
143
|
+
cmd += ["--effort", effort]
|
|
144
|
+
elif effort:
|
|
145
|
+
cmd += ["--effort", effort]
|
|
146
|
+
if prefs.get("mode"):
|
|
147
|
+
cmd += ["--mode", prefs["mode"]]
|
|
148
|
+
for d in prefs.get("add_dirs", []):
|
|
149
|
+
if not d:
|
|
150
|
+
continue
|
|
151
|
+
ok, resolved = validate_add_dir(d, user_id)
|
|
152
|
+
if ok:
|
|
153
|
+
cmd += ["--add-dir", resolved]
|
|
154
|
+
else:
|
|
155
|
+
logger.warning("Skipping disallowed add_dir for %s: %s (%s)", user_id, d, resolved)
|
|
156
|
+
|
|
157
|
+
if first:
|
|
158
|
+
logger.info(
|
|
159
|
+
"First message for user %s, running: agy -p ...", user_id
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
cmd += ["-c"]
|
|
163
|
+
logger.info(
|
|
164
|
+
"Continuing conversation for user %s, running: agy -c -p ...",
|
|
165
|
+
user_id,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
cmd += ["-p", prompt]
|
|
169
|
+
|
|
170
|
+
process = None
|
|
171
|
+
try:
|
|
172
|
+
env = sanitize_env(session_dir)
|
|
173
|
+
env["PAGER"] = "cat"
|
|
174
|
+
env["CI"] = "true"
|
|
175
|
+
env["NONINTERACTIVE"] = "1"
|
|
176
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
177
|
+
process = await asyncio.create_subprocess_exec(
|
|
178
|
+
|
|
179
|
+
*cmd,
|
|
180
|
+
stdout=asyncio.subprocess.PIPE,
|
|
181
|
+
stderr=asyncio.subprocess.PIPE,
|
|
182
|
+
cwd=session_dir,
|
|
183
|
+
env=env,
|
|
184
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
188
|
+
process.communicate(),
|
|
189
|
+
timeout=float(timeout),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
193
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
194
|
+
|
|
195
|
+
# A: Extract artifacts from raw stdout (before clean_output!)
|
|
196
|
+
artifacts = extract_artifacts(stdout_text)
|
|
197
|
+
|
|
198
|
+
# B: (disabled) Scratch diff via before/after snapshots is removed because
|
|
199
|
+
# multi-user shared scratch causes cross-user artifact leakage.
|
|
200
|
+
# agy-generated files always produce file:/// links in stdout, so
|
|
201
|
+
# extract_artifacts (A) above is sufficient.
|
|
202
|
+
|
|
203
|
+
# Clean display text
|
|
204
|
+
display = clean_output(stdout_text) or EMPTY_REPLY
|
|
205
|
+
|
|
206
|
+
# Strip file:/// links from display to avoid leaking server paths
|
|
207
|
+
display = re.sub(
|
|
208
|
+
r"\[([^\]]+)\]\(file:///[^)]+\)",
|
|
209
|
+
r"[\1]",
|
|
210
|
+
display,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if process.returncode != 0:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"agy exited with code %s for user %s: %.200s",
|
|
216
|
+
process.returncode,
|
|
217
|
+
user_id,
|
|
218
|
+
stderr_text,
|
|
219
|
+
)
|
|
220
|
+
if not stdout_text and stderr_text:
|
|
221
|
+
if "timeout waiting for cascade" in stderr_text.lower() or "timeout waiting for response" in stderr_text.lower():
|
|
222
|
+
logger.warning("Cascade/API timeout detected for user %s, retrying once automatically", user_id)
|
|
223
|
+
retry_process = await asyncio.create_subprocess_exec(
|
|
224
|
+
*cmd,
|
|
225
|
+
stdout=asyncio.subprocess.PIPE,
|
|
226
|
+
stderr=asyncio.subprocess.PIPE,
|
|
227
|
+
cwd=session_dir,
|
|
228
|
+
env=env,
|
|
229
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
230
|
+
)
|
|
231
|
+
r_stdout, r_stderr = await asyncio.wait_for(
|
|
232
|
+
retry_process.communicate(),
|
|
233
|
+
timeout=float(timeout),
|
|
234
|
+
)
|
|
235
|
+
r_stdout_text = r_stdout.decode("utf-8", errors="replace").strip()
|
|
236
|
+
r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
|
|
237
|
+
# Any useful stdout after retry counts as recovered (agy may exit non-zero)
|
|
238
|
+
if r_stdout_text:
|
|
239
|
+
r_artifacts = extract_artifacts(r_stdout_text)
|
|
240
|
+
r_display = clean_output(r_stdout_text) or EMPTY_REPLY
|
|
241
|
+
r_display = re.sub(r"\[([^\]]+)\]\(file:///[^)]+\)", r"[\1]", r_display)
|
|
242
|
+
if first and r_display != EMPTY_REPLY and not r_display.startswith("❌"):
|
|
243
|
+
mark_initialized(session_dir)
|
|
244
|
+
return r_display, r_artifacts
|
|
245
|
+
return format_error(
|
|
246
|
+
"级联超时",
|
|
247
|
+
"模型 API 级联推理超时,自动重试仍失败。请稍后重试或简化指令。",
|
|
248
|
+
), []
|
|
249
|
+
return format_cli_error(stderr_text, backend="agy"), []
|
|
250
|
+
# Non-zero exit: never treat raw stdout as a normal success reply
|
|
251
|
+
raw = stderr_text or stdout_text or display or "agy 进程异常退出"
|
|
252
|
+
return format_cli_error(raw, backend="agy"), []
|
|
253
|
+
|
|
254
|
+
# Success path only
|
|
255
|
+
if first and display != EMPTY_REPLY:
|
|
256
|
+
mark_initialized(session_dir)
|
|
257
|
+
|
|
258
|
+
elapsed = time.time() - t0
|
|
259
|
+
logger.info(
|
|
260
|
+
"agy done: user=%s elapsed=%.1fs artifacts=%d output=%d chars",
|
|
261
|
+
user_id, elapsed, len(artifacts), len(display),
|
|
262
|
+
)
|
|
263
|
+
return display, artifacts
|
|
264
|
+
|
|
265
|
+
except asyncio.TimeoutError:
|
|
266
|
+
logger.warning(
|
|
267
|
+
"agy execution timed out after %ss for user %s",
|
|
268
|
+
timeout,
|
|
269
|
+
user_id,
|
|
270
|
+
)
|
|
271
|
+
await terminate_process(process, graceful=True)
|
|
272
|
+
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.exception("Unexpected error running agy: %s", e)
|
|
276
|
+
await terminate_process(process, graceful=False)
|
|
277
|
+
return format_error("执行出错", str(e)), []
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Slash command support — per-user preference persistence & command dispatch
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
286
|
+
"""Run an agy subcommand (e.g., 'models', 'agents') and return cleaned output.
|
|
287
|
+
|
|
288
|
+
Timeout is fixed at 30 seconds.
|
|
289
|
+
Uses per-user session isolation matching run_agy.
|
|
290
|
+
"""
|
|
291
|
+
session_dir = ensure_user_gemini(user_id)
|
|
292
|
+
cmd = [config.agy_binary_path] + subcmd_args
|
|
293
|
+
try:
|
|
294
|
+
env = sanitize_env(session_dir)
|
|
295
|
+
process = await asyncio.create_subprocess_exec(
|
|
296
|
+
*cmd,
|
|
297
|
+
stdout=asyncio.subprocess.PIPE,
|
|
298
|
+
stderr=asyncio.subprocess.PIPE,
|
|
299
|
+
cwd=session_dir,
|
|
300
|
+
env=env,
|
|
301
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
302
|
+
)
|
|
303
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
304
|
+
process.communicate(), timeout=30.0
|
|
305
|
+
)
|
|
306
|
+
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
307
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
308
|
+
|
|
309
|
+
if process.returncode != 0:
|
|
310
|
+
logger.warning(
|
|
311
|
+
"agy %s exited with code %s",
|
|
312
|
+
" ".join(subcmd_args),
|
|
313
|
+
process.returncode,
|
|
314
|
+
)
|
|
315
|
+
return format_cli_error(
|
|
316
|
+
stderr_text or stdout_text or "终端指令执行失败",
|
|
317
|
+
backend="agy",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return clean_output(stdout_text) or EMPTY_REPLY
|
|
321
|
+
|
|
322
|
+
except asyncio.TimeoutError:
|
|
323
|
+
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.exception("Subcommand error: %s", e)
|
|
326
|
+
return format_error("执行出错", str(e))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _cmd_help() -> str:
|
|
330
|
+
"""Build /help response listing all supported slash commands."""
|
|
331
|
+
lines = [
|
|
332
|
+
"📋 **wechatbridge 支持指令 (agy)** 📋",
|
|
333
|
+
"",
|
|
334
|
+
"**模型控制**",
|
|
335
|
+
"- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
|
|
336
|
+
"- `/models` — 查看可用模型列表",
|
|
337
|
+
"- `/backend <agy|grok>` — 切换后端 CLI",
|
|
338
|
+
"",
|
|
339
|
+
"**对话控制**",
|
|
340
|
+
"- `/clear` 或 `/new` — 重置对话(开始新会话)",
|
|
341
|
+
"- `/fast` — 开启**快速模式**(低推理开销)",
|
|
342
|
+
"- `/planning` — 开启 **planning 模式**",
|
|
343
|
+
"",
|
|
344
|
+
"**工具**",
|
|
345
|
+
"- `/add-dir <路径>` — 添加工作目录",
|
|
346
|
+
"- `/agents` — 查看可用 agent",
|
|
347
|
+
"",
|
|
348
|
+
"**MCP & 子代理**",
|
|
349
|
+
"- `/mcp` — MCP 工具使用引导",
|
|
350
|
+
"- `/agent <名称> <任务>` — 调用子代理执行任务",
|
|
351
|
+
"",
|
|
352
|
+
"**人格**",
|
|
353
|
+
"- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
|
|
354
|
+
"",
|
|
355
|
+
"**其他**",
|
|
356
|
+
"- `/help` — 显示本帮助",
|
|
357
|
+
"",
|
|
358
|
+
"提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给 agy 处理。",
|
|
359
|
+
]
|
|
360
|
+
return "\n".join(lines)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def handle_persona(args: str, user_id: str) -> str:
|
|
364
|
+
"""Handle /persona command: set, show, clear, reset per-user GEMINI.md.
|
|
365
|
+
|
|
366
|
+
Subcommands:
|
|
367
|
+
set <content> — write content as user's persona document
|
|
368
|
+
<content> — same as set (no subcommand)
|
|
369
|
+
show — display current persona content
|
|
370
|
+
clear — delete persona, restore default
|
|
371
|
+
reset — re-copy global GEMINI.md, overwriting local
|
|
372
|
+
"""
|
|
373
|
+
session_dir = get_session_dir(user_id)
|
|
374
|
+
gemini_dir = os.path.join(session_dir, ".gemini")
|
|
375
|
+
gemini_path = os.path.join(gemini_dir, "GEMINI.md")
|
|
376
|
+
|
|
377
|
+
parts = args.strip().split(maxsplit=1)
|
|
378
|
+
subcmd = parts[0].lower() if parts else ""
|
|
379
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
380
|
+
|
|
381
|
+
# set or implicit content
|
|
382
|
+
if subcmd == "set" and rest:
|
|
383
|
+
os.makedirs(gemini_dir, exist_ok=True)
|
|
384
|
+
try:
|
|
385
|
+
with open(gemini_path, "w", encoding="utf-8") as f:
|
|
386
|
+
f.write(rest)
|
|
387
|
+
return "✅ **人格文档已更新** ✅"
|
|
388
|
+
except OSError as e:
|
|
389
|
+
logger.error("Failed to write persona for %s: %s", user_id, e)
|
|
390
|
+
return "❌ **写入人格文档失败** ❌"
|
|
391
|
+
elif subcmd and subcmd not in ("show", "clear", "reset", "set"):
|
|
392
|
+
# No subcommand → treat whole args as content
|
|
393
|
+
os.makedirs(gemini_dir, exist_ok=True)
|
|
394
|
+
try:
|
|
395
|
+
with open(gemini_path, "w", encoding="utf-8") as f:
|
|
396
|
+
f.write(args.strip())
|
|
397
|
+
return "✅ **人格文档已更新** ✅"
|
|
398
|
+
except OSError as e:
|
|
399
|
+
logger.error("Failed to write persona for %s: %s", user_id, e)
|
|
400
|
+
return "❌ **写入人格文档失败** ❌"
|
|
401
|
+
|
|
402
|
+
# show
|
|
403
|
+
if subcmd == "show":
|
|
404
|
+
if not os.path.exists(gemini_path):
|
|
405
|
+
return "(未设置人格文档)"
|
|
406
|
+
try:
|
|
407
|
+
with open(gemini_path, "r", encoding="utf-8") as f:
|
|
408
|
+
val = f.read()
|
|
409
|
+
if len(val) > 1500:
|
|
410
|
+
val = val[:1500] + "\n\n(已截断至前1500字符)"
|
|
411
|
+
return val or "(空文档)"
|
|
412
|
+
except OSError as e:
|
|
413
|
+
logger.error("Failed to read persona for %s: %s", user_id, e)
|
|
414
|
+
return "❌ **读取人格文档失败** ❌"
|
|
415
|
+
|
|
416
|
+
# clear
|
|
417
|
+
if subcmd == "clear":
|
|
418
|
+
if os.path.exists(gemini_path):
|
|
419
|
+
try:
|
|
420
|
+
os.remove(gemini_path)
|
|
421
|
+
return "✅ **人格文档已清除** ✅"
|
|
422
|
+
except OSError as e:
|
|
423
|
+
logger.error("Failed to clear persona for %s: %s", user_id, e)
|
|
424
|
+
return "❌ **清除人格文档失败** ❌"
|
|
425
|
+
return "ℹ️ **本就无人格文档** ℹ️"
|
|
426
|
+
|
|
427
|
+
# reset
|
|
428
|
+
if subcmd == "reset":
|
|
429
|
+
# agy standard persona file path, managed by agy CLI
|
|
430
|
+
agents_src = os.path.expanduser("~/.gemini/GEMINI.md")
|
|
431
|
+
if not os.path.exists(agents_src):
|
|
432
|
+
return "❌ **全局默认人格文档不存在** ❌"
|
|
433
|
+
os.makedirs(gemini_dir, exist_ok=True)
|
|
434
|
+
try:
|
|
435
|
+
shutil.copy(agents_src, gemini_path)
|
|
436
|
+
return "✅ **人格已重置为全局默认** ✅"
|
|
437
|
+
except OSError as e:
|
|
438
|
+
logger.error("Failed to reset persona for %s: %s", user_id, e)
|
|
439
|
+
return "❌ **重置人格文档失败** ❌"
|
|
440
|
+
|
|
441
|
+
# empty args
|
|
442
|
+
return "📋 **/persona 用法** 📋\n\n- `/persona <内容>` 设置\n- `/persona show` 查看\n- `/persona clear` 清除\n- `/persona reset` 重置默认"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _cmd_clear(user_id: str) -> str:
|
|
446
|
+
"""Handle /clear or /new: delete .initialized flag to start fresh."""
|
|
447
|
+
session_dir = get_session_dir(user_id)
|
|
448
|
+
flag_path = os.path.join(session_dir, ".initialized")
|
|
449
|
+
try:
|
|
450
|
+
if os.path.exists(flag_path):
|
|
451
|
+
os.remove(flag_path)
|
|
452
|
+
return "✅ **对话已重置** ✅"
|
|
453
|
+
except OSError as e:
|
|
454
|
+
logger.error("Failed to clear session for %s: %s", user_id, e)
|
|
455
|
+
return "❌ **重置失败** ❌"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _cmd_fast(user_id: str) -> str:
|
|
459
|
+
"""Handle /fast: set effort=low (scoped to current backend)."""
|
|
460
|
+
update_active_prefs(user_id, effort="low")
|
|
461
|
+
return "✅ **已开启 fast 模式** ✅"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _cmd_planning(user_id: str) -> str:
|
|
465
|
+
"""Handle /planning: set mode=plan (scoped to current backend)."""
|
|
466
|
+
update_active_prefs(user_id, mode="plan")
|
|
467
|
+
return "✅ **已开启 planning 模式** ✅"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _cmd_add_dir(args: str, user_id: str) -> str:
|
|
471
|
+
"""Handle /add-dir <path>: add path to add_dirs list (dedup, validated)."""
|
|
472
|
+
path = args.strip()
|
|
473
|
+
if not path:
|
|
474
|
+
return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
|
|
475
|
+
ok, result = validate_add_dir(path, user_id)
|
|
476
|
+
if not ok:
|
|
477
|
+
return f"❌ **目录不允许** ❌\n\n{result}"
|
|
478
|
+
resolved = result
|
|
479
|
+
prefs = load_prefs(user_id)
|
|
480
|
+
dirs = prefs.get("add_dirs", [])
|
|
481
|
+
if resolved not in dirs:
|
|
482
|
+
dirs.append(resolved)
|
|
483
|
+
prefs["add_dirs"] = dirs
|
|
484
|
+
save_prefs(user_id, prefs)
|
|
485
|
+
return f"✅ **已添加工作目录** ✅\n\n```\n{resolved}\n```"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def _cmd_model(args: str, user_id: str) -> str:
|
|
489
|
+
"""Handle /model <name>: validate against agy models list, then save.
|
|
490
|
+
|
|
491
|
+
Matching order:
|
|
492
|
+
1. Exact match against a model name
|
|
493
|
+
2. Prefix match (name is a prefix of one or more model names → first hit)
|
|
494
|
+
"""
|
|
495
|
+
name = args.strip()
|
|
496
|
+
if not name:
|
|
497
|
+
return "❌ **缺少参数** ❌\n\n`/model <名称>`"
|
|
498
|
+
|
|
499
|
+
output = await _run_agy_subcommand(["models"], user_id)
|
|
500
|
+
if output.startswith("[error]") or output.startswith("❌"):
|
|
501
|
+
return "❌ **无法获取模型列表** ❌"
|
|
502
|
+
|
|
503
|
+
models = [line.strip() for line in output.split("\n") if line.strip()]
|
|
504
|
+
|
|
505
|
+
# Exact match
|
|
506
|
+
if name in models:
|
|
507
|
+
# If model name carries an effort suffix, clear stored effort so
|
|
508
|
+
# run_agy doesn't pass a conflicting --effort flag
|
|
509
|
+
_, embedded = parse_model_effort(name)
|
|
510
|
+
if embedded:
|
|
511
|
+
update_active_prefs(user_id, model=name, effort="")
|
|
512
|
+
else:
|
|
513
|
+
update_active_prefs(user_id, model=name)
|
|
514
|
+
return f"✅ **模型已切换** ✅\n\n`{name}`"
|
|
515
|
+
|
|
516
|
+
# Prefix match
|
|
517
|
+
prefix_matches = [m for m in models if m.startswith(name)]
|
|
518
|
+
if prefix_matches:
|
|
519
|
+
matched = prefix_matches[0]
|
|
520
|
+
_, embedded = parse_model_effort(matched)
|
|
521
|
+
if embedded:
|
|
522
|
+
update_active_prefs(user_id, model=matched, effort="")
|
|
523
|
+
else:
|
|
524
|
+
update_active_prefs(user_id, model=matched)
|
|
525
|
+
return f"✅ **模型已切换** ✅\n\n`{matched}`"
|
|
526
|
+
|
|
527
|
+
return f"❌ **模型不存在** ❌\n\n`{name}`"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def handle_slash_command(text: str, user_id: str) -> str | None:
|
|
531
|
+
"""Handle /-slash commands from WeChat messages.
|
|
532
|
+
|
|
533
|
+
Parses the first whitespace-separated token as the command (lowercased),
|
|
534
|
+
and the remainder as arguments.
|
|
535
|
+
|
|
536
|
+
Classification:
|
|
537
|
+
A — implemented in wechatbridge (model, clear, fast, planning, add-dir, etc.)
|
|
538
|
+
B — dangerous (exit, quit, logout) → rejected with error message
|
|
539
|
+
C — TUI panels (config, settings, context, ...) → inform not supported on WeChat
|
|
540
|
+
D — passthrough to agy → returns None, caller runs run_agy() normally
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
str: reply message for A/B/C classes
|
|
544
|
+
None: for D class — the caller should pass the original text to run_agy()
|
|
545
|
+
"""
|
|
546
|
+
# Parse: first whitespace token = cmd, rest = args
|
|
547
|
+
parts = text.strip().split(maxsplit=1)
|
|
548
|
+
cmd = parts[0].lower() if parts else text.lower()
|
|
549
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
550
|
+
|
|
551
|
+
# --- B class: dangerous / rejected ---
|
|
552
|
+
B_CMDS = frozenset({"/exit", "/quit", "/logout"})
|
|
553
|
+
if cmd in B_CMDS:
|
|
554
|
+
return (
|
|
555
|
+
"⛔ **该指令在微信端禁用** ⛔"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# --- C class: TUI panels (not supported on WeChat) ---
|
|
559
|
+
C_CMDS = frozenset({
|
|
560
|
+
"/config", "/settings", "/context", "/diff", "/artifact", "/tasks",
|
|
561
|
+
"/hooks", "/keybindings", "/permissions", "/statusline",
|
|
562
|
+
"/copy", "/open", "/rename", "/fork", "/branch", "/rewind", "/undo",
|
|
563
|
+
"/resume", "/switch", "/conversation", "/title", "/feedback",
|
|
564
|
+
"/usage", "/quota", "/credits", "/skills",
|
|
565
|
+
})
|
|
566
|
+
if cmd in C_CMDS:
|
|
567
|
+
return (
|
|
568
|
+
f"⚠️ **微信端不支持** ⚠️\n\n`{cmd}`"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# --- A class: implemented commands ---
|
|
572
|
+
if cmd == "/help":
|
|
573
|
+
return _cmd_help()
|
|
574
|
+
|
|
575
|
+
if cmd in ("/clear", "/new"):
|
|
576
|
+
return _cmd_clear(user_id)
|
|
577
|
+
|
|
578
|
+
if cmd == "/fast":
|
|
579
|
+
return _cmd_fast(user_id)
|
|
580
|
+
|
|
581
|
+
if cmd == "/planning":
|
|
582
|
+
return _cmd_planning(user_id)
|
|
583
|
+
|
|
584
|
+
if cmd == "/model":
|
|
585
|
+
return await _cmd_model(args, user_id)
|
|
586
|
+
|
|
587
|
+
if cmd == "/add-dir":
|
|
588
|
+
return _cmd_add_dir(args, user_id)
|
|
589
|
+
|
|
590
|
+
if cmd == "/agents":
|
|
591
|
+
output = await _run_agy_subcommand(["agents"], user_id)
|
|
592
|
+
if not output or output == "Available agents:":
|
|
593
|
+
output = "**Available agents**\n\n(当前没有自定义 agent。)"
|
|
594
|
+
return output
|
|
595
|
+
|
|
596
|
+
if cmd == "/models":
|
|
597
|
+
return await _run_agy_subcommand(["models"], user_id)
|
|
598
|
+
|
|
599
|
+
if cmd == "/persona":
|
|
600
|
+
return handle_persona(args, user_id)
|
|
601
|
+
|
|
602
|
+
# --- MCP & Subagent ---
|
|
603
|
+
if cmd == "/mcp":
|
|
604
|
+
if not config.enable_mcp:
|
|
605
|
+
return "ℹ️ **该功能已禁用** ℹ️"
|
|
606
|
+
return (
|
|
607
|
+
"ℹ️ **MCP 工具使用引导** ℹ️\n\n"
|
|
608
|
+
"agy 已配置 MCP server(ctxmode / codegraph)。\n\n"
|
|
609
|
+
"使用方法:用自然语言描述调用,格式为:\n"
|
|
610
|
+
"> 用 call_mcp_tool 调用 `<工具名>`,参数 `<json>`\n\n"
|
|
611
|
+
"示例:\n"
|
|
612
|
+
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if cmd == "/agent":
|
|
616
|
+
if not config.enable_subagent:
|
|
617
|
+
return "ℹ️ **该功能已禁用** ℹ️"
|
|
618
|
+
if not args:
|
|
619
|
+
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
620
|
+
# Construct prompt and run through agy
|
|
621
|
+
agent_parts = args.split(maxsplit=1)
|
|
622
|
+
agent_name = agent_parts[0]
|
|
623
|
+
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
624
|
+
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
625
|
+
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
626
|
+
result_text, _ = await run_agy(crafted, user_id)
|
|
627
|
+
return result_text
|
|
628
|
+
|
|
629
|
+
# --- D class: passthrough to agy (return None so caller runs run_agy) ---
|
|
630
|
+
return None
|