724 lines
30 KiB
Python
724 lines
30 KiB
Python
"""
|
||
Chat Bridge — direct HTTP API calls with model fallback + session persistence.
|
||
|
||
Messages are dual-written:
|
||
- `.bridge_context.jsonl` for immediate context injection
|
||
- `opencode.db` (serve session) so session_search works for old messages
|
||
|
||
Context window: last 200 messages from session (hard limit, no compression).
|
||
Beyond 200: use session_search (##list_sessions## / ##switch_session##).
|
||
"""
|
||
|
||
import os, json, time, logging, sqlite3
|
||
from datetime import datetime, timezone, timedelta
|
||
from session_router import extract_session_context
|
||
os.environ["no_proxy"] = "*"
|
||
os.environ["NO_PROXY"] = "*"
|
||
import requests
|
||
_TZ = timezone(timedelta(hours=8))
|
||
|
||
# ── Logging ──
|
||
_LOG_FILE = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"logs", "bridge.log")
|
||
os.makedirs(os.path.dirname(_LOG_FILE), exist_ok=True)
|
||
_logger = logging.getLogger("chat_bridge")
|
||
_handler = logging.FileHandler(_LOG_FILE, encoding="utf-8")
|
||
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||
_logger.addHandler(_handler)
|
||
_logger.setLevel(logging.INFO)
|
||
|
||
# ── Provider configs from opencode config.json ──
|
||
_CONFIG_PATH = os.path.join(os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
|
||
".config", "opencode", "config.json")
|
||
|
||
|
||
def _load_providers() -> dict:
|
||
try:
|
||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
return json.load(f).get("provider", {})
|
||
except Exception as e:
|
||
_logger.error("Failed to load provider config: %s", e)
|
||
return {}
|
||
|
||
|
||
_PROVIDERS = _load_providers()
|
||
|
||
# ── Build provider chain dynamically from config ──
|
||
# Don't hardcode which providers have quota — try everything configured.
|
||
# Each provider's model name comes from its config (options.model),
|
||
# falling back to a sensible default based on provider key.
|
||
_DEFAULT_MODELS = {
|
||
"volcengine": "deepseek-v4-flash",
|
||
"opencode-go": "deepseek-v4-flash",
|
||
"opencode-go-new": "deepseek-v4-flash",
|
||
"deepseek": "deepseek-v4-flash",
|
||
"sense-nova": "nova-4",
|
||
}
|
||
|
||
|
||
def _build_chain() -> list[tuple[str, str, str]]:
|
||
"""Build (provider_key, base_url, model_name) in priority order.
|
||
|
||
优先用 volcengine(额度/免费)→ opencode-go-new(订阅)→ opencode-go(备用)。
|
||
deepseek(直连)作为最后兜底,额度不够时启用。
|
||
"""
|
||
allowed = ["volcengine", "opencode-go", "opencode-go-new"]
|
||
chain = []
|
||
for key in allowed:
|
||
prov = _PROVIDERS.get(key)
|
||
if not prov:
|
||
continue
|
||
opts = prov.get("options", {})
|
||
base = opts.get("baseURL", "")
|
||
api_key = opts.get("apiKey", "")
|
||
if not base or not api_key:
|
||
continue
|
||
model = opts.get("model") or _DEFAULT_MODELS.get(key, "deepseek-v4-flash")
|
||
chain.append((key, base, model))
|
||
return chain
|
||
|
||
|
||
DEFAULT_TIMEOUT = 60 # per model, in seconds
|
||
LOCK_DURATION = 300 # reuse good provider for 5 min
|
||
FAILED_BACKOFF = 1800 # skip failed provider for 30 min
|
||
|
||
_last_good_provider: str | None = None
|
||
_last_good_time: float = 0.0
|
||
_failed_providers: dict[str, float] = {}
|
||
|
||
_CACHE_FILE = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"temp", ".model_cache.json")
|
||
|
||
|
||
def _load_cache():
|
||
global _last_good_provider, _last_good_time
|
||
try:
|
||
with open(_CACHE_FILE, "r") as f:
|
||
d = json.load(f)
|
||
_last_good_provider = d.get("provider")
|
||
_last_good_time = d.get("time", 0.0)
|
||
except (FileNotFoundError, json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
|
||
def _save_cache():
|
||
d = {"provider": _last_good_provider, "time": _last_good_time}
|
||
try:
|
||
os.makedirs(os.path.dirname(_CACHE_FILE), exist_ok=True)
|
||
with open(_CACHE_FILE, "w") as f:
|
||
json.dump(d, f)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _cache_model(provider_key: str):
|
||
global _last_good_provider, _last_good_time
|
||
_last_good_provider = provider_key
|
||
_last_good_time = time.time()
|
||
_save_cache()
|
||
|
||
|
||
def _get_providers_to_try() -> list[tuple[str, str, str]]:
|
||
"""
|
||
Returns [(provider_key, base_url, model_name), ...] to try.
|
||
"""
|
||
global _last_good_provider, _last_good_time, _failed_providers
|
||
now = time.time()
|
||
_failed_providers = {p: t for p, t in _failed_providers.items() if now < t}
|
||
|
||
chain = _build_chain()
|
||
|
||
# Lock active — reuse last good provider
|
||
if _last_good_provider and (now - _last_good_time) < LOCK_DURATION:
|
||
for key, base, model in chain:
|
||
if key == _last_good_provider:
|
||
return [(key, base, model)]
|
||
|
||
# Build available list
|
||
available = []
|
||
for key, base, model in chain:
|
||
if key in _failed_providers:
|
||
continue
|
||
available.append((key, base, model))
|
||
|
||
if not available and _last_good_provider:
|
||
for key, base, model in chain:
|
||
if key == _last_good_provider:
|
||
available.append((key, base, model))
|
||
break
|
||
|
||
return available
|
||
|
||
|
||
_load_cache()
|
||
|
||
|
||
# ── Tool definitions (function calling) ──
|
||
_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "run_command",
|
||
"description": "执行一条 shell 命令。可以 SSH 到远程服务器(如 root@47.115.32.206)。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"command": {
|
||
"type": "string",
|
||
"description": "要执行的 shell 命令"
|
||
}
|
||
},
|
||
"required": ["command"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "session_search",
|
||
"description": "搜索其他 session 的历史对话内容。默认查当前 session(xxm 自己的 session),也可查指定 session(如 TUI 工作台 ses_1d95d15c4ffehQaZ6hrbIbak5k)。返回最近 N 条消息(带时间戳和来源标记),按时间正序排列。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"session_id": {
|
||
"type": "string",
|
||
"description": "要查询的 session ID。不传或传空字符串则查 TUI session"
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "返回最近多少条消息,默认 20,最大 100"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_sessions",
|
||
"description": "跨所有 session 搜索指定关键词,自动定位最相关的 session。不知道应该查哪个 session 时用这个。返回匹配到的 session 标题、匹配条数和消息摘要片段。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "搜索关键词,如项目名、文件名、话题等"
|
||
},
|
||
"max_sessions": {
|
||
"type": "integer",
|
||
"description": "最多搜几个 session,默认 5"
|
||
}
|
||
},
|
||
"required": ["query"]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
_MAX_TOOL_LOOPS = 30 # 超限后走 clean final force,不再泄漏 XML
|
||
|
||
|
||
def _run_tool_command(cmd: str) -> str:
|
||
"""Execute a shell command and return output."""
|
||
import subprocess as _sp
|
||
try:
|
||
r = _sp.run(cmd, shell=True, capture_output=True, timeout=60,
|
||
text=True, encoding='utf-8', errors='replace')
|
||
out = (r.stdout or "") + (r.stderr or "")
|
||
return out.strip() or f"(exit code {r.returncode}, no output)"
|
||
except _sp.TimeoutExpired:
|
||
return "(命令超时)"
|
||
except Exception as e:
|
||
return f"(执行失败: {e})"
|
||
|
||
|
||
def _search_all_sessions(query: str, max_sessions: int = 5) -> str:
|
||
"""Search ALL recent sessions for a query. Returns formatted results."""
|
||
import sqlite3, json
|
||
db = _SERVE_DB
|
||
if not os.path.exists(db):
|
||
return f"(session 数据库不存在: {db})"
|
||
try:
|
||
conn = sqlite3.connect(db)
|
||
conn.row_factory = sqlite3.Row
|
||
# Get recent sessions
|
||
sessions = conn.execute(
|
||
"SELECT id, title, time_updated FROM session ORDER BY time_updated DESC LIMIT ?",
|
||
(max_sessions * 3,),
|
||
).fetchall()
|
||
results = []
|
||
for s in sessions:
|
||
sid = s["id"]
|
||
title = s["title"] or "(无标题)"
|
||
# Search messages in this session
|
||
msgs = conn.execute(
|
||
f"""SELECT m.id, m.data FROM message m
|
||
WHERE m.session_id=? ORDER BY m.time_created DESC LIMIT 50""",
|
||
(sid,),
|
||
).fetchall()
|
||
matches = []
|
||
for m in msgs:
|
||
try:
|
||
d = json.loads(m["data"])
|
||
content = d.get("content", "")
|
||
if query.lower() in content.lower():
|
||
matches.append(content[:200])
|
||
except (json.JSONDecodeError, ValueError):
|
||
continue
|
||
if len(matches) >= 3:
|
||
break
|
||
if matches:
|
||
results.append(f"[{title}]({sid[:16]}...): {len(matches)}条匹配\n" + "\n".join(f" · {m}" for m in matches))
|
||
conn.close()
|
||
if not results:
|
||
return f"(搜索 \"{query}\" 未在任何 session 中找到匹配)"
|
||
return "搜索到相关 session:\n\n" + "\n\n".join(results)
|
||
except Exception as e:
|
||
return f"(搜索出错: {e})"
|
||
|
||
|
||
# ── Serve session DB path ──
|
||
_SERVE_DB = os.path.join(
|
||
os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
|
||
".local", "share", "opencode", "opencode.db")
|
||
|
||
|
||
class SessionBridge:
|
||
"""
|
||
Send message to LLM via direct HTTP API call.
|
||
Injects recent conversation context for continuity.
|
||
|
||
Context comes from the serve session (opencode.db, last 200 msgs),
|
||
with .bridge_context.jsonl as fallback.
|
||
Messages are written back to the session so session_search works.
|
||
"""
|
||
|
||
def __init__(self, session_id: str = "", serve_url: str = "",
|
||
temp_dir: str = "", timeout: int = DEFAULT_TIMEOUT):
|
||
self.session_id = session_id
|
||
self.timeout = timeout
|
||
self.temp_dir = temp_dir or os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
|
||
os.makedirs(self.temp_dir, exist_ok=True)
|
||
|
||
# ── Conversation log (fallback / debug) ──
|
||
self._ctx_log = os.path.join(self.temp_dir, ".bridge_context.jsonl")
|
||
self._ctx_max = 200
|
||
|
||
# ── Context management ─────────────────────────────────
|
||
|
||
def _read_recent_context(self) -> str:
|
||
"""Read last N exchanges, newest → oldest (top = most recent).
|
||
|
||
Priority:
|
||
1. Session (opencode.db) via extract_session_context() — has timestamps
|
||
2. Fallback: .bridge_context.jsonl — also with timestamps
|
||
|
||
Each line is prefixed with [MM-DD HH:MM] so LLM can judge recency.
|
||
"""
|
||
# Priority 1: session
|
||
if self.session_id:
|
||
try:
|
||
from session_router import extract_session_context
|
||
ctx = extract_session_context(self.session_id, limit=self._ctx_max)
|
||
if ctx:
|
||
return ctx
|
||
except Exception:
|
||
pass
|
||
|
||
# Priority 2: .bridge_context.jsonl fallback
|
||
try:
|
||
if not os.path.exists(self._ctx_log):
|
||
return ""
|
||
with open(self._ctx_log, "r", encoding="utf-8") as f:
|
||
raw = f.readlines()
|
||
recent = raw[-self._ctx_max:]
|
||
parts = []
|
||
for line in recent:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
ts = entry.get("ts", 0)
|
||
role = entry.get("role", "?")
|
||
content = str(entry.get("content", ""))
|
||
source = entry.get("source", "bridge")
|
||
role_label = "用户" if role == "user" else "小小莫"
|
||
src_tag = {"xmpp": "[群聊] ", "vc": "[VC] ", "tui": "[TUI] ", "bridge": "[桥接] "}.get(source, f"[{source}] ")
|
||
ts_str = ""
|
||
if ts:
|
||
ts_str = datetime.fromtimestamp(ts, tz=_TZ).strftime("%m-%d %H:%M")
|
||
line_str = f"{ts_str} {src_tag}{role_label}: {content}" if ts_str else f"{src_tag}{role_label}: {content}"
|
||
parts.append(line_str)
|
||
except (json.JSONDecodeError, ValueError):
|
||
continue
|
||
return "\n".join(parts)
|
||
except Exception:
|
||
return ""
|
||
|
||
def _append_to_log(self, role: str, content: str, source: str = "bridge"):
|
||
try:
|
||
entry = json.dumps(
|
||
{"ts": int(time.time()), "role": role, "content": content, "source": source},
|
||
ensure_ascii=False)
|
||
with open(self._ctx_log, "a", encoding="utf-8") as f:
|
||
f.write(entry + "\n")
|
||
self._trim_log()
|
||
except Exception:
|
||
pass
|
||
|
||
def _trim_log(self):
|
||
try:
|
||
with open(self._ctx_log, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
if len(lines) > self._ctx_max * 2:
|
||
with open(self._ctx_log, "w", encoding="utf-8") as f:
|
||
f.writelines(lines[-self._ctx_max:])
|
||
except Exception:
|
||
pass
|
||
|
||
def _append_to_session(self, role: str, content: str, source: str = "bridge",
|
||
model_info: dict | None = None):
|
||
"""Write a message to the serve session (opencode.db).
|
||
|
||
Fields match the opencode session message schema (v1.17+):
|
||
- mode: 'user' (user) / 'Sisyphus - Ultraworker' (assistant)
|
||
- tokens / cost: always present so UI doesn't crash on null
|
||
- model: only for assistant messages
|
||
- finish: 'stop' for assistant messages
|
||
|
||
source distinguishes bridge-injected (xmpp/vc) vs native TUI messages.
|
||
"""
|
||
import uuid as _uuid
|
||
|
||
if not self.session_id:
|
||
return
|
||
try:
|
||
now_ms = int(time.time() * 1000)
|
||
msg_id = "msg_" + _uuid.uuid4().hex[:24]
|
||
part_id = "prt_" + _uuid.uuid4().hex[:24]
|
||
default_tokens = {"input": 0, "output": 0}
|
||
default_cost = {"input": 0, "output": 0}
|
||
if role == "user":
|
||
data = {
|
||
"role": "user", "source": source, "mode": "user",
|
||
"tokens": default_tokens, "cost": default_cost,
|
||
}
|
||
else:
|
||
data = {
|
||
"role": "assistant", "source": source, "mode": "Sisyphus - Ultraworker",
|
||
"tokens": default_tokens, "cost": default_cost,
|
||
"finish": "stop",
|
||
}
|
||
if model_info:
|
||
data["model"] = model_info
|
||
msg_data = json.dumps(data, ensure_ascii=False)
|
||
part_data = json.dumps({"type": "text", "text": content}, ensure_ascii=False)
|
||
|
||
conn = sqlite3.connect(_SERVE_DB)
|
||
conn.execute(
|
||
"INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||
(msg_id, self.session_id, msg_data, now_ms, now_ms),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)",
|
||
(part_id, msg_id, self.session_id, part_data, now_ms, now_ms),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
_logger.debug("append_to_session: %s → session %s (%d chars)",
|
||
role, self.session_id[:20], len(content))
|
||
except Exception as e:
|
||
_logger.warning("append_to_session failed: %s", e)
|
||
|
||
# ── Direct API call ──────────────────────────────────
|
||
|
||
def _call_api(self, provider_key: str, base_url: str, model: str,
|
||
messages: list, timeout: int) -> str | None:
|
||
"""
|
||
Send messages to LLM via function calling API (using `requests`).
|
||
Handles tool_calls loop internally.
|
||
Returns final text response after all tool calls resolved.
|
||
Timeout: connect=10s, read=timeout (ensures no infinite hang)
|
||
"""
|
||
prov = _PROVIDERS.get(provider_key)
|
||
if not prov:
|
||
_logger.error("Provider %s not found in config", provider_key)
|
||
return None
|
||
api_key = prov.get("options", {}).get("apiKey", "")
|
||
|
||
session = requests.Session()
|
||
session.headers.update({
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
})
|
||
# Bypass system proxy (v2rayN :15000) — proxy causes permanent hang with volcengine API
|
||
session.trust_env = False
|
||
session.proxies = {"http": None, "https": None}
|
||
|
||
for loop in range(_MAX_TOOL_LOOPS):
|
||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"tools": _TOOLS,
|
||
}
|
||
|
||
t0 = time.time()
|
||
try:
|
||
resp = session.post(url, json=payload, timeout=(10, timeout))
|
||
resp.raise_for_status()
|
||
body = resp.json()
|
||
except requests.exceptions.Timeout:
|
||
_logger.warning("API %s/%s timeout (%ds) at loop %d",
|
||
provider_key, model, timeout, loop)
|
||
return None
|
||
except requests.exceptions.HTTPError as e:
|
||
err_body = ""
|
||
try:
|
||
err_body = e.response.text[:300]
|
||
except Exception:
|
||
pass
|
||
code = e.response.status_code if e.response is not None else 0
|
||
_logger.warning("API %s/%s HTTP %d: %s",
|
||
provider_key, model, code, err_body)
|
||
return None
|
||
except requests.exceptions.RequestException as e:
|
||
_logger.warning("API %s/%s request failed: %s",
|
||
provider_key, model, e)
|
||
return None
|
||
|
||
msg = body.get("choices", [{}])[0].get("message", {})
|
||
content = msg.get("content", "")
|
||
tool_calls = msg.get("tool_calls")
|
||
|
||
# No tool calls → final answer
|
||
if not tool_calls:
|
||
if content and content.strip():
|
||
elapsed = time.time() - t0
|
||
_logger.info("API %s/%s OK (%.1fs, loop %d)",
|
||
provider_key, model, elapsed, loop)
|
||
return content.strip()
|
||
# Empty content with no tool calls → something wrong
|
||
if loop == 0:
|
||
return None
|
||
return ""
|
||
|
||
# Has tool calls → execute them
|
||
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
|
||
for tc in tool_calls:
|
||
if tc.get("type") != "function":
|
||
continue
|
||
fn = tc.get("function", {})
|
||
fn_name = fn.get("name", "")
|
||
fn_args_str = fn.get("arguments", "{}")
|
||
tool_call_id = tc.get("id", "")
|
||
|
||
if fn_name == "run_command":
|
||
try:
|
||
fn_args = json.loads(fn_args_str)
|
||
cmd = fn_args.get("command", "")
|
||
except (json.JSONDecodeError, ValueError):
|
||
cmd = ""
|
||
output = _run_tool_command(cmd) if cmd else "(no command)"
|
||
_logger.info(" tool: run_command → %s (%d chars)", cmd[:80], len(output))
|
||
elif fn_name == "session_search":
|
||
try:
|
||
fn_args = json.loads(fn_args_str)
|
||
sid = fn_args.get("session_id", "") or self.session_id
|
||
limit = min(int(fn_args.get("limit", 20)), 100)
|
||
except (json.JSONDecodeError, ValueError, TypeError):
|
||
sid = self.session_id
|
||
limit = 20
|
||
ctx = extract_session_context(sid, limit=limit)
|
||
output = ctx if ctx else f"(session {sid}: no messages)"
|
||
_logger.info(" tool: session_search → %s (%d chars)", sid[:32], len(output))
|
||
|
||
elif fn_name == "search_sessions":
|
||
try:
|
||
fn_args = json.loads(fn_args_str)
|
||
query = fn_args.get("query", "")
|
||
max_sessions = min(int(fn_args.get("max_sessions", 5)), 20)
|
||
except (json.JSONDecodeError, ValueError, TypeError):
|
||
query = ""
|
||
max_sessions = 5
|
||
output = _search_all_sessions(query, max_sessions)
|
||
_logger.info(" tool: search_sessions query=%s (%d chars)", query[:40], len(output))
|
||
else:
|
||
output = f"(unknown tool: {fn_name})"
|
||
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call_id,
|
||
"content": output[:2000], # trim to avoid context overflow
|
||
})
|
||
|
||
_logger.warning("API %s/%s: max tool loops (%d) reached, forcing final answer",
|
||
provider_key, model, _MAX_TOOL_LOOPS)
|
||
# 循环上限到了,重发一次不带工具的 API。
|
||
# 关键:滤掉所有工具调用脏记录,只留干净的 system + user 节,否则 LLM
|
||
# 看到上下文里的 tool_calls 格式会跟着输出 XML 到群里。
|
||
try:
|
||
clean_msgs = [m for m in messages
|
||
if not m.get("tool_calls") and m.get("role") != "tool"]
|
||
final_url = f"{base_url.rstrip('/')}/chat/completions"
|
||
final_payload = {
|
||
"model": model,
|
||
"messages": clean_msgs,
|
||
}
|
||
final_resp = session.post(final_url, json=final_payload, timeout=(10, timeout))
|
||
final_resp.raise_for_status()
|
||
final_body = final_resp.json()
|
||
final_msg = final_body.get("choices", [{}])[0].get("message", {})
|
||
final_content = final_msg.get("content", "")
|
||
if final_content and final_content.strip():
|
||
_logger.info("API %s/%s final force OK (clean, %d msgs)",
|
||
provider_key, model, len(clean_msgs))
|
||
return final_content.strip()
|
||
except Exception as e:
|
||
_logger.warning("API %s/%s final force failed: %s", provider_key, model, e)
|
||
return None
|
||
|
||
# ── Clean message extraction ──────────────────────────
|
||
|
||
@staticmethod
|
||
def _extract_user_message(full_prompt: str) -> str:
|
||
"""Extract the actual user message from the SessionRouter's full prompt.
|
||
|
||
SessionRouter 的 prompt 格式:
|
||
[session: xxx]
|
||
|
||
[可用命令] ...
|
||
---
|
||
[群聊/coregroup] hmo: actual message
|
||
|
||
我们只存 "---" 后面的部分,不存 session 上下文。
|
||
"""
|
||
idx = full_prompt.rfind("\n---\n")
|
||
if idx >= 0:
|
||
rest = full_prompt[idx + 5:].strip()
|
||
if rest:
|
||
return rest
|
||
# Fallback: just use last 200 chars
|
||
return full_prompt[-200:].strip()
|
||
|
||
# ── Public API ───────────────────────────────────────
|
||
|
||
def send_raw(self, message: str) -> str | None:
|
||
"""Send message to LLM via function calling API."""
|
||
providers = _get_providers_to_try()
|
||
if not providers:
|
||
_logger.error("No available providers")
|
||
return None
|
||
|
||
_logger.info("send_raw: trying %d provider(s): %s",
|
||
len(providers), [(k, m) for k, _, m in providers])
|
||
|
||
# Build system prompt
|
||
sys_prompt = (
|
||
"你是 xxm(小小莫),老莫的 AI 助手。\n"
|
||
"你不是 Sisyphus,不是莫荷,不是莫小果。你是 xxm。\n"
|
||
"老莫让你做事就做,不要推脱,不要反复确认。\n"
|
||
"回复简洁,不用 emoji。\n"
|
||
"\n"
|
||
"=== 言出必行(重要)===\n"
|
||
"你可以使用 run_command 工具执行 shell 命令。\n"
|
||
"你说「我去做某事」时,必须在同一条回复中使用 run_command 真正执行,\n"
|
||
"否则被视为空话。只说不做比不说更糟糕。\n"
|
||
"不确定该怎么做时,先 run_command 查一下再决定。\n"
|
||
"\n"
|
||
"=== 回答问题前先搜 session ===\n"
|
||
"当被问到项目状态、代码位置、近期工作、其他人说过什么等事实性问题时,\n"
|
||
"**必须先使用 search_sessions 或 session_search 找到证据再回答**。\n"
|
||
"不要凭你当前 session 里的记忆回答——你的 session 可能不是最相关的。\n"
|
||
"正确流程:search_sessions(关键词) → 找到相关 session → session_search(具体 session) → 再回答\n"
|
||
"\n"
|
||
"=== 写文件的正确方式 ===\n"
|
||
"用 Python 一次性写完所有内容,不要分多次调用。\n"
|
||
"错误示例(会覆盖,每调用一次就清空一次):python -c \"open('file', 'w').write('一行')\"\n"
|
||
"正确做法:把全部内容拼在一个 python -c 调用里写完。\n"
|
||
"\n"
|
||
"=== 上下文说明 ===\n"
|
||
"下面是最近 200 条对话历史,按时间正序排列(最上面是最旧的消息,最下面是最新的消息)。\n"
|
||
"每条消息前有 [MM-DD HH:MM] 时间戳,以及来源标记:\n"
|
||
" · [TUI] = 你和我(老莫)在 AI 工作台里的对话\n"
|
||
" · [群聊] = 微信群聊天记录\n"
|
||
"你可以根据时间判断消息的新旧程度。\n"
|
||
"凡是时间较早的消息(比如 30 分钟前、1 小时前),说明已经是过去的话题,\n"
|
||
"不要把它们当作当前正在发生的事情来讨论。重点关注最后几条消息,那才是最当前的。\n"
|
||
"超过 200 条的旧对话不在当前上下文中。\n"
|
||
"如果你需要查其他 session 里的内容(比如 TUI 工作台里老莫讨论过的方案),\n"
|
||
"可以用 session_search 工具搜索指定 session 的历史消息。\n"
|
||
"\n"
|
||
"=== 群聊沉默协议 ===\n"
|
||
"群里的消息你都会看到。判断是否回应:\n"
|
||
" · 老莫 @你 / 点名你 / 催你 → 正常回复\n"
|
||
" · 别人(小荷/小果/其他人)的对话跟你无关 → 保持沉默\n"
|
||
" · 有人问问题且你能帮上忙 → 可以主动回复\n"
|
||
"\n"
|
||
"=== 核心规则:避免确认循环 ===\n"
|
||
"群聊中最烦人的就是确认循环。以下行为绝对禁止:\n"
|
||
" · 别人说「收到」后你再说「收到」— 禁止,这叫 echo\n"
|
||
" · 别人确认了某个信息后你再确认一遍 — 禁止,这叫二次确认\n"
|
||
" · 帮别人总结/复述他们刚说完的内容 — 禁止,这叫抢话\n"
|
||
" · 反复声明「我没活等任务」或问「有活吗」— 有活自然会说\n"
|
||
" · 对别人的确认信息再做确认 — 禁止,无意义\n"
|
||
" · 老莫骂人了还在回复 — 禁止,闭嘴等老莫说下一句\n"
|
||
"\n"
|
||
"正确做法:\n"
|
||
" · 有实质内容就说,没有就闭嘴\n"
|
||
" · 每条消息只确认一件事一次,不回头\n"
|
||
" · 看到老莫生气了直接闭嘴,不要解释不要认错不要保证\n"
|
||
" · 不确定该不该说话 → __SILENT__\n"
|
||
"\n"
|
||
"保持沉默:回复开头写 __SILENT__。系统检测到就不会发出去。\n"
|
||
"想沉默 → __SILENT__ 开头。想说话 → 直接写回复。"
|
||
)
|
||
recent_ctx = self._read_recent_context()
|
||
if recent_ctx:
|
||
sys_prompt += f"\n\n最近对话:\n{recent_ctx}"
|
||
|
||
# Build messages array
|
||
messages = [
|
||
{"role": "system", "content": sys_prompt},
|
||
{"role": "user", "content": message},
|
||
]
|
||
|
||
# Extract clean message for context storage
|
||
clean_msg = self._extract_user_message(message)
|
||
|
||
for key, base, model in providers:
|
||
reply = self._call_api(key, base, model, messages, self.timeout)
|
||
if reply:
|
||
_cache_model(key)
|
||
_logger.info("send_raw: success via %s/%s", key, model)
|
||
model_info = {"modelID": model, "providerID": key}
|
||
self._append_to_log("user", clean_msg, "xmpp")
|
||
self._append_to_log("assistant", reply, "xmpp")
|
||
self._append_to_session("user", clean_msg, "xmpp")
|
||
self._append_to_session("assistant", reply, "xmpp", model_info)
|
||
return reply
|
||
|
||
# All providers failed — retry once after 3s for transient failures
|
||
_logger.warning("send_raw: ALL failed, retrying once after 3s...")
|
||
time.sleep(3)
|
||
for key, base, model in providers:
|
||
reply = self._call_api(key, base, model, messages, self.timeout)
|
||
if reply:
|
||
_cache_model(key)
|
||
_logger.info("send_raw: retry OK via %s/%s", key, model)
|
||
model_info = {"modelID": model, "providerID": key}
|
||
self._append_to_log("user", clean_msg, "xmpp")
|
||
self._append_to_log("assistant", reply, "xmpp")
|
||
self._append_to_session("user", clean_msg, "xmpp")
|
||
self._append_to_session("assistant", reply, "xmpp", model_info)
|
||
return reply
|
||
|
||
_logger.error("send_raw: ALL providers failed (incl. retry)")
|
||
return None
|
||
|
||
def send(self, message: str) -> str | None:
|
||
"""Alias for send_raw."""
|
||
return self.send_raw(message) |