Add offline analysis and client event tracking to Assistent API
- Introduced `/assistent/analyze-reply` endpoint for offline extraction of patches and client predictions without VRAM. - Added `/assistent/client-event` and `/assistent/client-events` endpoints for tracking UI interactions and retrieving event history. - Updated Debug API documentation to reflect new endpoints and their functionalities. - Enhanced Assistent session handling with improved client prediction logic and diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+146
-12
@@ -65,25 +65,28 @@ DATA = Path("/mnt/swarm_data")
|
||||
OPT = Path("/opt/swarmui/src/Extensions")
|
||||
roots = [DATA / "Extensions", OPT]
|
||||
found = []
|
||||
dll_seen = set()
|
||||
for search in (DATA, Path("/opt/swarmui")):
|
||||
if not search.is_dir():
|
||||
continue
|
||||
for dll in search.rglob("SwarmAssistentExtension.dll"):
|
||||
if any(p in {"obj", "node_modules"} for p in dll.parts):
|
||||
continue
|
||||
dll_seen.add(dll)
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for p in sorted(root.iterdir()):
|
||||
if "assistent" not in p.name.lower():
|
||||
continue
|
||||
# Prefer TFM output dirs (Debug/Release net*), then any bin/** fallback
|
||||
dlls = []
|
||||
dlls = [d for d in dll_seen if p in d.parents or d.parent == p]
|
||||
# Prefer TFM output dirs (Debug/Release net*), then any match
|
||||
ranked = []
|
||||
for cfg_name in ("Debug", "Release"):
|
||||
dlls.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll")))
|
||||
if not dlls:
|
||||
dlls = sorted(p.glob("bin/**/SwarmAssistentExtension.dll"))
|
||||
if not dlls:
|
||||
# Older layout / alternate assembly name
|
||||
for cfg_name in ("Debug", "Release"):
|
||||
dlls.extend(sorted(p.glob(f"bin/{cfg_name}/net*/*Assistent*.dll")))
|
||||
if not dlls:
|
||||
dlls = sorted(p.glob("bin/**/*Assistent*.dll"))
|
||||
dll = dlls[0] if dlls else None
|
||||
ranked.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll")))
|
||||
if not ranked:
|
||||
ranked = sorted(dlls) or sorted(p.glob("bin/**/SwarmAssistentExtension.dll"))
|
||||
dll = ranked[0] if ranked else None
|
||||
dll_dir = dll.parent if dll else None
|
||||
sqlite_dll = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None
|
||||
csproj = next(p.glob("*.csproj"), None)
|
||||
@@ -356,6 +359,137 @@ def _generate_flag_on(obj: dict[str, Any] | None) -> bool:
|
||||
return isinstance(acts, list) and "generate" in [str(a) for a in acts]
|
||||
|
||||
|
||||
# Mirrors swarm-assistent src/intent.js (0.15.2). Keep in lockstep.
|
||||
_CYR_BOUND = r"(^|[^0-9A-Za-z_А-Яа-яЁё])"
|
||||
_CYR_END = r"(?=$|[^0-9A-Za-z_А-Яа-яЁё])"
|
||||
_PERSONA_CHIP = {
|
||||
"neutral": "Нормальный",
|
||||
"aggressive": "Агрессивный",
|
||||
"dreamer": "Мечтатель",
|
||||
}
|
||||
|
||||
|
||||
def _cyr_token_re(alts: str) -> re.Pattern[str]:
|
||||
return re.compile(f"{_CYR_BOUND}(?:{alts}){_CYR_END}", re.IGNORECASE)
|
||||
|
||||
|
||||
def user_asks_no_generate(text: str | None) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
if re.search(
|
||||
r"\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|"
|
||||
r"don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b",
|
||||
t,
|
||||
re.I,
|
||||
):
|
||||
return True
|
||||
return bool(
|
||||
_cyr_token_re(
|
||||
r"запомн|запомни|запомним|сохрани|сохраним|шаблон|"
|
||||
r"базов(ый|ого|ому|ым|ая|ую|ое)?\s+промпт|"
|
||||
r"не\s+генерир[а-яё]*|без\s+генерац[а-яё]*|не\s+надо\s+генер[а-яё]*|"
|
||||
r"только\s+запомн[а-яё]*|пока\s+запомн[а-яё]*|"
|
||||
r"не\s+рисуй|не\s+запускай\s+генер[а-яё]*|"
|
||||
r"только\s+(ответь|скажи|объясни)"
|
||||
).search(t)
|
||||
)
|
||||
|
||||
|
||||
def user_asks_generate(text: str | None) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t or user_asks_no_generate(t):
|
||||
return False
|
||||
if re.search(
|
||||
r"\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b",
|
||||
t,
|
||||
re.I,
|
||||
):
|
||||
return True
|
||||
return bool(
|
||||
_cyr_token_re(
|
||||
r"сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|"
|
||||
r"запусти\s+генер[а-яё]*|"
|
||||
r"сдела(й|ем|йте)\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)"
|
||||
).search(t)
|
||||
)
|
||||
|
||||
|
||||
def predict_client_turn(
|
||||
message: str | None,
|
||||
patch: dict[str, Any] | None,
|
||||
*,
|
||||
persona: str | None = None,
|
||||
pack: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""What swarm-assistent 0.15.3 JS would do after this HTTP reply.
|
||||
|
||||
Generate is user-owned: model generate:true is advisory. AssistentChat
|
||||
never starts Swarm Generate.
|
||||
"""
|
||||
model_generate = _generate_flag_on(patch)
|
||||
vetoed = user_asks_no_generate(message)
|
||||
user_gen = user_asks_generate(message)
|
||||
has_prompt = bool(str((patch or {}).get("prompt") or "").strip())
|
||||
user_asked = user_gen and (model_generate or has_prompt)
|
||||
would_generate = bool(not vetoed and user_asked)
|
||||
if vetoed:
|
||||
reason = "vetoed"
|
||||
elif user_asked:
|
||||
reason = "user_phrase"
|
||||
elif model_generate:
|
||||
reason = "model_flag_ignored"
|
||||
else:
|
||||
reason = "none"
|
||||
pid = (persona or "").strip() or None
|
||||
toast = None
|
||||
if would_generate:
|
||||
toast = "Промпт обновлён · Generate" if has_prompt else "Запускаю Generate"
|
||||
elif patch and has_prompt:
|
||||
toast = None
|
||||
return {
|
||||
"user_asks_generate": user_gen,
|
||||
"user_asks_no_generate": vetoed,
|
||||
"model_generate": model_generate,
|
||||
"would_apply_patch": bool(would_generate and patch),
|
||||
"would_generate": would_generate,
|
||||
"generate_reason": reason,
|
||||
"http_starts_generate": False,
|
||||
"toast": toast,
|
||||
"persona": pid,
|
||||
"persona_chip": _PERSONA_CHIP.get(pid or "", pid) if pid else None,
|
||||
"pack": (pack or "").strip() or None,
|
||||
"note": (
|
||||
"0.15.3: HTTP AssistentChat never runs Generate. "
|
||||
"would_generate mirrors resolveTurnIntent — user «нарисуй»/"
|
||||
"«сгенерируй»/«сделаем изображение», not model generate:true alone."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def analyze_assistent_reply(
|
||||
*,
|
||||
message: str | None = None,
|
||||
reply: str | None = None,
|
||||
persona: str | None = None,
|
||||
pack: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Offline extract + 0.15.2 client prediction. No Swarm / VRAM."""
|
||||
extracted = extract_assistent_patch(reply)
|
||||
patch = extracted.get("patch")
|
||||
client = predict_client_turn(message, patch, persona=persona, pack=pack)
|
||||
return {
|
||||
"ok": True,
|
||||
"offline": True,
|
||||
"message": message or "",
|
||||
"reply": reply or "",
|
||||
"reply_prose": extracted.get("prose") or "",
|
||||
"patch": _summarize_patch(patch),
|
||||
"client": client,
|
||||
"note": "No AssistentChat — paste a live UI reply to see what 0.15.2 would apply/Generate.",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_extracted_patch(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
patch = dict(obj)
|
||||
if _generate_flag_on(patch):
|
||||
|
||||
Reference in New Issue
Block a user