feat(pheby): accept inbound chat attachments
This commit is contained in:
@@ -225,6 +225,16 @@ def _iso(value: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _display_user_text(text: str) -> str:
|
||||
"""Hide agent-only inline file context from the user-facing transcript."""
|
||||
start = "[Pheby user message]\n"
|
||||
end = "\n[/Pheby user message]"
|
||||
if start not in text:
|
||||
return text
|
||||
body = text.split(start, 1)[1]
|
||||
return body.split(end, 1)[0] if end in body else text
|
||||
|
||||
|
||||
async def conversation_history(conversation_id: str, limit: int
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""Load transcript rows for a conversation from Hermes state.db.
|
||||
@@ -259,6 +269,8 @@ async def conversation_history(conversation_id: str, limit: int
|
||||
continue
|
||||
content = row.get("content")
|
||||
text = content if isinstance(content, str) else str(content or "")
|
||||
if role == "user":
|
||||
text = _display_user_text(text)
|
||||
# Tool-call rows can surface as assistant rows with empty
|
||||
# content; skip empties so the client transcript stays clean.
|
||||
if not text.strip() and role == "assistant":
|
||||
@@ -374,12 +386,18 @@ async def delete_conversation(conversation_id: str) -> bool:
|
||||
# Chat runs
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
async def send_chat(server: Any, conversation_id: str, text: str,
|
||||
client: Any, request_id: Optional[str]) -> None:
|
||||
client: Any, request_id: Optional[str],
|
||||
attachment_ids: Optional[List[str]] = None) -> None:
|
||||
"""Deliver a user message into the Hermes gateway for this conversation.
|
||||
|
||||
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
|
||||
deliverables, streaming) runs on the adapter's message handler. Pheby
|
||||
adds nothing to the agent loop.
|
||||
|
||||
*attachment_ids* (optional) reference inbound uploads already registered
|
||||
in ``server.store``; they are anchored to the user message, handed to
|
||||
Hermes as ``media_urls``/``media_types`` (tool-accessible local files),
|
||||
and small text files are additionally inlined into the message text.
|
||||
"""
|
||||
adapter = _current_adapter()
|
||||
if adapter is None or not hasattr(adapter, "handle_message"):
|
||||
@@ -392,13 +410,68 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
||||
|
||||
run_id = uuid.uuid4().hex[:16]
|
||||
source = _source_for(conversation_id)
|
||||
message_id = uuid.uuid4().hex[:12]
|
||||
|
||||
# ── inbound attachments ────────────────────────────────────────────────
|
||||
media_urls: List[str] = []
|
||||
media_types: List[str] = []
|
||||
media_text_inlined: List[bool] = []
|
||||
inline_blocks: List[str] = []
|
||||
attachment_descs: List[Dict[str, Any]] = []
|
||||
for aid in attachment_ids or []:
|
||||
desc = server.store.describe(aid)
|
||||
if desc is None or desc.get("conversation_id") != conversation_id or \
|
||||
desc.get("direction") != "inbound" or desc.get("message_id"):
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_NOT_FOUND,
|
||||
"Unknown or already sent attachment for this conversation", request_id))
|
||||
return
|
||||
attachment_descs.append(desc)
|
||||
for desc in attachment_descs:
|
||||
aid = desc["attachment_id"]
|
||||
blob = server.store.get_blob_path(aid)
|
||||
if blob is None:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_NOT_FOUND, "Attachment unavailable", request_id))
|
||||
return
|
||||
media_urls.append(str(blob))
|
||||
mime = str(desc.get("mime_type", "application/octet-stream"))
|
||||
media_types.append(mime)
|
||||
# Small text files become part of the message text so the model
|
||||
# reads them directly without a tool round-trip.
|
||||
fname = str(desc.get("filename", "file"))
|
||||
inlined = False
|
||||
if mime.startswith("text/") and int(desc.get("size", 0)) <= \
|
||||
proto.INLINE_TEXT_BYTES:
|
||||
try:
|
||||
content = blob.read_text(encoding="utf-8", errors="replace")
|
||||
inline_blocks.append(
|
||||
f"Attached file: {fname}\n```{fname.rsplit('.', 1)[-1]}\n"
|
||||
f"{content}\n```")
|
||||
inlined = True
|
||||
except OSError:
|
||||
pass
|
||||
media_text_inlined.append(inlined)
|
||||
|
||||
effective_text = text
|
||||
if attachment_descs:
|
||||
effective_text = f"[Pheby user message]\n{text}\n[/Pheby user message]"
|
||||
if inline_blocks:
|
||||
effective_text += "\n\n" + "\n\n".join(inline_blocks)
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
text=effective_text,
|
||||
message_type=(MessageType.PHOTO if media_types and
|
||||
all(t.startswith("image/") for t in media_types)
|
||||
else (MessageType.DOCUMENT if media_types
|
||||
else MessageType.TEXT)),
|
||||
source=source,
|
||||
message_id=uuid.uuid4().hex[:12],
|
||||
message_id=message_id,
|
||||
metadata={"pheby_run_id": run_id},
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
media_text_inlined=media_text_inlined,
|
||||
)
|
||||
|
||||
with _RUN_LOCK:
|
||||
@@ -438,6 +511,14 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
||||
# returns quickly; the eventual reply arrives through adapter.send().
|
||||
try:
|
||||
await adapter.handle_message(event)
|
||||
for desc in attachment_descs:
|
||||
aid = desc["attachment_id"]
|
||||
server.store.anchor_message(aid, message_id)
|
||||
await server.broadcast({
|
||||
"type": proto.S_ATTACHMENT_ADDED,
|
||||
"conversation_id": conversation_id,
|
||||
"attachment": server.store.describe(aid),
|
||||
})
|
||||
except Exception:
|
||||
with _RUN_LOCK:
|
||||
current = _ACTIVE_RUNS.get(conversation_id)
|
||||
|
||||
Reference in New Issue
Block a user