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/grok.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""grok (Grok Build CLI) runner with per-user session isolation.
|
|
2
|
+
|
|
3
|
+
Mirrors agy.py's interface: run_grok(), handle_grok_slash_command(),
|
|
4
|
+
ensure_user_grok(), get_session_dir() — all imported from runner_common.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import time
|
|
14
|
+
import urllib.parse
|
|
15
|
+
|
|
16
|
+
from .config import config
|
|
17
|
+
from .runner_common import (
|
|
18
|
+
sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized,
|
|
19
|
+
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
20
|
+
sanitize_env, terminate_process, update_active_prefs,
|
|
21
|
+
format_error, format_cli_error, EMPTY_REPLY, validate_add_dir,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("grok_runner")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Per-user .grok directory setup
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _host_grok_dir() -> str:
|
|
32
|
+
"""Host-level ~/.grok (bridge process home), not the per-user session HOME.
|
|
33
|
+
|
|
34
|
+
Auth/login is machine-wide; conversation state stays under the session dir.
|
|
35
|
+
Override with WECHATBRIDGE_HOST_HOME if the service home is not the login home.
|
|
36
|
+
"""
|
|
37
|
+
host_home = os.environ.get("WECHATBRIDGE_HOST_HOME") or os.path.expanduser("~")
|
|
38
|
+
return os.path.join(host_home, ".grok")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sync_grok_auth(grok_dir: str) -> bool:
|
|
42
|
+
"""Make session .grok/auth.json track the host login credentials.
|
|
43
|
+
|
|
44
|
+
Bug fixed: we used to copy auth.json only once. Token refresh updates the
|
|
45
|
+
host ~/.grok/auth.json, while the session kept a stale/missing copy →
|
|
46
|
+
false "Not signed in" even when host login is still valid.
|
|
47
|
+
|
|
48
|
+
Strategy: symlink session auth.json → host auth.json (shared refresh).
|
|
49
|
+
Fall back to copy2 if symlink is not possible.
|
|
50
|
+
Returns True if credentials are available for the child process.
|
|
51
|
+
"""
|
|
52
|
+
auth_src = os.path.join(_host_grok_dir(), "auth.json")
|
|
53
|
+
auth_dst = os.path.join(grok_dir, "auth.json")
|
|
54
|
+
|
|
55
|
+
if not os.path.isfile(auth_src):
|
|
56
|
+
logger.warning("Host grok auth missing: %s", auth_src)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
src_real = os.path.realpath(auth_src)
|
|
61
|
+
if os.path.islink(auth_dst) or os.path.exists(auth_dst):
|
|
62
|
+
try:
|
|
63
|
+
if os.path.realpath(auth_dst) == src_real and os.path.isfile(auth_dst):
|
|
64
|
+
return True
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
os.unlink(auth_dst)
|
|
69
|
+
except OSError:
|
|
70
|
+
# Windows or busy file — try overwrite via copy below
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
os.symlink(src_real, auth_dst)
|
|
75
|
+
logger.info("Linked session auth.json -> %s", src_real)
|
|
76
|
+
return True
|
|
77
|
+
except OSError as e:
|
|
78
|
+
logger.warning("symlink auth.json failed (%s), falling back to copy", e)
|
|
79
|
+
shutil.copy2(auth_src, auth_dst)
|
|
80
|
+
os.chmod(auth_dst, 0o600)
|
|
81
|
+
return True
|
|
82
|
+
except OSError as e:
|
|
83
|
+
logger.warning("Failed to sync auth.json into %s: %s", grok_dir, e)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_user_grok(user_id: str) -> str:
|
|
88
|
+
"""Ensure per-user .grok directory with auth credentials.
|
|
89
|
+
|
|
90
|
+
Creates session/.grok/ for grok config and conversations.
|
|
91
|
+
Always syncs host auth.json (symlink preferred) so login state matches
|
|
92
|
+
the machine-level `grok login`, not a stale one-shot copy.
|
|
93
|
+
Returns session_dir path (for use as HOME when running grok).
|
|
94
|
+
"""
|
|
95
|
+
session_dir = ensure_session_dir(user_id)
|
|
96
|
+
grok_dir = os.path.join(session_dir, ".grok")
|
|
97
|
+
os.makedirs(grok_dir, exist_ok=True)
|
|
98
|
+
try:
|
|
99
|
+
os.chmod(grok_dir, 0o700)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
_sync_grok_auth(grok_dir)
|
|
104
|
+
|
|
105
|
+
return session_dir
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Persona persistence (via --rules injection)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _persona_path(session_dir: str) -> str:
|
|
113
|
+
return os.path.join(session_dir, "grok_persona.txt")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_persona(session_dir: str) -> str:
|
|
117
|
+
"""Read persona content for --rules injection. Returns empty string if none."""
|
|
118
|
+
path = _persona_path(session_dir)
|
|
119
|
+
if os.path.exists(path):
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
122
|
+
return f.read().strip()
|
|
123
|
+
except OSError:
|
|
124
|
+
return ""
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handle_grok_persona(args: str, user_id: str) -> str:
|
|
129
|
+
"""Handle /persona command for grok backend.
|
|
130
|
+
|
|
131
|
+
Stores persona text in grok_persona.txt, injected via --rules at run time.
|
|
132
|
+
Subcommands: set <content>, show, clear, reset (same as clear for grok).
|
|
133
|
+
"""
|
|
134
|
+
session_dir = get_session_dir(user_id)
|
|
135
|
+
persona_path = _persona_path(session_dir)
|
|
136
|
+
|
|
137
|
+
parts = args.strip().split(maxsplit=1)
|
|
138
|
+
subcmd = parts[0].lower() if parts else ""
|
|
139
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
140
|
+
|
|
141
|
+
# set or implicit content
|
|
142
|
+
if subcmd == "set" and rest:
|
|
143
|
+
try:
|
|
144
|
+
with open(persona_path, "w", encoding="utf-8") as f:
|
|
145
|
+
f.write(rest)
|
|
146
|
+
return "✅ **人格文档已更新** ✅"
|
|
147
|
+
except OSError as e:
|
|
148
|
+
logger.error("Failed to write persona for %s: %s", user_id, e)
|
|
149
|
+
return "❌ **写入人格文档失败** ❌"
|
|
150
|
+
elif subcmd and subcmd not in ("show", "clear", "reset", "set"):
|
|
151
|
+
# No subcommand → treat whole args as content
|
|
152
|
+
try:
|
|
153
|
+
with open(persona_path, "w", encoding="utf-8") as f:
|
|
154
|
+
f.write(args.strip())
|
|
155
|
+
return "✅ **人格文档已更新** ✅"
|
|
156
|
+
except OSError as e:
|
|
157
|
+
logger.error("Failed to write persona for %s: %s", user_id, e)
|
|
158
|
+
return "❌ **写入人格文档失败** ❌"
|
|
159
|
+
|
|
160
|
+
# show
|
|
161
|
+
if subcmd == "show":
|
|
162
|
+
if not os.path.exists(persona_path):
|
|
163
|
+
return "(未设置人格文档)"
|
|
164
|
+
try:
|
|
165
|
+
with open(persona_path, "r", encoding="utf-8") as f:
|
|
166
|
+
val = f.read()
|
|
167
|
+
if len(val) > 1500:
|
|
168
|
+
val = val[:1500] + "\n\n(已截断至前1500字符)"
|
|
169
|
+
return val or "(空文档)"
|
|
170
|
+
except OSError as e:
|
|
171
|
+
logger.error("Failed to read persona for %s: %s", user_id, e)
|
|
172
|
+
return "❌ **读取人格文档失败** ❌"
|
|
173
|
+
|
|
174
|
+
# clear
|
|
175
|
+
if subcmd == "clear":
|
|
176
|
+
if os.path.exists(persona_path):
|
|
177
|
+
try:
|
|
178
|
+
os.remove(persona_path)
|
|
179
|
+
return "✅ **人格文档已清除** ✅"
|
|
180
|
+
except OSError as e:
|
|
181
|
+
logger.error("Failed to clear persona for %s: %s", user_id, e)
|
|
182
|
+
return "❌ **清除人格文档失败** ❌"
|
|
183
|
+
return "ℹ️ **本就无人格文档** ℹ️"
|
|
184
|
+
|
|
185
|
+
# reset (grok has no global default persona; same as clear)
|
|
186
|
+
if subcmd == "reset":
|
|
187
|
+
if os.path.exists(persona_path):
|
|
188
|
+
try:
|
|
189
|
+
os.remove(persona_path)
|
|
190
|
+
return "✅ **人格已重置** ✅"
|
|
191
|
+
except OSError as e:
|
|
192
|
+
logger.error("Failed to reset persona for %s: %s", user_id, e)
|
|
193
|
+
return "❌ **重置人格文档失败** ❌"
|
|
194
|
+
return "ℹ️ **本就无人格文档** ℹ️"
|
|
195
|
+
|
|
196
|
+
# empty args
|
|
197
|
+
return "📋 **/persona 用法** 📋\n\n- `/persona <内容>` 设置\n- `/persona show` 查看\n- `/persona clear` 清除\n- `/persona reset` 重置"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Command builder (pure function for testability)
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _build_grok_command(prompt: str, prefs: dict, first: bool, persona_content: str = "") -> list:
|
|
205
|
+
"""Build grok CLI argv list. Pure function — does not execute.
|
|
206
|
+
|
|
207
|
+
Flag mapping (agy → grok):
|
|
208
|
+
--dangerously-skip-permissions → --always-approve
|
|
209
|
+
--effort → --reasoning-effort
|
|
210
|
+
--mode plan → --permission-mode plan
|
|
211
|
+
-c → --continue
|
|
212
|
+
(persona via GEMINI.md) → --rules <content>
|
|
213
|
+
"""
|
|
214
|
+
cmd = [config.grok_binary_path, "--output-format", "json"]
|
|
215
|
+
|
|
216
|
+
mode = prefs.get("mode", "")
|
|
217
|
+
if mode == "plan":
|
|
218
|
+
cmd += ["--permission-mode", "plan"]
|
|
219
|
+
else:
|
|
220
|
+
cmd += ["--always-approve"]
|
|
221
|
+
|
|
222
|
+
model = prefs.get("model", "")
|
|
223
|
+
effort = prefs.get("effort", "")
|
|
224
|
+
if model:
|
|
225
|
+
base_model, embedded_effort = parse_model_effort(model)
|
|
226
|
+
if embedded_effort and effort:
|
|
227
|
+
cmd += ["--model", base_model, "--reasoning-effort", effort]
|
|
228
|
+
elif embedded_effort:
|
|
229
|
+
cmd += ["--model", model]
|
|
230
|
+
else:
|
|
231
|
+
cmd += ["--model", model]
|
|
232
|
+
if effort:
|
|
233
|
+
cmd += ["--reasoning-effort", effort]
|
|
234
|
+
elif effort:
|
|
235
|
+
cmd += ["--reasoning-effort", effort]
|
|
236
|
+
|
|
237
|
+
# Persona injection via --rules
|
|
238
|
+
if persona_content:
|
|
239
|
+
cmd += ["--rules", persona_content]
|
|
240
|
+
|
|
241
|
+
# Session continuation
|
|
242
|
+
if not first:
|
|
243
|
+
cmd += ["--continue"]
|
|
244
|
+
|
|
245
|
+
cmd += ["-p", prompt]
|
|
246
|
+
return cmd
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Artifact extraction from chat_history.jsonl
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
254
|
+
"""Extract (name, abs_path) tuples from grok session chat_history.jsonl.
|
|
255
|
+
|
|
256
|
+
grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
|
|
257
|
+
The chat_history.jsonl contains structured tool_calls with file_path arguments
|
|
258
|
+
from write/edit operations.
|
|
259
|
+
|
|
260
|
+
Falls back to empty list on any error (never blocks text reply).
|
|
261
|
+
"""
|
|
262
|
+
grok_sessions = os.path.join(session_dir, ".grok", "sessions")
|
|
263
|
+
cwd_encoded = urllib.parse.quote(session_dir, safe="")
|
|
264
|
+
session_path = os.path.join(grok_sessions, cwd_encoded, session_id)
|
|
265
|
+
chat_history = os.path.join(session_path, "chat_history.jsonl")
|
|
266
|
+
|
|
267
|
+
if not os.path.exists(chat_history):
|
|
268
|
+
logger.debug("No chat_history.jsonl at %s", chat_history)
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
artifacts = []
|
|
272
|
+
seen = set()
|
|
273
|
+
try:
|
|
274
|
+
with open(chat_history, "r", encoding="utf-8") as f:
|
|
275
|
+
for line in f:
|
|
276
|
+
line = line.strip()
|
|
277
|
+
if not line.startswith("{"):
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
d = json.loads(line)
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
continue
|
|
283
|
+
if d.get("type") == "assistant" and d.get("tool_calls"):
|
|
284
|
+
for tc in d.get("tool_calls", []):
|
|
285
|
+
name = tc.get("name", "")
|
|
286
|
+
args = tc.get("arguments", "")
|
|
287
|
+
if isinstance(args, str):
|
|
288
|
+
try:
|
|
289
|
+
args = json.loads(args)
|
|
290
|
+
except json.JSONDecodeError:
|
|
291
|
+
continue
|
|
292
|
+
if name in ("write", "edit", "str_replace") and isinstance(args, dict):
|
|
293
|
+
fp = args.get("file_path", "")
|
|
294
|
+
if fp and os.path.isabs(fp):
|
|
295
|
+
art_name = os.path.basename(fp)
|
|
296
|
+
key = (art_name, fp)
|
|
297
|
+
if key not in seen:
|
|
298
|
+
seen.add(key)
|
|
299
|
+
artifacts.append(key)
|
|
300
|
+
except OSError as e:
|
|
301
|
+
logger.warning("Failed to read chat_history.jsonl: %s", e)
|
|
302
|
+
|
|
303
|
+
if artifacts:
|
|
304
|
+
logger.debug("Extracted %d grok artifacts: %s", len(artifacts), [n for n, _ in artifacts[:3]])
|
|
305
|
+
return artifacts
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
|
|
309
|
+
"""Parse grok JSON output into (display_text, artifacts).
|
|
310
|
+
|
|
311
|
+
Handles both success JSON ({text, sessionId, ...}) and error JSON
|
|
312
|
+
({type: error, message: ...}). Falls back to plain text on parse failure.
|
|
313
|
+
"""
|
|
314
|
+
if not stdout_text:
|
|
315
|
+
return EMPTY_REPLY, []
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
data = json.loads(stdout_text)
|
|
319
|
+
except json.JSONDecodeError:
|
|
320
|
+
# Non-JSON output — treat as plain text
|
|
321
|
+
return clean_output(stdout_text) or EMPTY_REPLY, []
|
|
322
|
+
|
|
323
|
+
if data.get("type") == "error":
|
|
324
|
+
msg = data.get("message", "unknown grok error")
|
|
325
|
+
logger.warning("grok error: %s", msg)
|
|
326
|
+
return format_cli_error(msg, backend="grok"), []
|
|
327
|
+
|
|
328
|
+
display = data.get("text", "")
|
|
329
|
+
session_id = data.get("sessionId", "")
|
|
330
|
+
|
|
331
|
+
artifacts = []
|
|
332
|
+
if session_id:
|
|
333
|
+
artifacts = _extract_grok_artifacts(session_dir, session_id)
|
|
334
|
+
|
|
335
|
+
# Strip file:/// links from display (in case grok emits them)
|
|
336
|
+
display = re.sub(
|
|
337
|
+
r"\[([^\]]+)\]\(file:///[^)]+\)",
|
|
338
|
+
r"[\1]",
|
|
339
|
+
display,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return clean_output(display) or EMPTY_REPLY, artifacts
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# grok CLI execution
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
350
|
+
"""Execute grok CLI for a given user message.
|
|
351
|
+
|
|
352
|
+
Mirrors agy.run_agy() interface.
|
|
353
|
+
Returns (cleaned_display_text, list_of_(name, abs_path)_artifacts).
|
|
354
|
+
"""
|
|
355
|
+
if timeout is None:
|
|
356
|
+
timeout = config.agy_timeout
|
|
357
|
+
|
|
358
|
+
t0 = time.time()
|
|
359
|
+
session_dir = ensure_user_grok(user_id)
|
|
360
|
+
|
|
361
|
+
# Audit logging
|
|
362
|
+
logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
|
|
363
|
+
if is_dangerous(prompt):
|
|
364
|
+
logger.warning("[AUDIT] dangerous keyword in prompt from user=%s", user_id)
|
|
365
|
+
|
|
366
|
+
first = is_first_message(session_dir)
|
|
367
|
+
prefs = load_prefs(user_id)
|
|
368
|
+
persona_content = _read_persona(session_dir)
|
|
369
|
+
cmd = _build_grok_command(prompt, prefs, first, persona_content)
|
|
370
|
+
|
|
371
|
+
if first:
|
|
372
|
+
logger.info("First message for user %s, running: grok -p ...", user_id)
|
|
373
|
+
else:
|
|
374
|
+
logger.info("Continuing conversation for user %s, running: grok --continue -p ...", user_id)
|
|
375
|
+
|
|
376
|
+
process = None
|
|
377
|
+
try:
|
|
378
|
+
env = sanitize_env(session_dir)
|
|
379
|
+
env["PAGER"] = "cat"
|
|
380
|
+
env["CI"] = "true"
|
|
381
|
+
env["NONINTERACTIVE"] = "1"
|
|
382
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
383
|
+
|
|
384
|
+
process = await asyncio.create_subprocess_exec(
|
|
385
|
+
*cmd,
|
|
386
|
+
stdout=asyncio.subprocess.PIPE,
|
|
387
|
+
stderr=asyncio.subprocess.PIPE,
|
|
388
|
+
cwd=session_dir,
|
|
389
|
+
env=env,
|
|
390
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
394
|
+
process.communicate(),
|
|
395
|
+
timeout=float(timeout),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
399
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
400
|
+
|
|
401
|
+
display, artifacts = _parse_grok_output(stdout_text, session_dir)
|
|
402
|
+
|
|
403
|
+
# Failure detection: non-zero exit, or structured error already formatted
|
|
404
|
+
failed = process.returncode != 0 or (
|
|
405
|
+
isinstance(display, str) and display.startswith("❌")
|
|
406
|
+
)
|
|
407
|
+
if process.returncode != 0 and (not display or display == EMPTY_REPLY):
|
|
408
|
+
logger.warning(
|
|
409
|
+
"grok exited with code %s for user %s: %.200s",
|
|
410
|
+
process.returncode, user_id, stderr_text,
|
|
411
|
+
)
|
|
412
|
+
display = format_cli_error(
|
|
413
|
+
stderr_text or "grok 进程异常退出", backend="grok"
|
|
414
|
+
)
|
|
415
|
+
artifacts = []
|
|
416
|
+
failed = True
|
|
417
|
+
|
|
418
|
+
# Only mark session initialized on a real successful reply
|
|
419
|
+
if first and not failed:
|
|
420
|
+
mark_initialized(session_dir)
|
|
421
|
+
|
|
422
|
+
elapsed = time.time() - t0
|
|
423
|
+
logger.info(
|
|
424
|
+
"grok done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
|
|
425
|
+
user_id, elapsed, len(artifacts), len(display), failed,
|
|
426
|
+
)
|
|
427
|
+
return display, artifacts
|
|
428
|
+
|
|
429
|
+
except asyncio.TimeoutError:
|
|
430
|
+
logger.warning("grok execution timed out after %ss for user %s", timeout, user_id)
|
|
431
|
+
await terminate_process(process, graceful=True)
|
|
432
|
+
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
433
|
+
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.exception("Unexpected error running grok: %s", e)
|
|
436
|
+
await terminate_process(process, graceful=False)
|
|
437
|
+
return format_error("执行出错", str(e)), []
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
441
|
+
"""Run a grok subcommand (e.g., 'models', 'agent') and return cleaned output.
|
|
442
|
+
|
|
443
|
+
Timeout is fixed at 30 seconds.
|
|
444
|
+
Uses per-user session isolation matching run_grok.
|
|
445
|
+
"""
|
|
446
|
+
session_dir = ensure_user_grok(user_id)
|
|
447
|
+
cmd = [config.grok_binary_path] + subcmd_args
|
|
448
|
+
try:
|
|
449
|
+
env = sanitize_env(session_dir)
|
|
450
|
+
process = await asyncio.create_subprocess_exec(
|
|
451
|
+
*cmd,
|
|
452
|
+
stdout=asyncio.subprocess.PIPE,
|
|
453
|
+
stderr=asyncio.subprocess.PIPE,
|
|
454
|
+
cwd=session_dir,
|
|
455
|
+
env=env,
|
|
456
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
457
|
+
)
|
|
458
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
459
|
+
process.communicate(), timeout=30.0
|
|
460
|
+
)
|
|
461
|
+
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
462
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
463
|
+
|
|
464
|
+
if process.returncode != 0:
|
|
465
|
+
logger.warning("grok %s exited with code %s", " ".join(subcmd_args), process.returncode)
|
|
466
|
+
# Prefer stdout (often JSON error) then stderr
|
|
467
|
+
raw = stdout_text or stderr_text or "终端指令执行失败"
|
|
468
|
+
try:
|
|
469
|
+
data = json.loads(stdout_text) if stdout_text else None
|
|
470
|
+
except json.JSONDecodeError:
|
|
471
|
+
data = None
|
|
472
|
+
if isinstance(data, dict) and data.get("type") == "error":
|
|
473
|
+
return format_cli_error(data.get("message") or raw, backend="grok")
|
|
474
|
+
return format_cli_error(raw, backend="grok")
|
|
475
|
+
|
|
476
|
+
return clean_output(stdout_text) or EMPTY_REPLY
|
|
477
|
+
|
|
478
|
+
except asyncio.TimeoutError:
|
|
479
|
+
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.exception("Subcommand error: %s", e)
|
|
482
|
+
return format_error("执行出错", str(e))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# Slash command support
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def _parse_grok_models(output: str) -> list:
|
|
490
|
+
"""Parse grok models output into a list of model names.
|
|
491
|
+
|
|
492
|
+
grok output format:
|
|
493
|
+
Available models:
|
|
494
|
+
* grok-4.5 (default)
|
|
495
|
+
* grok-4.5-mini
|
|
496
|
+
"""
|
|
497
|
+
models = []
|
|
498
|
+
for line in output.split("\n"):
|
|
499
|
+
line = line.strip()
|
|
500
|
+
if line.startswith("* "):
|
|
501
|
+
name = line[2:].split()[0].split("(")[0].strip()
|
|
502
|
+
if name:
|
|
503
|
+
models.append(name)
|
|
504
|
+
return models
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def _cmd_model(args: str, user_id: str) -> str:
|
|
508
|
+
"""Handle /model <name>: validate against grok models list, then save."""
|
|
509
|
+
name = args.strip()
|
|
510
|
+
if not name:
|
|
511
|
+
return "❌ **缺少参数** ❌\n\n`/model <名称>`"
|
|
512
|
+
|
|
513
|
+
output = await _run_grok_subcommand(["models"], user_id)
|
|
514
|
+
if output.startswith("❌"):
|
|
515
|
+
return "❌ **无法获取模型列表** ❌"
|
|
516
|
+
|
|
517
|
+
models = _parse_grok_models(output)
|
|
518
|
+
if not models:
|
|
519
|
+
# Fallback: treat non-empty lines as model names
|
|
520
|
+
models = [line.strip() for line in output.split("\n") if line.strip()]
|
|
521
|
+
|
|
522
|
+
# Exact match
|
|
523
|
+
if name in models:
|
|
524
|
+
_, embedded = parse_model_effort(name)
|
|
525
|
+
if embedded:
|
|
526
|
+
update_active_prefs(user_id, model=name, effort="")
|
|
527
|
+
else:
|
|
528
|
+
update_active_prefs(user_id, model=name)
|
|
529
|
+
return f"✅ **模型已切换** ✅\n\n`{name}`"
|
|
530
|
+
|
|
531
|
+
# Prefix match
|
|
532
|
+
prefix_matches = [m for m in models if m.startswith(name)]
|
|
533
|
+
if prefix_matches:
|
|
534
|
+
matched = prefix_matches[0]
|
|
535
|
+
_, embedded = parse_model_effort(matched)
|
|
536
|
+
if embedded:
|
|
537
|
+
update_active_prefs(user_id, model=matched, effort="")
|
|
538
|
+
else:
|
|
539
|
+
update_active_prefs(user_id, model=matched)
|
|
540
|
+
return f"✅ **模型已切换** ✅\n\n`{matched}`"
|
|
541
|
+
|
|
542
|
+
return f"❌ **模型不存在** ❌\n\n`{name}`"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _cmd_help() -> str:
|
|
546
|
+
"""Build /help response for grok backend."""
|
|
547
|
+
lines = [
|
|
548
|
+
"📋 **wechatbridge 支持指令 (grok)** 📋",
|
|
549
|
+
"",
|
|
550
|
+
"**模型控制**",
|
|
551
|
+
"- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
|
|
552
|
+
"- `/models` — 查看可用模型列表",
|
|
553
|
+
"- `/backend <agy|grok>` — 切换后端 CLI",
|
|
554
|
+
"",
|
|
555
|
+
"**对话控制**",
|
|
556
|
+
"- `/clear` 或 `/new` — 重置对话(开始新会话)",
|
|
557
|
+
"- `/fast` — 开启**快速模式**(低推理开销)",
|
|
558
|
+
"- `/planning` — 开启 **planning 模式**",
|
|
559
|
+
"",
|
|
560
|
+
"**工具**",
|
|
561
|
+
"- `/add-dir <路径>` — 添加工作目录(grok 后端暂不支持,仅记录)",
|
|
562
|
+
"- `/agents` — 查看可用 agent",
|
|
563
|
+
"",
|
|
564
|
+
"**人格**",
|
|
565
|
+
"- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
|
|
566
|
+
"",
|
|
567
|
+
"**其他**",
|
|
568
|
+
"- `/help` — 显示本帮助",
|
|
569
|
+
"",
|
|
570
|
+
"提示:其他 `/` 指令会直接交给 grok 处理。",
|
|
571
|
+
]
|
|
572
|
+
return "\n".join(lines)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
|
|
576
|
+
"""Handle /-slash commands for grok backend.
|
|
577
|
+
|
|
578
|
+
Returns str for A/B/C classes, None for D class (passthrough to run_grok).
|
|
579
|
+
"""
|
|
580
|
+
parts = text.strip().split(maxsplit=1)
|
|
581
|
+
cmd = parts[0].lower() if parts else text.lower()
|
|
582
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
583
|
+
|
|
584
|
+
# --- B class: dangerous / rejected ---
|
|
585
|
+
B_CMDS = frozenset({"/exit", "/quit", "/logout"})
|
|
586
|
+
if cmd in B_CMDS:
|
|
587
|
+
return "⛔ **该指令在微信端禁用** ⛔"
|
|
588
|
+
|
|
589
|
+
# --- C class: TUI panels (not supported on WeChat) ---
|
|
590
|
+
C_CMDS = frozenset({
|
|
591
|
+
"/config", "/settings", "/context", "/diff", "/artifact", "/tasks",
|
|
592
|
+
"/hooks", "/keybindings", "/permissions", "/statusline",
|
|
593
|
+
"/copy", "/open", "/rename", "/fork", "/branch", "/rewind", "/undo",
|
|
594
|
+
"/resume", "/switch", "/conversation", "/title", "/feedback",
|
|
595
|
+
"/usage", "/quota", "/credits", "/skills",
|
|
596
|
+
})
|
|
597
|
+
if cmd in C_CMDS:
|
|
598
|
+
return f"⚠️ **微信端不支持** ⚠️\n\n`{cmd}`"
|
|
599
|
+
|
|
600
|
+
# --- A class: implemented commands ---
|
|
601
|
+
if cmd == "/help":
|
|
602
|
+
return _cmd_help()
|
|
603
|
+
|
|
604
|
+
if cmd in ("/clear", "/new"):
|
|
605
|
+
session_dir = get_session_dir(user_id)
|
|
606
|
+
flag_path = os.path.join(session_dir, ".initialized")
|
|
607
|
+
try:
|
|
608
|
+
if os.path.exists(flag_path):
|
|
609
|
+
os.remove(flag_path)
|
|
610
|
+
return "✅ **对话已重置** ✅"
|
|
611
|
+
except OSError as e:
|
|
612
|
+
logger.error("Failed to clear session for %s: %s", user_id, e)
|
|
613
|
+
return "❌ **重置失败** ❌"
|
|
614
|
+
|
|
615
|
+
if cmd == "/fast":
|
|
616
|
+
update_active_prefs(user_id, effort="low")
|
|
617
|
+
return "✅ **已开启 fast 模式** ✅"
|
|
618
|
+
|
|
619
|
+
if cmd == "/planning":
|
|
620
|
+
update_active_prefs(user_id, mode="plan")
|
|
621
|
+
return "✅ **已开启 planning 模式** ✅"
|
|
622
|
+
|
|
623
|
+
if cmd == "/model":
|
|
624
|
+
return await _cmd_model(args, user_id)
|
|
625
|
+
|
|
626
|
+
if cmd == "/add-dir":
|
|
627
|
+
path = args.strip()
|
|
628
|
+
if not path:
|
|
629
|
+
return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
|
|
630
|
+
ok, result = validate_add_dir(path, user_id)
|
|
631
|
+
if not ok:
|
|
632
|
+
return f"❌ **目录不允许** ❌\n\n{result}"
|
|
633
|
+
resolved = result
|
|
634
|
+
prefs = load_prefs(user_id)
|
|
635
|
+
dirs = prefs.get("add_dirs", [])
|
|
636
|
+
if resolved not in dirs:
|
|
637
|
+
dirs.append(resolved)
|
|
638
|
+
prefs["add_dirs"] = dirs
|
|
639
|
+
save_prefs(user_id, prefs)
|
|
640
|
+
return (
|
|
641
|
+
f"✅ **已记录工作目录** ✅\n\n```\n{resolved}\n```\n\n"
|
|
642
|
+
"ℹ️ grok 后端暂不支持通过命令行传递额外目录。"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if cmd == "/agents":
|
|
646
|
+
output = await _run_grok_subcommand(["agent"], user_id)
|
|
647
|
+
return output
|
|
648
|
+
|
|
649
|
+
if cmd == "/models":
|
|
650
|
+
return await _run_grok_subcommand(["models"], user_id)
|
|
651
|
+
|
|
652
|
+
if cmd == "/persona":
|
|
653
|
+
return handle_grok_persona(args, user_id)
|
|
654
|
+
|
|
655
|
+
# --- MCP & Subagent ---
|
|
656
|
+
if cmd == "/mcp":
|
|
657
|
+
if not config.enable_mcp:
|
|
658
|
+
return "ℹ️ **该功能已禁用** ℹ️"
|
|
659
|
+
return (
|
|
660
|
+
"ℹ️ **MCP 工具使用引导** ℹ️\n\n"
|
|
661
|
+
"grok 已配置 MCP server。\n\n"
|
|
662
|
+
"使用方法:用自然语言描述调用,格式为:\n"
|
|
663
|
+
"> 用 `<工具名>` 调用,参数 `<json>`\n\n"
|
|
664
|
+
"示例:\n"
|
|
665
|
+
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if cmd == "/agent":
|
|
669
|
+
if not config.enable_subagent:
|
|
670
|
+
return "ℹ️ **该功能已禁用** ℹ️"
|
|
671
|
+
if not args:
|
|
672
|
+
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
673
|
+
agent_parts = args.split(maxsplit=1)
|
|
674
|
+
agent_name = agent_parts[0]
|
|
675
|
+
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
676
|
+
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
677
|
+
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
678
|
+
result_text, _ = await run_grok(crafted, user_id)
|
|
679
|
+
return result_text
|
|
680
|
+
|
|
681
|
+
# --- D class: passthrough to grok (return None so caller runs run_grok) ---
|
|
682
|
+
return None
|