diff --git a/api/agent_sessions.py b/api/agent_sessions.py index fb35ffbd..15a30bf8 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -255,6 +255,14 @@ def read_importable_agent_session_rows( parent_expr = _optional_col('parent_session_id', session_cols) ended_expr = _optional_col('ended_at', session_cols) end_reason_expr = _optional_col('end_reason', session_cols) + user_id_expr = _optional_col('user_id', session_cols) + chat_id_expr = _optional_col('chat_id', session_cols) + chat_type_expr = _optional_col('chat_type', session_cols) + thread_id_expr = _optional_col('thread_id', session_cols) + session_key_expr = _optional_col('session_key', session_cols) + origin_chat_id_expr = _optional_col('origin_chat_id', session_cols) + origin_user_id_expr = _optional_col('origin_user_id', session_cols) + platform_expr = _optional_col('platform', session_cols) where_clauses = ["s.source IS NOT NULL"] params: list[str] = [] @@ -269,6 +277,14 @@ def read_importable_agent_session_rows( f""" SELECT s.id, s.title, s.model, s.message_count, s.started_at, s.source, + {user_id_expr}, + {chat_id_expr}, + {chat_type_expr}, + {thread_id_expr}, + {session_key_expr}, + {origin_chat_id_expr}, + {origin_user_id_expr}, + {platform_expr}, {parent_expr}, {ended_expr}, {end_reason_expr}, diff --git a/api/models.py b/api/models.py index 90a2ce06..50490446 100644 --- a/api/models.py +++ b/api/models.py @@ -1068,6 +1068,12 @@ def get_cli_sessions() -> list: 'profile': profile, 'source_tag': _source, 'raw_source': row.get('raw_source'), + 'user_id': row.get('user_id'), + 'chat_id': row.get('chat_id') or row.get('origin_chat_id'), + 'chat_type': row.get('chat_type'), + 'thread_id': row.get('thread_id'), + 'session_key': row.get('session_key'), + 'platform': row.get('platform'), 'session_source': row.get('session_source'), 'source_label': row.get('source_label'), 'parent_session_id': row.get('parent_session_id'), @@ -1075,6 +1081,8 @@ def get_cli_sessions() -> list: 'parent_source': row.get('parent_source'), 'relationship_type': row.get('relationship_type'), '_parent_lineage_root_id': row.get('_parent_lineage_root_id'), + 'end_reason': row.get('end_reason'), + 'actual_message_count': row.get('actual_message_count'), '_lineage_root_id': row.get('_lineage_root_id'), '_lineage_tip_id': row.get('_lineage_tip_id'), '_compression_segment_count': row.get('_compression_segment_count'), diff --git a/api/routes.py b/api/routes.py index e3d75158..8f6a88ed 100644 --- a/api/routes.py +++ b/api/routes.py @@ -15,6 +15,7 @@ import sys import threading import time import uuid +import re from pathlib import Path from urllib.parse import parse_qs from api.agent_sessions import MESSAGING_SOURCES @@ -748,7 +749,6 @@ def _resolve_effective_session_model_for_display(session) -> str: ) return effective_model or original_model - def _resolve_effective_session_model_provider_for_display(session) -> str | None: original_model = getattr(session, "model", None) or "" _model, provider, _changed = _resolve_compatible_session_model_state( @@ -1691,6 +1691,11 @@ def handle_get(handler, parsed) -> bool: _t1 = _time.monotonic() s = get_session(sid, metadata_only=(not load_messages)) _clear_stale_stream_state(s) + cli_meta = _lookup_cli_session_metadata(sid) + is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta) + cli_messages = [] + if is_messaging_session: + cli_messages = get_cli_session_messages(sid) _t2 = _time.monotonic() effective_model = ( _resolve_effective_session_model_for_display(s) @@ -1703,7 +1708,13 @@ def handle_get(handler, parsed) -> bool: else None ) _t3 = _time.monotonic() - _all_msgs = s.messages if load_messages else [] + if load_messages: + if is_messaging_session and cli_messages: + _all_msgs = cli_messages + else: + _all_msgs = s.messages + else: + _all_msgs = [] if load_messages: if msg_before is not None: # Scroll-to-top paging: msg_before is a 0-based index into @@ -1748,6 +1759,8 @@ def handle_get(handler, parsed) -> bool: "threshold_tokens": getattr(s, "threshold_tokens", 0) or 0, "last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0, } + if cli_meta and _is_messaging_session_record(cli_meta): + raw = _merge_cli_sidebar_metadata(raw, cli_meta) # Signal to the frontend that older messages were omitted. # For msg_before paging, compare against the filtered set, # not the full list — otherwise we signal truncation even when @@ -1783,13 +1796,9 @@ def handle_get(handler, parsed) -> bool: return resp except KeyError: # Not a WebUI session -- try CLI store + cli_meta = _lookup_cli_session_metadata(sid) msgs = get_cli_session_messages(sid) if msgs: - cli_meta = None - for cs in get_cli_sessions(): - if cs["session_id"] == sid: - cli_meta = cs - break sess = { "session_id": sid, "title": (cli_meta or {}).get("title", "CLI Session"), @@ -1799,15 +1808,21 @@ def handle_get(handler, parsed) -> bool: "created_at": (cli_meta or {}).get("created_at", 0), "updated_at": (cli_meta or {}).get("updated_at", 0), "last_message_at": (cli_meta or {}).get("last_message_at") - or (cli_meta or {}).get("updated_at", 0), + or (cli_meta or {}).get("updated_at", 0) + or (msgs[-1] if msgs else {"timestamp": 0}).get("timestamp", 0), "pinned": False, "archived": False, "project_id": None, "profile": (cli_meta or {}).get("profile"), "is_cli_session": True, + "source_tag": (cli_meta or {}).get("source_tag"), + "raw_source": (cli_meta or {}).get("raw_source"), + "session_source": (cli_meta or {}).get("session_source"), + "source_label": (cli_meta or {}).get("source_label"), "messages": msgs, "tool_calls": [], } + sess = _merge_cli_sidebar_metadata(sess, cli_meta) return j(handler, {"session": redact_session_data(sess)}) return bad(handler, "Session not found", 404) @@ -1852,14 +1867,17 @@ def handle_get(handler, parsed) -> bool: cli = get_cli_sessions() cli_by_id = {s["session_id"]: s for s in cli} for s in webui_sessions: - if not s.get("is_cli_session"): - continue meta = cli_by_id.get(s.get("session_id")) if not meta: continue - for key in ("source_tag", "raw_source", "session_source", "source_label"): - if not s.get(key) and meta.get(key): - s[key] = meta[key] + if _is_messaging_session_record(meta): + s.update(_merge_cli_sidebar_metadata(s, meta)) + if s.get("session_id") != meta.get("session_id"): + s["session_id"] = meta.get("session_id") + else: + for key in ("source_tag", "raw_source", "session_source", "source_label"): + if not s.get(key) and meta.get(key): + s[key] = meta[key] webui_ids = {s["session_id"] for s in webui_sessions} from api.models import _hide_from_default_sidebar as _cron_hide deduped_cli = [s for s in cli @@ -3036,26 +3054,55 @@ def handle_post(handler, parsed) -> bool: try: s = get_session(sid) except KeyError: - if not _is_messaging_session_id(sid): + cli_meta = _lookup_cli_session_metadata(sid) + if not cli_meta: return bad(handler, "Session not found", 404) - msgs = get_cli_session_messages(sid) - if not msgs: - return bad(handler, "Session not found", 404) - cli_meta = next((cs for cs in get_cli_sessions() if cs["session_id"] == sid), {}) - s = import_cli_session( - sid, - cli_meta.get("title") or title_from(msgs, "CLI Session"), - msgs, - cli_meta.get("model") or "unknown", - profile=cli_meta.get("profile"), - created_at=cli_meta.get("created_at"), - updated_at=cli_meta.get("updated_at"), - ) - s.is_cli_session = True - s.source_tag = cli_meta.get("source_tag") - s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") - s.session_source = cli_meta.get("session_source") - s.source_label = cli_meta.get("source_label") + if _is_messaging_session_record(cli_meta): + s = Session( + session_id=sid, + title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"), + workspace=get_last_workspace(), + messages=[], + model=cli_meta.get("model") or "unknown", + created_at=cli_meta.get("created_at"), + updated_at=cli_meta.get("updated_at"), + ) + s.is_cli_session = True + s.source_tag = cli_meta.get("source_tag") + s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") + s.session_source = cli_meta.get("session_source") + s.source_label = cli_meta.get("source_label") + s.user_id = cli_meta.get("user_id") + s.chat_id = cli_meta.get("chat_id") + s.chat_type = cli_meta.get("chat_type") + s.thread_id = cli_meta.get("thread_id") + s.session_key = cli_meta.get("session_key") + s.platform = cli_meta.get("platform") + s.save(touch_updated_at=False) + else: + msgs = get_cli_session_messages(sid) + if not msgs: + return bad(handler, "Session not found", 404) + s = import_cli_session( + sid, + cli_meta.get("title") or title_from(msgs, "CLI Session"), + msgs, + cli_meta.get("model") or "unknown", + profile=cli_meta.get("profile"), + created_at=cli_meta.get("created_at"), + updated_at=cli_meta.get("updated_at"), + ) + s.is_cli_session = True + s.source_tag = cli_meta.get("source_tag") + s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") + s.session_source = cli_meta.get("session_source") + s.source_label = cli_meta.get("source_label") + s.user_id = cli_meta.get("user_id") + s.chat_id = cli_meta.get("chat_id") + s.chat_type = cli_meta.get("chat_type") + s.thread_id = cli_meta.get("thread_id") + s.session_key = cli_meta.get("session_key") + s.platform = cli_meta.get("platform") with _get_session_agent_lock(sid): s.archived = bool(body.get("archived", True)) s.save(touch_updated_at=False) @@ -5579,6 +5626,203 @@ def _handle_conversation_rounds(handler, body): }) +def _build_handoff_summary_tool_message( + sid: str, + summary: str, + channel: str | None, + rounds: int | None = None, + fallback: bool = False, +) -> dict: + """Build a compact tool-role transcript marker for persistence.""" + now = time.time() + return { + "role": "tool", + # Keep this intentionally empty so API-history sanitization drops it from + # model context (it is display-only data). + "tool_call_id": "", + "name": "handoff_summary", + "timestamp": now, + "_ts": now, + "content": json.dumps({ + "_handoff_summary_card": True, + "session_id": sid, + "summary": str(summary or "").strip(), + "channel": (str(channel or "").strip() or None), + "rounds": rounds, + "fallback": bool(fallback), + "generated_at": now, + }, ensure_ascii=False), + } + + +def _extract_handoff_summary_payload(message: dict) -> dict | None: + """Return a normalized handoff-summary payload if *message* is a tool marker.""" + if not isinstance(message, dict): + return None + if message.get("role") != "tool" or message.get("name") != "handoff_summary": + return None + + content = message.get("content") + if isinstance(content, dict): + payload = content + else: + try: + payload = json.loads(content or "") + except Exception: + return None + + if not isinstance(payload, dict) or not payload.get("_handoff_summary_card"): + return None + if payload.get("session_id") is None: + return None + return { + "session_id": str(payload.get("session_id")), + "summary": str(payload.get("summary", "")), + "channel": payload.get("channel"), + "rounds": payload.get("rounds"), + "fallback": bool(payload.get("fallback")), + "_handoff_summary_card": True, + } + + +def _is_matching_handoff_summary_message(existing: dict, target: dict) -> bool: + """Return True when two message payloads represent the same handoff summary.""" + existing_payload = _extract_handoff_summary_payload(existing) + target_payload = _extract_handoff_summary_payload(target) + if not existing_payload or not target_payload: + return False + return ( + existing_payload.get("session_id") == target_payload.get("session_id") and + existing_payload.get("summary") == target_payload.get("summary") and + existing_payload.get("channel") == target_payload.get("channel") and + existing_payload.get("rounds") == target_payload.get("rounds") and + existing_payload.get("fallback") == target_payload.get("fallback") and + existing_payload.get("_handoff_summary_card") == target_payload.get("_handoff_summary_card") + ) + + +def _is_matching_handoff_summary_content(content: object, target_payload: dict | None) -> bool: + """Return True if DB content JSON matches an expected handoff summary payload.""" + if target_payload is None: + return False + try: + payload = json.loads(content or "") + except Exception: + return False + if not isinstance(payload, dict): + return False + if payload.get("session_id") is None: + return False + return ( + payload.get("_handoff_summary_card") is True and + str(payload.get("session_id")) == str(target_payload.get("session_id")) and + str(payload.get("summary", "")) == str(target_payload.get("summary", "")) and + payload.get("channel") == target_payload.get("channel") and + payload.get("rounds") == target_payload.get("rounds") and + bool(payload.get("fallback")) == bool(target_payload.get("fallback")) + ) + + +def _persist_handoff_summary_locally(sid: str, message: dict) -> bool: + """Persist a handoff summary marker into a local WebUI session file.""" + try: + from api.models import get_session + + s = get_session(sid) + except KeyError: + return False + + try: + if s.messages and _is_matching_handoff_summary_message(s.messages[-1], message): + return True + s.messages.append(message) + s.save() + return True + except Exception as e: + logger.warning("Failed to persist handoff summary marker in local session %s: %s", sid, e) + return False + + +def _persist_handoff_summary_to_state_db(sid: str, message: dict) -> bool: + """Persist a handoff summary marker into CLI sessions state.db. + + This keeps summary cards available after hard-refresh for imported gateway + sessions that are not in local session JSON yet. + """ + import os + + try: + import sqlite3 + except ImportError: + return False + + try: + from api.profiles import get_active_hermes_home + + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + hermes_home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser().resolve() + + db_path = hermes_home / "state.db" + if not db_path.exists(): + return False + + ts = message.get("timestamp", time.time()) + content = message.get("content", "") + if not isinstance(content, str): + content = json.dumps(content, ensure_ascii=False) + + marker_payload = _extract_handoff_summary_payload(message) + try: + with sqlite3.connect(str(db_path)) as conn: + try: + if marker_payload is not None: + cur = conn.execute( + "SELECT content FROM messages WHERE session_id = ? AND role = 'tool' " + "ORDER BY rowid DESC LIMIT 1", + (sid,), + ) + row = cur.fetchone() + if row is not None and _is_matching_handoff_summary_content(row[0], marker_payload): + return True + except Exception: + # If tail-read fails, continue with a best-effort write. + logger.debug("Unable to read tail handoff marker from state.db for %s", sid) + + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES (?, 'tool', ?, ?)", + (sid, content, ts), + ) + # Keep session row message_count/last-activity aligned with displayed + # transcript length. session rows are optional in some test DBs, so + # this update is best-effort. + conn.execute( + "UPDATE sessions SET message_count = COALESCE(message_count, 0) + 1 " + "WHERE id = ?", + (sid,), + ) + conn.commit() + return True + except Exception as e: + logger.warning("Failed to persist handoff summary marker in state.db for %s: %s", sid, e) + return False + + +def _persist_handoff_summary(sid: str, summary: str, channel: str | None, rounds: int | None, fallback: bool = False) -> dict: + """Persist a handoff summary marker across local/session backends.""" + marker = _build_handoff_summary_tool_message(sid, summary, channel, rounds, fallback) + is_messaging_session = _is_messaging_session_id(sid) + if is_messaging_session: + _persist_handoff_summary_to_state_db(sid, marker) + _persist_handoff_summary_locally(sid, marker) + return marker + persisted_local = _persist_handoff_summary_locally(sid, marker) + if persisted_local: + return marker + return marker if _persist_handoff_summary_to_state_db(sid, marker) else marker + + def _handle_handoff_summary(handler, body): """Generate an on-demand handoff summary for a gateway session. @@ -5642,42 +5886,138 @@ def _handle_handoff_summary(handler, body): if len(msgs) < 2: return bad(handler, "Not enough messages to summarize.", 400) + def _extract_handoff_text(raw_content): + if isinstance(raw_content, list): + return " ".join( + str(p.get("text") or p.get("content") or "") + for p in raw_content + if isinstance(p, dict) + ).strip() + return str(raw_content or "").strip() + + def _contains_chinese(text): + return any("\u4e00" <= ch <= "\u9fff" for ch in str(text)) + + transcript_is_chinese = any( + _contains_chinese(_extract_handoff_text(m.get("content"))) + for m in msgs + ) # Build a lightweight conversation transcript for the LLM. lines = [] for m in msgs: role = m.get("role", "") - content = m.get("content", "") - if isinstance(content, list): - content = " ".join( - str(p.get("text") or p.get("content") or "") - for p in content - if isinstance(p, dict) - ) + content = _extract_handoff_text(m.get("content")) content = str(content or "").strip()[:1000] if role in ("user", "assistant") and content: - label = "User" if role == "user" else "Agent" - lines.append(f"{label}: {content}") + lines.append(content) transcript = "\n".join(lines) def _fallback_handoff_summary(items): """Return a deterministic summary when LLM summary generation is unavailable.""" - recent = [] + user_points = [] + assistant_points = [] + + def _summarize_snippet(raw_text, max_len=78): + text = " ".join(str(raw_text or "").split()).strip() + if not text: + return "" + if len(text) <= max_len: + return text + return text[: max_len - 1].rstrip() + "…" + for m in items: role = m.get("role", "") - content = m.get("content", "") - if isinstance(content, list): - content = " ".join( - str(p.get("text") or p.get("content") or "") - for p in content - if isinstance(p, dict) - ) - content = " ".join(str(content or "").split()).strip() + content = _summarize_snippet(_extract_handoff_text(m.get("content")), 82) if role in ("user", "assistant") and content: - label = "User" if role == "user" else "Agent" - recent.append(f"- {label}: {content[:180]}") - if not recent: - return "Recent external-channel messages were found, but no readable text was available." - return "Recent external-channel activity:\n" + "\n".join(recent[-6:]) + if role == "user": + user_points.append(content) + else: + assistant_points.append(content) + if not user_points and not assistant_points: + return ( + "近期可读文本不足,无法生成更完整的交接摘要,请补充一条消息后重试。" + if transcript_is_chinese + else "Not enough readable text to create a useful handoff summary; please send one more message and retry." + ) + + if transcript_is_chinese: + bullets = [] + if user_points: + bullets.append(f"- 你刚讨论了:{user_points[-1]}。") + if assistant_points: + bullets.append(f"- 助手已回复:{assistant_points[-1]}。") + if len(user_points) + len(assistant_points) >= 2: + bullets.append("- 当前对话存在尚未确认的后续动作。") + else: + bullets.append("- 当前信息偏少,建议补充关键点后再切换。") + return "\n".join(bullets) + + bullets = [] + if user_points: + bullets.append(f"- You asked: {user_points[-1]}.") + if assistant_points: + bullets.append(f"- The assistant responded: {assistant_points[-1]}.") + if len(user_points) + len(assistant_points) >= 2: + bullets.append("- There is pending context to continue next.") + else: + bullets.append("- The conversation is still short; add one more turn before summarizing.") + return "\n".join(bullets) + + def _summary_output_incomplete(text): + """Best-effort guard for truncated summaries when LLM signals are unavailable.""" + if not isinstance(text, str): + text = str(text or "") + text = text.strip() + if not text: + return True + if text.endswith("...") or text.endswith("…"): + return True + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + return True + last_line = lines[-1] + if re.search(r"[。!?;!?.;]$", last_line): + return False + if len(last_line) >= 56 and not re.search(r"\b(and|or|so|then|because|if|when|but|so|as)\b$", last_line, re.IGNORECASE): + return True + return bool(re.search(r"\b(and|or|but|so|because|if|when)$", last_line, re.IGNORECASE)) + + def _agent_summary_incomplete(summary_result): + if not isinstance(summary_result, dict): + return True + reason = (summary_result.get("finish_reason") or "").strip().lower() + if reason == "length": + return True + stop_reason = (summary_result.get("stop_reason") or "").strip().lower() + if stop_reason in {"max_tokens", "length"}: + return True + return _summary_output_incomplete(summary_result.get("text", "")) + + def _resolve_handoff_channel_label(): + channel_label = None + try: + from api.models import get_session as _get_session, get_cli_sessions + + session_meta = _get_session(sid) + channel_label = ( + session_meta.source_label + or session_meta.raw_source + or session_meta.source_tag + or session_meta.session_source + ) + if not channel_label: + for candidate in get_cli_sessions(): + if candidate.get("session_id") == sid: + channel_label = ( + candidate.get("source_label") + or candidate.get("raw_source") + or candidate.get("source_tag") + or candidate.get("source") + ) + break + except Exception: + pass + return channel_label def _agent_text_completion(agent, system_prompt, user_text, max_tokens=700): """Use the current Hermes Agent transport without mutating conversation history.""" @@ -5685,6 +6025,12 @@ def _handle_handoff_summary(handler, body): {"role": "system", "content": system_prompt}, {"role": "user", "content": user_text}, ] + result = { + "text": "", + "finish_reason": None, + "stop_reason": None, + "incomplete": True, + } disabled_reasoning = {"enabled": False} previous_reasoning = getattr(agent, "reasoning_config", None) try: @@ -5695,7 +6041,9 @@ def _handle_handoff_summary(handler, body): codex_kwargs["max_output_tokens"] = max_tokens resp = agent._run_codex_stream(codex_kwargs) assistant_message, _ = agent._normalize_codex_response(resp) - return str((assistant_message.content or "") if assistant_message else "").strip() + result["text"] = str((assistant_message.content or "") if assistant_message else "").strip() + result["incomplete"] = _summary_output_incomplete(result["text"]) + return result if getattr(agent, "api_mode", "") == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response @@ -5715,7 +6063,9 @@ def _handle_handoff_summary(handler, body): resp, strip_tool_prefix=getattr(agent, "_is_anthropic_oauth", False), ) - return str((assistant_message.content or "") if assistant_message else "").strip() + result["text"] = str((assistant_message.content or "") if assistant_message else "").strip() + result["incomplete"] = _summary_output_incomplete(result["text"]) + return result api_kwargs = agent._build_api_kwargs(api_messages) api_kwargs.pop("tools", None) @@ -5730,11 +6080,15 @@ def _handle_handoff_summary(handler, body): ) choice = (getattr(resp, "choices", None) or [None])[0] msg = getattr(choice, "message", None) if choice is not None else None - return str(getattr(msg, "content", "") or "").strip() + result["text"] = str(getattr(msg, "content", "") or "").strip() + result["finish_reason"] = getattr(choice, "finish_reason", None) + result["stop_reason"] = getattr(choice, "stop_reason", None) + result["incomplete"] = _agent_summary_incomplete(result) + return result finally: agent.reasoning_config = previous_reasoning - # Call LLM for summary. + # Call LLM for summary. try: import api.config as _cfg import hermes_cli.runtime_provider as _runtime_provider @@ -5765,9 +6119,20 @@ def _handle_handoff_summary(handler, body): logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e) if not resolved_api_key: + summary_text = _fallback_handoff_summary(msgs) + try: + _persist_handoff_summary( + sid, + summary_text, + _resolve_handoff_channel_label(), + rounds, + fallback=True, + ) + except Exception: + pass return j(handler, { "ok": True, - "summary": _fallback_handoff_summary(msgs), + "summary": summary_text, "message_count": len(msgs), "rounds": rounds, "fallback": True, @@ -5785,21 +6150,46 @@ def _handle_handoff_summary(handler, body): ) summary_system_prompt = ( - "You are summarizing a conversation that happened on an external channel " - "(WeChat/Telegram) so the user can quickly catch up when switching to Web UI.\n\n" + "You are summarizing an external-channel conversation so a Web UI reader " + "can quickly catch up after switching contexts.\n\n" + "Only use the latest messages, and never copy raw transcript lines.\n" + "Do not output role labels (no “你:” / “assistant:” / “user:” / “assistant”).\n" + "Use direct 2–5 bullet points in the conversation language.\n" + "English: speak using “you”.\n" + "中文: 使用“你”。\n\n" "Focus on:\n" "- Unfinished tasks or action items\n" "- Pending questions that need replies\n" "- Key decisions made\n" "- Open disagreements or TBD items\n\n" - "Keep it concise — 2-5 bullet points max. " "If the conversation is purely casual with no actionable items, " "say so in one sentence." ) summary_user_text = f"Conversation transcript:\n{transcript}" try: - summary_text = _agent_text_completion(agent, summary_system_prompt, summary_user_text) + first_pass = _agent_text_completion( + agent, + summary_system_prompt, + summary_user_text, + max_tokens=700, + ) + summary_text = first_pass.get("text") if isinstance(first_pass, dict) else "" + if _agent_summary_incomplete(first_pass): + second_pass = _agent_text_completion( + agent, + summary_system_prompt, + summary_user_text, + max_tokens=1400, + ) + summary_text = second_pass.get("text") if isinstance(second_pass, dict) else "" + if _agent_summary_incomplete(second_pass): + summary_text = _fallback_handoff_summary(msgs) + fallback = True + else: + fallback = False + else: + fallback = False finally: try: agent.release_clients() @@ -5807,19 +6197,43 @@ def _handle_handoff_summary(handler, body): pass if not summary_text: summary_text = _fallback_handoff_summary(msgs) + fallback = True + elif _summary_output_incomplete(summary_text): + if not fallback: + fallback = True + + channel_label = _resolve_handoff_channel_label() + _persist_handoff_summary( + sid, + summary_text, + channel_label, + rounds, + fallback=fallback, + ) return j(handler, { "ok": True, "summary": summary_text, "message_count": len(msgs), "rounds": rounds, - "fallback": summary_text.startswith("Recent external-channel activity:"), + "fallback": fallback, }) except Exception as e: logger.warning("Handoff summary generation failed: %s", e) + summary_text = _fallback_handoff_summary(msgs) + try: + _persist_handoff_summary( + sid, + summary_text, + _resolve_handoff_channel_label(), + rounds, + fallback=True, + ) + except Exception: + pass return j(handler, { "ok": True, - "summary": _fallback_handoff_summary(msgs), + "summary": summary_text, "message_count": len(msgs), "rounds": rounds, "fallback": True, @@ -5894,6 +6308,40 @@ def _handle_memory_write(handler, body): return j(handler, {"ok": True, "section": section, "path": str(target)}) +def _normalize_message_for_import_refresh(message: object) -> object: + """Normalize message payloads for import refresh prefix checks. + + The strict dict comparison previously failed when existing messages held + integer timestamps while refreshed messages held floating-point timestamps. + Strip timing keys before comparison so we can safely treat semantic + prefixes as equivalent. + """ + if not isinstance(message, dict): + return message + normalized = dict(message) + normalized.pop("timestamp", None) + normalized.pop("_ts", None) + return normalized + + +def _is_messages_refresh_prefix_match(existing_messages: list, fresh_messages: list) -> bool: + """Return True when existing_messages is a prefix of fresh_messages by value. + + This is a semantic comparison intended for import refresh, not deep + structural equality. It intentionally ignores timing fields that may differ + in type/precision between storage layers. + """ + if not isinstance(existing_messages, list) or not isinstance(fresh_messages, list): + return False + if len(existing_messages) > len(fresh_messages): + return False + for idx, existing_message in enumerate(existing_messages): + fresh_message = fresh_messages[idx] + if _normalize_message_for_import_refresh(existing_message) != _normalize_message_for_import_refresh(fresh_message): + return False + return True + + def _handle_session_import_cli(handler, body): """Import a single CLI session into the WebUI store.""" try: @@ -5917,7 +6365,7 @@ def _handle_session_import_cli(handler, body): # Prefix-equality guard: only extend if existing messages are a prefix of # the fresh CLI messages. Prevents silently dropping WebUI-added messages # on hybrid sessions (user sent messages via WebUI while CLI continued). - if existing.messages == fresh_msgs[:len(existing.messages)]: + if _is_messages_refresh_prefix_match(existing.messages, fresh_msgs): existing.messages = fresh_msgs changed = True if cli_meta: @@ -5961,6 +6409,12 @@ def _handle_session_import_cli(handler, body): cli_raw_source = None cli_session_source = None cli_source_label = None + cli_user_id = None + cli_chat_id = None + cli_chat_type = None + cli_thread_id = None + cli_session_key = None + cli_platform = None for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") @@ -5972,6 +6426,12 @@ def _handle_session_import_cli(handler, body): cli_raw_source = cs.get("raw_source") cli_session_source = cs.get("session_source") cli_source_label = cs.get("source_label") + cli_user_id = cs.get("user_id") + cli_chat_id = cs.get("chat_id") + cli_chat_type = cs.get("chat_type") + cli_thread_id = cs.get("thread_id") + cli_session_key = cs.get("session_key") + cli_platform = cs.get("platform") break # Use the CLI session title if available (e.g., cron job name), otherwise derive from messages @@ -5998,6 +6458,12 @@ def _handle_session_import_cli(handler, body): s.raw_source = cli_raw_source or cli_source_tag s.session_source = cli_session_source s.source_label = cli_source_label + s.user_id = cli_user_id + s.chat_id = cli_chat_id + s.chat_type = cli_chat_type + s.thread_id = cli_thread_id + s.session_key = cli_session_key + s.platform = cli_platform s._cli_origin = sid s.save(touch_updated_at=False) return j( diff --git a/static/sessions.js b/static/sessions.js index 1d72759d..dc6a020c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -552,6 +552,15 @@ async function loadSession(sid){ const _HANDOFF_THRESHOLD = 10; // conversation rounds const _HANDOFF_STORAGE_PREFIX = 'handoff:'; +const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at'; +const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at'; +const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack']); +const _MESSAGING_SOURCE_LABELS = { + weixin: 'WeChat', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', +}; function _isMessagingSession(session) { if (!session) return false; @@ -559,26 +568,83 @@ function _isMessagingSession(session) { if (session.session_source === 'messaging') return true; // Fallback: check raw_source directly const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase(); - return ['weixin', 'telegram', 'discord', 'slack'].includes(raw); + return _MESSAGING_RAW_SOURCES.has(raw); +} + +function _normalizeMessageForCliImportComparison(message) { + if (!message || typeof message !== 'object') return message; + const clone = { ...message }; + delete clone.timestamp; + delete clone._ts; + return clone; +} + +function _isCliImportRefreshPrefixMatch(localMessages, freshMessages) { + if (!Array.isArray(localMessages) || !Array.isArray(freshMessages)) return false; + if (localMessages.length > freshMessages.length) return false; + for (let i = 0; i < localMessages.length; i += 1) { + if (JSON.stringify(_normalizeMessageForCliImportComparison(localMessages[i])) !== JSON.stringify(_normalizeMessageForCliImportComparison(freshMessages[i]))) { + return false; + } + } + return true; } function _handoffStorageKey(sid) { - return _HANDOFF_STORAGE_PREFIX + sid + ':dismissed_at'; + return `${_HANDOFF_STORAGE_PREFIX}${sid}:`; } -function _getHandoffDismissedAt(sid) { +function _getHandoffStorageValue(sid, suffix) { try { - const val = localStorage.getItem(_handoffStorageKey(sid)); - return val ? parseFloat(val) : null; + const raw = localStorage.getItem(_handoffStorageKey(sid) + suffix); + return raw ? parseFloat(raw) : null; } catch { return null; } } -function _setHandoffDismissedAt(sid, ts) { +function _setHandoffStorageValue(sid, suffix, ts) { + const key = _handoffStorageKey(sid) + suffix; try { - localStorage.setItem(_handoffStorageKey(sid), String(ts)); + if (!Number.isFinite(ts)) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, String(ts)); } catch {} } +function _clearHandoffStorageForSession(sid) { + if (!sid) return; + try { + _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null); + _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null); + } catch {} +} + +function _getHandoffDismissedAt(sid) { + return _getHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT); +} + +function _setHandoffDismissedAt(sid, ts) { + _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, ts); +} + +function _getHandoffSummaryHandledAt(sid) { + return _getHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT); +} + +function _setHandoffSummaryHandledAt(sid, ts) { + _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, ts); +} + +function _getHandoffSince(sid) { + const dismissedAt = _getHandoffDismissedAt(sid); + const summaryHandledAt = _getHandoffSummaryHandledAt(sid); + if (Number.isFinite(dismissedAt) && Number.isFinite(summaryHandledAt)) return Math.max(dismissedAt, summaryHandledAt); + if (Number.isFinite(dismissedAt)) return dismissedAt; + if (Number.isFinite(summaryHandledAt)) return summaryHandledAt; + return null; +} + function _handoffMessagesEl() { return document.getElementById('messages'); } @@ -614,13 +680,12 @@ function _getChannelLabel(session) { // Use source_label from PR #1294 if available if (session.source_label) return session.source_label; const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase(); - const labels = { weixin: 'WeChat', telegram: 'Telegram', discord: 'Discord', slack: 'Slack' }; - return labels[raw] || raw || ''; + return _MESSAGING_SOURCE_LABELS[raw] || raw || ''; } async function _checkAndShowHandoffHint(sid) { try { - const since = _getHandoffDismissedAt(sid); + const since = _getHandoffSince(sid); const body = { session_id: sid }; if (since != null) body.since = since; @@ -628,14 +693,19 @@ async function _checkAndShowHandoffHint(sid) { method: 'POST', body: JSON.stringify(body), }); - // Stale? Session switched while we were fetching. if (!S.session || S.session.session_id !== sid) return; if (result && result.ok && result.should_show) { _showHandoffHint(sid, result.rounds); } else { - _hideHandoffHint(); + const container = $('handoffHintContainer'); + const isSameVisibleSession = !!( + container && + container.classList.contains('is-visible') && + container.dataset.sessionId === String(sid) + ); + if (!isSameVisibleSession) _hideHandoffHint(); } } catch (e) { console.warn('Handoff hint check failed:', e); @@ -651,26 +721,32 @@ function _showHandoffHint(sid, rounds) { container.innerHTML = ''; container.style.display = ''; container.classList.add('is-visible'); + container.dataset.sessionId = String(sid); const channel = _getChannelLabel(S.session); const hintText = channel - ? `${channel} has ${rounds} new conversation rounds — click to view summary` - : `${rounds} new conversation rounds — click to view summary`; + ? `${channel} handoff` + : `Conversation handoff`; + const hintMeta = `${rounds} new conversation rounds`; const bar = document.createElement('div'); bar.className = 'handoff-hint-bar'; bar.id = 'handoffHintBar'; bar.innerHTML = `
Fallback summary generated from recent turns; no model-based rewrite was used.
' + : '' + }` + ) : `${esc(detail)}
`; return `