mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-13 21:21:53 +00:00
feat(chat): auto-generate chat titles from first message via Haiku (#982)
Adds an opt-in chat title generator that fires on the first user message in a session. Emits a session_titled SystemEvent so the dashboard sidebar (and any SSE client on /chat/stream) updates in realtime. Forced on when ee.cloud is present; OSS default is off and controlled by POCKETPAW_CHAT_TITLE_GENERATION_ENABLED. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,10 @@
|
||||
# POCKETPAW_SOUL_PATH=
|
||||
# POCKETPAW_SOUL_AUTO_SAVE_INTERVAL=300
|
||||
|
||||
# ── Chat Title Generation (Haiku-backed auto-naming) ─────
|
||||
# POCKETPAW_CHAT_TITLE_GENERATION_ENABLED=false
|
||||
# POCKETPAW_CHAT_TITLE_MODEL=claude-haiku-4-5-20251001
|
||||
|
||||
# ── Security ──────────────────────────────────────────────
|
||||
# POCKETPAW_BYPASS_PERMISSIONS=false
|
||||
# POCKETPAW_LOCALHOST_AUTH_BYPASS=true
|
||||
|
||||
12
ee/cloud/features.py
Normal file
12
ee/cloud/features.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Cloud-mode feature overrides.
|
||||
|
||||
When ee.cloud is installed, certain OSS-optional features are force-enabled
|
||||
because the cloud product depends on them (chat titles drive the sidebar,
|
||||
outbound webhooks drive realtime UI updates, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def chat_titles_enabled() -> bool:
|
||||
return True
|
||||
@@ -371,6 +371,36 @@ class AgentLoop:
|
||||
self._router = AgentRouter(Settings.load())
|
||||
return self._router
|
||||
|
||||
async def _generate_and_emit_title(self, session_key: str, first_message: str) -> None:
|
||||
"""Generate a chat title from ``first_message`` and publish a
|
||||
``session_titled`` SystemEvent. Best-effort; never raises."""
|
||||
try:
|
||||
from pocketpaw.memory.titler import generate_title
|
||||
|
||||
title = await generate_title(
|
||||
first_message,
|
||||
model=self.settings.chat_title_model,
|
||||
api_key=self.settings.anthropic_api_key or None,
|
||||
)
|
||||
if not title:
|
||||
return
|
||||
# session_key is "channel:chat_id" — expose the safe_key form
|
||||
# ("channel_chat_id") so web clients can correlate with their
|
||||
# session_id directly.
|
||||
safe_key = session_key.replace(":", "_")
|
||||
await self.bus.publish_system(
|
||||
SystemEvent(
|
||||
event_type="session_titled",
|
||||
data={
|
||||
"session_key": session_key,
|
||||
"session_id": safe_key,
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("session titling failed for %s", session_key, exc_info=True)
|
||||
|
||||
async def process_message(self, message: InboundMessage) -> None:
|
||||
"""Public entry point — run the full processing pipeline on one
|
||||
message without going through the bus consumer.
|
||||
@@ -740,6 +770,14 @@ class AgentLoop:
|
||||
store_meta = {
|
||||
k: v for k, v in (message.metadata or {}).items() if k != "pocket_system_context"
|
||||
}
|
||||
# Detect first-message state *before* persisting so titler can fire once.
|
||||
is_first_message = False
|
||||
try:
|
||||
prior = await self.memory._store.get_session(session_key)
|
||||
is_first_message = len(prior) == 0
|
||||
except (AttributeError, TypeError):
|
||||
is_first_message = False
|
||||
|
||||
await self.memory.add_to_session(
|
||||
session_key=session_key,
|
||||
role="user",
|
||||
@@ -747,6 +785,17 @@ class AgentLoop:
|
||||
metadata=store_meta,
|
||||
)
|
||||
|
||||
# 1a. Fire-and-forget chat title generation on the first user message.
|
||||
# Publishes a ``session_titled`` SystemEvent; persistence is the
|
||||
# caller's responsibility (cloud: Mongo; OSS: in-memory/SSE only).
|
||||
if is_first_message:
|
||||
from pocketpaw.features import chat_titles_enabled
|
||||
|
||||
if chat_titles_enabled(self.settings):
|
||||
asyncio.create_task(
|
||||
self._generate_and_emit_title(session_key, content)
|
||||
)
|
||||
|
||||
# 1b. Inject inbound media file paths so the agent can use them
|
||||
# Also detect whether this is a voice message so we can auto-TTS the reply.
|
||||
is_voice_message = any(
|
||||
|
||||
@@ -162,6 +162,16 @@ class _APISessionBridge:
|
||||
"data": {"mutation": data.get("mutation", {})},
|
||||
}
|
||||
)
|
||||
elif evt.event_type == "session_titled":
|
||||
await self.queue.put(
|
||||
{
|
||||
"event": "session_titled",
|
||||
"data": {
|
||||
"session_id": data.get("session_id", ""),
|
||||
"title": data.get("title", ""),
|
||||
},
|
||||
}
|
||||
)
|
||||
elif evt.event_type == "error":
|
||||
await self.queue.put(
|
||||
{"event": "error", "data": {"detail": data.get("message", "")}}
|
||||
|
||||
@@ -586,6 +586,19 @@ class Settings(BaseSettings):
|
||||
default=True, description="Extend log scrubber with PII patterns (when pii_scan_enabled)"
|
||||
)
|
||||
|
||||
# Chat Title Generation (Haiku-backed, first-message naming)
|
||||
chat_title_generation_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Auto-generate a short title for a chat from its first user message."
|
||||
" Uses a Haiku model; fires a session_titled SystemEvent on completion."
|
||||
),
|
||||
)
|
||||
chat_title_model: str = Field(
|
||||
default="claude-haiku-4-5-20251001",
|
||||
description="Model used by the chat title generator (Anthropic).",
|
||||
)
|
||||
|
||||
# Smart Model Routing
|
||||
smart_routing_enabled: bool = Field(
|
||||
default=False,
|
||||
|
||||
32
src/pocketpaw/features.py
Normal file
32
src/pocketpaw/features.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Feature flag resolution.
|
||||
|
||||
Reads OSS settings, but allows ``ee.cloud.features`` to force-on specific
|
||||
capabilities when running in cloud mode. ee.cloud is imported lazily so OSS
|
||||
builds without the ee package continue to work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pocketpaw.config import Settings
|
||||
|
||||
|
||||
def _cloud_override(name: str) -> bool | None:
|
||||
"""Return True/False if ee.cloud forces a feature on/off, else None."""
|
||||
try:
|
||||
from ee.cloud import features as cloud_features # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
return None
|
||||
getter = getattr(cloud_features, name, None)
|
||||
if getter is None:
|
||||
return None
|
||||
try:
|
||||
return bool(getter())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def chat_titles_enabled(settings: Settings) -> bool:
|
||||
override = _cloud_override("chat_titles_enabled")
|
||||
if override is not None:
|
||||
return override
|
||||
return settings.chat_title_generation_enabled
|
||||
@@ -666,6 +666,20 @@ window.PocketPaw.Transparency = {
|
||||
return;
|
||||
}
|
||||
|
||||
// session_titled — Haiku-generated chat title; update sidebar entry
|
||||
if (eventType === 'session_titled') {
|
||||
const d = data.data || {};
|
||||
const sid = d.session_id;
|
||||
const title = d.title;
|
||||
if (sid && title && this.sessions) {
|
||||
const session = this.sessions.find(s => s.id === sid);
|
||||
if (session) {
|
||||
session.title = title;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AskUserQuestion — show interactive question in chat
|
||||
if (eventType === 'ask_user_question') {
|
||||
const d = data.data || {};
|
||||
|
||||
66
src/pocketpaw/memory/titler.py
Normal file
66
src/pocketpaw/memory/titler.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Chat title generation from the first user message.
|
||||
|
||||
Uses a Haiku-class Anthropic model to produce a short (≤6 word) title. The
|
||||
caller is responsible for persisting the title and emitting the
|
||||
``session_titled`` SystemEvent — this module only generates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROMPT = (
|
||||
"Write a concise chat title (max 6 words, Title Case, no quotes, no"
|
||||
" trailing punctuation) that captures the subject of this user message.\n\n"
|
||||
"Message:\n{message}\n\nTitle:"
|
||||
)
|
||||
|
||||
_MAX_INPUT_CHARS = 2000
|
||||
_MAX_TOKENS = 24
|
||||
|
||||
|
||||
async def generate_title(
|
||||
first_message: str, *, model: str, api_key: str | None = None
|
||||
) -> str | None:
|
||||
"""Return a short title for ``first_message`` or ``None`` on failure.
|
||||
|
||||
Failures (missing SDK, missing key, API error) are logged at debug and
|
||||
swallowed — titling is best-effort and must never break the chat flow.
|
||||
"""
|
||||
text = (first_message or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > _MAX_INPUT_CHARS:
|
||||
text = text[:_MAX_INPUT_CHARS]
|
||||
|
||||
try:
|
||||
from anthropic import AsyncAnthropic
|
||||
except ImportError:
|
||||
logger.debug("anthropic SDK not installed; skipping title generation")
|
||||
return None
|
||||
|
||||
try:
|
||||
client = AsyncAnthropic(api_key=api_key) if api_key else AsyncAnthropic()
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=_MAX_TOKENS,
|
||||
messages=[{"role": "user", "content": _PROMPT.format(message=text)}],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("title generation call failed", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = response.content[0].text
|
||||
except (AttributeError, IndexError):
|
||||
return None
|
||||
|
||||
title = raw.strip().strip('"').strip("'").rstrip(".").strip()
|
||||
if not title:
|
||||
return None
|
||||
# Cap at a hard character budget in case the model ignores word-count.
|
||||
if len(title) > 80:
|
||||
title = title[:80].rstrip()
|
||||
return title
|
||||
Reference in New Issue
Block a user