Auto-threading, history pagination, streaming toggle + tool/reasoning display settings
- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
mints a fresh AI-named thread (instant derived title, LLM upgrade via
channel.renamed); channel.created {auto:true}; the app jumps into the new
thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
verbose tool progress (full args → tool.start.args) and captures each
completed call via the post_tool_call hook (output/duration/ok → tool.end).
The app reveals the full call + output on expand (Truncated) and
auto-expands cards in Everything mode.
This commit is contained in:
1 parent
1d67de5f12
commit
60ec2b44a7
24 files changed
+1115
-81
No files matched your search
@@ -169,6 +169,114 @@ class Outbox:
|
||||
)
|
||||
return out
|
||||
|
||||
# ── history (full message history for a chat/thread) ──────────────────
|
||||
|
||||
def history(
|
||||
self,
|
||||
chat_id: str,
|
||||
thread_id: Optional[str] = None,
|
||||
before_message_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""Final messages for a chat/thread, for the ``history`` frame.
|
||||
|
||||
Reconstructs the message list from the outbox log: a final message is
|
||||
either a standalone ``message`` frame (user echo / non-streaming
|
||||
assistant) or a ``message.stop`` frame (streaming assistant final).
|
||||
Intermediate frames (``message.start``/``message.update``, tool,
|
||||
commentary, notification, …) are skipped. Messages are deduplicated by
|
||||
``message_id`` and returned oldest → newest.
|
||||
|
||||
Pagination: ``before_message_id`` returns the page of messages older
|
||||
than that id (the newest page when omitted). Returns
|
||||
``{messages, has_more, oldest_message_id}``.
|
||||
"""
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT cursor, frame FROM outbox WHERE chat_id = ? "
|
||||
"ORDER BY cursor ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
final: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
try:
|
||||
frame = json.loads(r["frame"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
if thread_id is not None and frame.get("thread_id") != thread_id:
|
||||
continue
|
||||
ftype = frame.get("type")
|
||||
payload = frame.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if ftype == "message":
|
||||
role = payload.get("role")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
final.append(
|
||||
{
|
||||
"cursor": int(r["cursor"]),
|
||||
"message_id": payload.get("message_id"),
|
||||
"role": role,
|
||||
"text": payload.get("text", ""),
|
||||
"reasoning": payload.get("reasoning"),
|
||||
"model": payload.get("model"),
|
||||
"tokens": payload.get("tokens"),
|
||||
"ts": payload.get("ts"),
|
||||
"media": payload.get("media"),
|
||||
}
|
||||
)
|
||||
elif ftype == "message.stop":
|
||||
final.append(
|
||||
{
|
||||
"cursor": int(r["cursor"]),
|
||||
"message_id": payload.get("message_id"),
|
||||
"role": "assistant",
|
||||
"text": payload.get("final_text", ""),
|
||||
"reasoning": payload.get("reasoning"),
|
||||
"model": payload.get("model"),
|
||||
"tokens": payload.get("tokens"),
|
||||
"ts": payload.get("ts"),
|
||||
"media": None,
|
||||
}
|
||||
)
|
||||
# Deduplicate by message_id (keep the latest occurrence), keep order.
|
||||
by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for m in final:
|
||||
mid = m.get("message_id")
|
||||
if mid:
|
||||
by_id[mid] = m
|
||||
ordered = sorted(by_id.values(), key=lambda m: m["cursor"])
|
||||
# Paginate: messages older than ``before_message_id`` (newest page when
|
||||
# omitted / not found — a pruned anchor falls back to the newest page).
|
||||
if before_message_id:
|
||||
idx = next(
|
||||
(i for i, m in enumerate(ordered) if m["message_id"] == before_message_id),
|
||||
None,
|
||||
)
|
||||
pool = ordered[:idx] if idx is not None else ordered
|
||||
else:
|
||||
pool = ordered
|
||||
has_more = len(pool) > limit
|
||||
page = pool[-limit:] if has_more else list(pool)
|
||||
oldest_message_id = page[0]["message_id"] if page else None
|
||||
for m in page:
|
||||
m.pop("cursor", None)
|
||||
# Omit absent optional fields (the app's serializer treats a
|
||||
# missing key as its default, but a JSON ``null`` for a
|
||||
# non-nullable field like ``media`` would fail to parse).
|
||||
for key in ("reasoning", "model", "tokens", "ts", "media"):
|
||||
if m.get(key) is None:
|
||||
m.pop(key, None)
|
||||
return {
|
||||
"messages": page,
|
||||
"has_more": has_more,
|
||||
"oldest_message_id": oldest_message_id,
|
||||
}
|
||||
|
||||
# ── retention ─────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_prune(self) -> None:
|
||||
|
||||
Reference in new issue
Block a user