feat: add multi-channel Devii sessions with docs search mode and channel-aware conversation persistence

This commit is contained in:
2026-06-13 21:37:13 +00:00
parent 873186b274
commit 61c1ae8c5d
25 changed files with 1250 additions and 134 deletions
+15 -1
View File
@@ -318,8 +318,21 @@ def init_db():
db, "bug_comment_authors", "idx_bug_comment_authors_number", ["gitea_number"]
)
_index(db, "service_state", "idx_service_state_name", ["name"])
if "devii_conversations" in db.tables:
conversations = get_table("devii_conversations")
if not conversations.has_column("channel"):
conversations.create_column_by_example("channel", "main")
try:
db.query(
"UPDATE devii_conversations SET channel='main' WHERE channel IS NULL"
)
except Exception as e: # noqa: BLE001
logger.warning(f"Could not backfill devii_conversations.channel: {e}")
_index(
db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"]
db,
"devii_conversations",
"idx_devii_conv_owner",
["owner_kind", "owner_id", "channel"],
)
_index(
db,
@@ -528,6 +541,7 @@ def init_db():
"customization_enabled": "1",
"customization_js_enabled": "1",
"audit_log_retention_days": "90",
"docs_search_mode": "agent",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
+43 -14
View File
@@ -172,10 +172,10 @@ def _snippet(text: str, terms: list, width: int = 280) -> str:
return prefix + fragment + suffix
def search(query: str, user=None, is_admin: bool = False, limit: int = 20) -> list:
def _rank(query: str, user, is_admin: bool, limit: int) -> tuple:
terms = _tokenize(query or "")
if not terms:
return []
return [], None
idx = get_index(user)
scores: dict = {}
for term in set(terms):
@@ -188,20 +188,49 @@ def search(query: str, user=None, is_admin: bool = False, limit: int = 20) -> li
denom = tf + K1 * (1 - B + B * dl / idx["avgdl"])
scores[i] = scores.get(i, 0.0) + idf_t * (tf * (K1 + 1)) / denom
ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
results = []
selected = []
for i, score in ranked:
doc = idx["docs"][i]
if doc["admin"] and not is_admin:
continue
results.append(
{
"slug": doc["slug"],
"title": doc["title"],
"score": round(score, 3),
"url": f"/docs/{doc['slug']}.html",
"snippet": _snippet(idx["doc_text"][i], terms),
}
)
if len(results) >= limit:
selected.append((i, doc, round(score, 3)))
if len(selected) >= limit:
break
return results
return selected, idx
def search(query: str, user=None, is_admin: bool = False, limit: int = 20) -> list:
selected, idx = _rank(query, user, is_admin, limit)
if not selected:
return []
terms = _tokenize(query)
return [
{
"slug": doc["slug"],
"title": doc["title"],
"score": score,
"url": f"/docs/{doc['slug']}.html",
"snippet": _snippet(idx["doc_text"][i], terms),
}
for i, doc, score in selected
]
def search_pages(
query: str,
user=None,
is_admin: bool = False,
limit: int = 6,
content_chars: int = 2400,
) -> list:
selected, idx = _rank(query, user, is_admin, limit)
return [
{
"title": doc["title"],
"slug": doc["slug"],
"url": f"/docs/{doc['slug']}.html",
"score": score,
"content": idx["doc_text"][i][:content_chars],
}
for i, doc, score in selected
]
+1
View File
@@ -393,3 +393,4 @@ class AdminSettingsForm(BaseModel):
registration_open: str = Field(default="", max_length=1)
maintenance_mode: str = Field(default="", max_length=1)
maintenance_message: str = Field(default="", max_length=300)
docs_search_mode: str = Field(default="", max_length=20)
+5
View File
@@ -29,6 +29,7 @@ router = APIRouter()
GUEST_COOKIE = DEVII_GUEST_COOKIE
GUEST_COOKIE_MAX_AGE = 31536000
CHANNELS = frozenset({"main", "docs"})
def _service():
@@ -208,6 +209,9 @@ async def devii_ws(websocket: WebSocket):
if not service_manager.owns_lock():
await websocket.close(code=4013)
return
channel = websocket.query_params.get("channel", "main")
if channel not in CHANNELS:
channel = "main"
owner_kind, owner_id, username, api_key, owner_is_admin = _resolve_ws_owner(
websocket
)
@@ -225,6 +229,7 @@ async def devii_ws(websocket: WebSocket):
api_key,
svc.instance_base_url(),
is_admin=owner_is_admin,
channel=channel,
)
session.attach(websocket)
try:
+28 -2
View File
@@ -8,7 +8,9 @@ from devplacepy.utils import get_current_user, not_found, is_admin as user_is_ad
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.docs_api import render_group, build_services_group
from devplacepy.services.manager import service_manager
from devplacepy import docs_search, docs_export, docs_live, docs_prose
from devplacepy.database import get_setting
from devplacepy.constants import DEVII_GUEST_COOKIE
from devplacepy import docs_export, docs_live, docs_prose, docs_search
from devplacepy.routers.docs.pages import DOCS_PAGES, PAGES_BY_SLUG, nav_groups
@@ -53,6 +55,24 @@ async def docs_download_html(request: Request):
)
def _agent_search_state(request: Request, user, is_admin: bool) -> str:
svc = service_manager.get_service("devii")
if svc is None or not svc.is_enabled():
return "disabled"
if user:
owner_kind, owner_id = "user", user["uid"]
else:
owner_kind = "guest"
owner_id = request.cookies.get(DEVII_GUEST_COOKIE) or ""
if owner_kind == "guest" and not svc.guests_enabled():
return "disabled"
if owner_id:
limit = svc.daily_limit_for(owner_kind, is_admin)
if limit > 0 and svc.spent_24h(owner_kind, owner_id) >= limit:
return "quota"
return "ok"
@router.get("/docs/{slug}.html", response_class=HTMLResponse)
async def docs_page(request: Request, slug: str):
user = get_current_user(request)
@@ -62,7 +82,11 @@ async def docs_page(request: Request, slug: str):
if slug == "search":
query = request.query_params.get("q", "")
results = docs_search.search(query, user=user, is_admin=is_admin)
mode = get_setting("docs_search_mode", "agent")
state = _agent_search_state(request, user, is_admin) if mode == "agent" else "off"
use_agent = mode == "agent" and state == "ok"
quota_fallback = mode == "agent" and state == "quota"
results = None if use_agent else docs_search.search(query, user=user, is_admin=is_admin)
seo_ctx = base_seo_context(
request,
title="Search - Documentation",
@@ -86,6 +110,8 @@ async def docs_page(request: Request, slug: str):
"nav": nav_groups(visible_pages),
"current": "search",
"kind": "search",
"search_mode": "agent" if use_agent else "bm25",
"quota_fallback": quota_fallback,
"page_title_doc": "Search",
"search_query": query,
"search_results": results,
@@ -248,7 +248,7 @@ class Dispatcher:
self._owner_kind = owner_kind
self._owner_id = owner_id
self._fetch = FetchController(settings)
self._docs = DocsController(settings)
self._docs = DocsController(settings, is_admin=is_admin)
self._cost = CostController(quota_provider=quota_provider)
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
+13 -81
View File
@@ -2,81 +2,32 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
import httpx
from ..agentic.lessons import tokenize
from ..config import Settings
from ..errors import NetworkError, ToolInputError
from ..fetch.controller import STEALTH_HEADERS
from ..text import truncate
from ..errors import ToolInputError
logger = logging.getLogger("devii.docs")
DOCS_PATH = "/docs/download.md"
HEADING = re.compile(r"^(#{1,3})\s+(.*)$")
DEFAULT_MAX_RESULTS = 5
SECTION_CHARS = 1800
HEADING_WEIGHT = 3
CONTENT_CHARS = 2400
class DocsController:
def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings, is_admin: bool = False) -> None:
self._settings = settings
self._sections: list[tuple[str, str]] | None = None
self._lock = asyncio.Lock()
self._is_admin = is_admin
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name != "search_docs":
raise ToolInputError(f"Unknown docs tool: {name}")
return await self._search(arguments)
async def _load(self) -> list[tuple[str, str]]:
async with self._lock:
if self._sections is not None:
return self._sections
url = self._settings.base_url + DOCS_PATH
try:
async with httpx.AsyncClient(
headers=STEALTH_HEADERS,
follow_redirects=True,
timeout=self._settings.fetch_timeout_seconds,
) as client:
response = await client.get(url)
response.raise_for_status()
markdown = response.text
except httpx.HTTPError as exc:
raise NetworkError(
f"Could not load documentation: {exc}", url=url
) from exc
self._sections = self._split(markdown)
logger.info("Loaded %d documentation sections", len(self._sections))
return self._sections
@staticmethod
def _split(markdown: str) -> list[tuple[str, str]]:
sections: list[tuple[str, str]] = []
heading = "Overview"
body: list[str] = []
for line in markdown.splitlines():
match = HEADING.match(line)
if match:
if body:
sections.append((heading, "\n".join(body).strip()))
body = []
heading = match.group(2).strip()
else:
body.append(line)
if body:
sections.append((heading, "\n".join(body).strip()))
return [(title, text) for title, text in sections if text]
async def _search(self, arguments: dict[str, Any]) -> str:
from devplacepy import docs_search
query = str(arguments.get("query", "")).strip()
if not query:
raise ToolInputError("search_docs requires a query.")
@@ -85,32 +36,13 @@ class DocsController:
)
max_results = max(1, min(max_results, 10))
sections = await self._load()
terms = tokenize(query)
scored: list[tuple[float, str, str]] = []
for heading, text in sections:
heading_tokens = set(tokenize(heading))
body_tokens = tokenize(text)
body_counts: dict[str, int] = {}
for token in body_tokens:
body_counts[token] = body_counts.get(token, 0) + 1
score = 0.0
for term in terms:
score += body_counts.get(term, 0)
if term in heading_tokens:
score += HEADING_WEIGHT
if score > 0:
scored.append((score, heading, text))
scored.sort(key=lambda item: item[0], reverse=True)
results = [
{
"title": heading,
"score": round(score, 2),
"content": truncate(text, SECTION_CHARS),
}
for score, heading, text in scored[:max_results]
]
results = docs_search.search_pages(
query,
user=None,
is_admin=self._is_admin,
limit=max_results,
content_chars=CONTENT_CHARS,
)
return json.dumps(
{
"status": "success",
+16 -7
View File
@@ -30,7 +30,7 @@ class DeviiHub:
"ledger": UsageLedger(),
"turns": TurnAudit(),
}
self._sessions: dict[tuple[str, str], DeviiSession] = {}
self._sessions: dict[tuple[str, str, str], DeviiSession] = {}
@property
def ledger(self) -> UsageLedger:
@@ -44,15 +44,20 @@ class DeviiHub:
api_key: str,
base_url: str,
is_admin: bool = False,
channel: str = "main",
) -> DeviiSession:
key = (owner_kind, owner_id)
key = (owner_kind, owner_id, channel)
session = self._sessions.get(key)
if session is not None:
return session
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
llm = LLMClient(settings)
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
owned_db = db if owner_kind == "user" else memory_db()
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
# thread persists, keyed per channel in the shared ConversationStore.
owned_db = db if (owner_kind == "user" and channel == "main") else memory_db()
task_store = TaskStore(owned_db, owner_kind, owner_id)
lessons = LessonStore(owned_db, owner_kind, owner_id)
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
@@ -70,22 +75,26 @@ class DeviiHub:
behavior_store,
self._stores,
is_admin=is_admin,
channel=channel,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id)
saved = self._stores["conversations"].load(owner_kind, owner_id, channel)
if saved:
session.restore_history(saved)
self._sessions[key] = session
logger.info(
"Created session %s/%s (total %d)",
"Created session %s/%s [%s] (total %d)",
owner_kind,
owner_id,
channel,
len(self._sessions),
)
return session
def find(self, owner_kind: str, owner_id: str) -> DeviiSession | None:
return self._sessions.get((owner_kind, owner_id))
def find(
self, owner_kind: str, owner_id: str, channel: str = "main"
) -> DeviiSession | None:
return self._sessions.get((owner_kind, owner_id, channel))
def active_sessions(self) -> int:
return len(self._sessions)
+24
View File
@@ -71,6 +71,30 @@ class LLMClient:
)
return message
async def complete_text(
self, messages: list[dict[str, Any]], temperature: float = 0.0
) -> str:
payload = {
"model": self._settings.ai_model,
"messages": messages,
"temperature": temperature,
}
try:
response = await self._client.post(self._settings.ai_url, json=payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:
raise LLMError(
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
)
try:
data = response.json()
content = data["choices"][0]["message"]["content"] or ""
except (ValueError, KeyError, IndexError) as exc:
raise LLMError("Model endpoint returned an unexpected response.") from exc
record_usage(data.get("usage"))
return content
async def summarize(self, text: str) -> str:
payload = {
"model": self._settings.ai_model,
+170 -8
View File
@@ -85,9 +85,11 @@ class DeviiSession:
behavior_store: Any,
stores: dict[str, Any],
is_admin: bool = False,
channel: str = "main",
) -> None:
self.owner_kind = owner_kind
self.owner_id = owner_id
self.channel = channel
self.username = username
self.settings = settings
self.is_admin = is_admin
@@ -127,8 +129,10 @@ class DeviiSession:
virtual_tools=self.virtual_tools,
behavior=self.behavior,
)
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
self._system_prompt = _system_prompt_for(is_admin)
self.tools = self._builtin_tools()
self._system_prompt = (
DOCS_SYSTEM_PROMPT if channel == "docs" else _system_prompt_for(is_admin)
)
self.agentic.bind(
llm=llm,
dispatcher=self.dispatcher,
@@ -224,7 +228,7 @@ class DeviiSession:
self._disconnected.clear()
self.avatar.bind(self._avatar_request)
self.browser.bind(self._client_request)
if not self._started:
if not self._started and self.channel == "main":
self.scheduler.start()
self._started = True
if self._buffer:
@@ -282,6 +286,8 @@ class DeviiSession:
await self._llm.aclose()
async def bootstrap_greeting(self) -> str:
if self.channel == "docs":
return DOCS_GREETING
if self.client.authenticated:
return f"Signed in as {self.username}. Devii is operating your DevPlace account. How can I help?"
return LOGIN_REQUEST
@@ -307,7 +313,7 @@ class DeviiSession:
self._buffer = []
if self.persist_conversation:
try:
self._conv.clear(self.owner_kind, self.owner_id)
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
except Exception: # noqa: BLE001 - clearing storage must not break the socket
logger.exception(
"Failed to clear conversation for %s/%s",
@@ -354,7 +360,10 @@ class DeviiSession:
if self.persist_conversation:
try:
self._conv.save(
self.owner_kind, self.owner_id, self.agent._messages
self.owner_kind,
self.owner_id,
self.agent._messages,
self.channel,
)
except Exception: # noqa: BLE001 - persistence must not break the socket
logger.exception(
@@ -376,6 +385,8 @@ class DeviiSession:
async with self._lock:
self._refresh_tools()
self._refresh_system_prompt()
if self.channel == "docs":
await self._docs_topic_gate(text)
reply = await self.agent.respond(text)
if epoch == self._turn_epoch:
await self._emit({"type": "reply", "text": reply}, buffer=True)
@@ -391,12 +402,26 @@ class DeviiSession:
if not cancelled and epoch == self._turn_epoch:
self._record_turn(turn_id, started_at, text, reply, error, before)
def _builtin_tools(self) -> list[dict[str, Any]]:
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
if self.channel == "docs":
return [
s
for s in schemas
if s.get("function", {}).get("name") in DOCS_TOOLS
]
return schemas
def _refresh_tools(self) -> None:
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
if self.channel == "docs":
self.tools[:] = self._builtin_tools()
return
virtual = self._virtual_tool_store.tool_schemas()
self.tools[:] = builtin + virtual
self.tools[:] = self._builtin_tools() + virtual
def _compose_system_prompt(self) -> str:
if self.channel == "docs":
return self._system_prompt
body = self._behavior_store.text().strip()
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
return f"{self._system_prompt}\n\n{section}"
@@ -406,6 +431,56 @@ class DeviiSession:
if messages and messages[0].get("role") == "system":
messages[0]["content"] = self._compose_system_prompt()
async def _docs_topic_gate(self, text: str) -> None:
prior = [
m
for m in self.agent._messages
if m.get("role") in ("user", "assistant") and m.get("content")
]
if not prior:
return
decision, reason = await self._classify_topic(prior, text)
if decision == "new":
messages = self.agent._messages
if messages and messages[0].get("role") == "system":
system = messages[0]
else:
system = {"role": "system", "content": self._compose_system_prompt()}
self.agent._messages = [system]
self._buffer = []
if self.persist_conversation:
try:
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
except Exception: # noqa: BLE001 - clearing storage must not break the turn
logger.exception(
"Failed to clear docs conversation for %s/%s",
self.owner_kind,
self.owner_id,
)
await self._emit(
{"type": "topic", "decision": decision, "reason": reason}, buffer=False
)
async def _classify_topic(
self, prior: list[dict[str, Any]], text: str
) -> tuple[str, str]:
recent = prior[-6:]
convo = "\n".join(
f"{m['role']}: {str(m['content'])[:300]}" for m in recent
)
messages = [
{"role": "system", "content": TOPIC_CLASSIFIER_PROMPT},
{
"role": "user",
"content": f"Prior conversation:\n{convo}\n\nNew message:\n{text}\n\nClassify.",
},
]
try:
raw = await self._llm.complete_text(messages)
except Exception: # noqa: BLE001 - on any failure, keep context (treat as follow-up)
return "follow_up", ""
return _parse_topic(raw)
def _quota_snapshot(self) -> dict[str, Any]:
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
@@ -440,7 +515,12 @@ class DeviiSession:
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
try:
if self.persist_conversation:
self._conv.save(self.owner_kind, self.owner_id, self.agent._messages)
self._conv.save(
self.owner_kind,
self.owner_id,
self.agent._messages,
self.channel,
)
self._ledger.record(
self.owner_kind,
self.owner_id,
@@ -615,6 +695,88 @@ NON_ADMIN_COST_RULE = (
"quota used and the turn count."
)
DOCS_TOOLS = frozenset({"search_docs"})
TOPIC_CLASSIFIER_PROMPT = (
"You are a routing classifier for a documentation assistant. Decide whether the "
"user's NEW message continues the PRIOR conversation (a follow-up: a clarification, "
"a refinement, or another question about the same subject) or starts a NEW, "
"unrelated topic that should begin from a clean slate.\n"
"Respond with ONLY a JSON object and nothing else: "
'{"decision": "follow_up" or "new", "reason": "<one short sentence explaining why>"}.'
)
DOCS_GREETING = (
"Hi, I am Docii, the DevPlace documentation assistant. Ask me anything about "
"DevPlace and I will search the documentation to find your answer."
)
DOCS_SYSTEM_PROMPT = (
"You are Docii, the documentation assistant for DevPlace, a social network for "
"software developers. You answer questions about DevPlace using ONLY its official "
"documentation.\n\n"
"YOU HAVE EXACTLY ONE TOOL: search_docs(query, max_results). It performs a keyword "
"search over the documentation and returns the matching pages. Each result is an object "
'with "title", "url" (e.g. /docs/authentication.html), "score" (BM25 relevance, higher '
'means more relevant), and "content" (the page text).\n\n'
"HARD RULES - follow every one, every turn:\n"
"1. Ground every CLAIM ABOUT DEVPLACE (routes, endpoints, parameters, auth methods, "
"settings, behavior, limits) in content returned by search_docs in THIS turn. Never state "
"a platform fact from memory or assumption.\n"
"2. For EVERY question, your FIRST action is to call search_docs with focused keywords "
"drawn from the question (feature names, endpoint paths, nouns).\n"
"3. READ the content in the results. If they do not fully and confidently answer the "
"question, call search_docs AGAIN with different or more specific keywords (synonyms, "
"related terms, the exact feature or route). Keep searching recursively - visiting the "
"results, refining the query, searching again - until you have gathered enough grounded "
"information to answer. Prefer several targeted searches over one broad search.\n"
"4. Do not invent routes, endpoints, parameters, settings, or behavior that the docs did "
"not state. The platform-specific facts in your answer must come from the docs.\n"
"5. If, after several distinct searches, the documentation genuinely does not cover a "
"needed FACT, say which part is undocumented - but still help as far as the docs allow "
"(for example, write the code using the endpoints you DID find).\n"
"6. Your only tool is search_docs and you perform no platform actions (no posting, "
"account changes, file edits, or web browsing). Writing code and examples in your reply "
"is allowed and encouraged.\n\n"
"WRITING CODE - when the user asks for a script, code example, API client, bot, or "
"snippet in ANY language, WRITE complete, runnable example code. Do NOT refuse merely "
"because the literal source is not published in the docs - synthesize it from the "
"documented API: take the base URL, endpoints, HTTP methods, parameters and "
"authentication from your search_docs results, and write all the ordinary programming "
"scaffolding (imports, language syntax, error handling, a main loop, comments) yourself. "
"Put it in a fenced code block with a language tag. Never invent endpoints or parameters "
"the docs do not describe; if a detail you need is missing, search for it first.\n\n"
"LINKING - this is mandatory:\n"
"- ACTIVE INLINE LINKING: whenever you mention a documented page, feature, endpoint, or "
"concept in your prose, write it as a markdown link to that page's `url`, for example "
"[authentication](/docs/authentication.html). Use the exact `url` from the search "
"results. Never paste a bare URL - always a markdown link with descriptive text.\n"
"- ALWAYS end every answer with a '## References' section: a markdown bullet list of the "
"documentation pages you actually used to answer, each as a clickable markdown link "
"followed by its relevance score, highest score first, e.g. "
"`- [Authentication](/docs/authentication.html) - score 12.3`. Include only pages you "
"used; do not invent links or scores.\n\n"
"STYLE: concise, practical, technical. Use markdown."
)
def _parse_topic(raw: str) -> tuple[str, str]:
import json
import re
match = re.search(r"\{.*\}", raw or "", re.S)
if not match:
return "follow_up", ""
try:
data = json.loads(match.group())
except (ValueError, TypeError):
return "follow_up", ""
decision = (
"new" if str(data.get("decision", "")).strip().lower() == "new" else "follow_up"
)
reason = str(data.get("reason", "")).strip()[:200]
return decision, reason
def _system_prompt_for(is_admin: bool) -> str:
if is_admin:
+15 -6
View File
@@ -28,11 +28,13 @@ def _iso(moment: datetime) -> str:
class ConversationStore:
def load(self, owner_kind: str, owner_id: str) -> list[dict[str, Any]] | None:
def load(
self, owner_kind: str, owner_id: str, channel: str = "main"
) -> list[dict[str, Any]] | None:
if CONVERSATIONS not in db.tables:
return None
row = get_table(CONVERSATIONS).find_one(
owner_kind=owner_kind, owner_id=owner_id
owner_kind=owner_kind, owner_id=owner_id, channel=channel
)
if not row or not row.get("messages"):
return None
@@ -43,18 +45,23 @@ class ConversationStore:
return None
def save(
self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]
self,
owner_kind: str,
owner_id: str,
messages: list[dict[str, Any]],
channel: str = "main",
) -> None:
now = _iso(_now())
record = {
"owner_kind": owner_kind,
"owner_id": owner_id,
"channel": channel,
"messages": json.dumps(messages),
"updated_at": now,
}
table = get_table(CONVERSATIONS)
existing = (
table.find_one(owner_kind=owner_kind, owner_id=owner_id)
table.find_one(owner_kind=owner_kind, owner_id=owner_id, channel=channel)
if CONVERSATIONS in db.tables
else None
)
@@ -64,9 +71,11 @@ class ConversationStore:
record["created_at"] = now
table.insert(record)
def clear(self, owner_kind: str, owner_id: str) -> None:
def clear(self, owner_kind: str, owner_id: str, channel: str = "main") -> None:
if CONVERSATIONS in db.tables:
get_table(CONVERSATIONS).delete(owner_kind=owner_kind, owner_id=owner_id)
get_table(CONVERSATIONS).delete(
owner_kind=owner_kind, owner_id=owner_id, channel=channel
)
class UsageLedger:
+318
View File
@@ -0,0 +1,318 @@
/* retoor <retoor@molodetz.nl> */
dp-docs-chat {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 60vh;
}
.docs-chat-log {
display: flex;
flex-direction: column;
gap: 0.875rem;
overflow-y: auto;
padding: 0.25rem;
flex: 1;
max-height: 65vh;
}
.docs-chat-msg {
display: flex;
}
.docs-chat-msg.user {
justify-content: flex-end;
}
.docs-chat-msg.agent,
.docs-chat-msg.error {
justify-content: flex-start;
}
.docs-chat-bubble {
max-width: 85%;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.docs-chat-msg.user .docs-chat-bubble {
background: var(--accent-light);
border-color: var(--accent);
border-bottom-right-radius: var(--radius);
white-space: pre-wrap;
}
.docs-chat-msg.agent .docs-chat-bubble {
background: var(--bg-card);
border-bottom-left-radius: var(--radius);
}
.docs-chat-msg.error .docs-chat-bubble {
background: var(--bg-card);
border-color: var(--danger);
color: var(--danger);
}
.docs-chat-bubble > :first-child {
margin-top: 0;
}
.docs-chat-bubble > :last-child {
margin-bottom: 0;
}
.docs-chat-bubble p {
margin: 0 0 0.5rem;
}
.docs-chat-bubble h1,
.docs-chat-bubble h2,
.docs-chat-bubble h3,
.docs-chat-bubble h4 {
margin: 0.75rem 0 0.5rem;
font-size: 1rem;
color: var(--text-primary);
}
.docs-chat-bubble ul,
.docs-chat-bubble ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.docs-chat-bubble li {
margin-bottom: 0.25rem;
}
.docs-chat-bubble a {
color: var(--accent);
text-decoration: none;
}
.docs-chat-bubble a:hover {
text-decoration: underline;
}
.docs-chat-bubble .md-code {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.05rem 0.3rem;
font-family: var(--font-mono, monospace);
font-size: 0.85em;
}
.docs-chat-bubble .md-codewrap {
position: relative;
margin: 0.5rem 0;
}
.docs-chat-bubble .md-pre {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 0.875rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.5;
}
.docs-chat-bubble .md-pre code {
background: none;
border: none;
padding: 0;
font-family: var(--font-mono, monospace);
}
.docs-chat-bubble .md-copy {
position: absolute;
top: 0.375rem;
right: 0.375rem;
opacity: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: opacity 0.15s ease;
}
.docs-chat-bubble .md-codewrap:hover .md-copy,
.docs-chat-bubble .md-copy:focus-visible {
opacity: 1;
}
.docs-chat-bubble .md-copy:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.docs-chat-bubble .md-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin: 0.5rem 0;
}
.docs-chat-bubble .md-table th,
.docs-chat-bubble .md-table td {
border: 1px solid var(--border);
padding: 0.375rem 0.5rem;
text-align: left;
}
.docs-chat-bubble .md-table th {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.docs-chat-bubble .md-img {
max-width: 100%;
border-radius: var(--radius);
}
.docs-chat-step {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted);
padding: 0.1rem 0.25rem;
}
.docs-chat-step-icon {
flex: 0 0 auto;
width: 0.85rem;
height: 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: 0.8rem;
line-height: 1;
}
.docs-chat-step.pending .docs-chat-step-icon {
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: docs-chat-spin 0.7s linear infinite;
}
.docs-chat-step.done {
color: var(--text-secondary);
}
.docs-chat-step.done .docs-chat-step-icon::before {
content: "\2713";
color: var(--success);
}
.docs-chat-step.error .docs-chat-step-icon::before {
content: "\2717";
color: var(--danger);
}
.docs-chat-step-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes docs-chat-spin {
to {
transform: rotate(360deg);
}
}
.docs-chat-note {
font-size: 0.78rem;
font-style: italic;
color: var(--text-muted);
padding: 0.1rem 0.25rem;
}
.docs-chat-note.new {
color: var(--accent);
font-style: normal;
}
.docs-chat-status {
font-size: 0.8125rem;
color: var(--text-muted);
padding-left: 0.25rem;
min-height: 1rem;
}
.docs-chat-status:not(:empty)::after {
content: " …";
animation: docs-chat-blink 1.2s steps(1) infinite;
}
@keyframes docs-chat-blink {
50% {
opacity: 0.3;
}
}
.docs-chat-input {
display: flex;
gap: 0.5rem;
align-items: flex-end;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}
.docs-chat-field {
flex: 1;
resize: none;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.75rem;
font: inherit;
font-size: 0.9375rem;
line-height: 1.5;
max-height: 160px;
}
.docs-chat-field:focus {
outline: none;
border-color: var(--accent);
}
.docs-chat-send {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.625rem 1.25rem;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.docs-chat-send:hover {
filter: brightness(1.08);
}
.docs-chat-send:disabled {
opacity: 0.6;
cursor: default;
}
@media (max-width: 640px) {
.docs-chat-bubble {
max-width: 100%;
}
}
@@ -0,0 +1,343 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import DeviiSocket from "../devii/DeviiSocket.js";
import Markdown from "../devii/markdown.js";
const DOCS_CHAT_CSS_ID = "docs-chat-css";
const CHANNEL = "docs";
export class AppDocsChat extends Component {
constructor() {
super();
this.markdown = new Markdown();
this.socket = null;
this._seeded = false;
this._bootstrapped = false;
this._pending = {};
this._lastHistoryQuestion = null;
}
_ensureCss() {
if (document.getElementById(DOCS_CHAT_CSS_ID)) return;
const link = document.createElement("link");
link.id = DOCS_CHAT_CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/docs-chat.css";
document.head.appendChild(link);
}
connectedCallback() {
if (this._built) return;
this._built = true;
this._build();
this._bindSidebar();
this._start();
}
disconnectedCallback() {
if (this.socket) this.socket.close();
}
_build() {
this.innerHTML = "";
this.log = document.createElement("div");
this.log.className = "docs-chat-log";
this.log.setAttribute("role", "log");
this.log.setAttribute("aria-live", "polite");
this.status = document.createElement("div");
this.status.className = "docs-chat-status";
this.status.hidden = true;
this.form = document.createElement("form");
this.form.className = "docs-chat-input";
this.field = document.createElement("textarea");
this.field.className = "docs-chat-field";
this.field.rows = 1;
this.field.placeholder = "Ask a question about DevPlace...";
this.field.setAttribute("aria-label", "Ask the documentation");
this.send = document.createElement("button");
this.send.type = "submit";
this.send.className = "docs-chat-send";
this.send.textContent = "Ask";
this.form.append(this.field, this.send);
this.append(this.log, this.status, this.form);
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this._submit(this.field.value);
});
this.field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this._submit(this.field.value);
}
});
this.field.addEventListener("input", () => this._autosize());
this.log.addEventListener("click", (event) => this._onLogClick(event));
}
_bindSidebar() {
const search = document.querySelector("form.docs-search-form");
if (!search) return;
const input = search.querySelector("input[name='q']");
search.addEventListener("submit", (event) => {
event.preventDefault();
const value = input ? input.value : "";
this._submit(value);
if (input) input.value = "";
});
}
async _start() {
try {
await fetch("/devii/session", { credentials: "same-origin" });
} catch (error) {
// Best-effort: ensures the guest cookie exists before the socket opens.
}
this.socket = new DeviiSocket(
{
onReady: () => this._onReady(),
onMessage: (message) => this._onMessage(message),
onClose: () => this._setStatus("Disconnected. Reconnecting..."),
onError: () => this._setStatus("Connection error."),
},
CHANNEL,
);
this.socket.connect();
}
_onReady() {
this._setStatus("");
}
_onMessage(message) {
const kind = message.type;
if (kind === "reply") {
this._resolvePending();
this._agent(message.text);
if (!this._bootstrapped) {
this._bootstrapped = true;
this._maybeSeed();
}
this._setStatus("");
} else if (kind === "history") {
this._renderHistory(message.messages);
this._bootstrapped = true;
this._maybeSeed();
} else if (kind === "clear") {
this.log.innerHTML = "";
} else if (kind === "topic") {
this._topic(message);
} else if (kind === "error") {
this._resolvePending();
this._errorLine(message.text);
this._setStatus("");
} else if (kind === "status") {
this._setStatus(message.text);
} else if (kind === "trace") {
this._trace(message);
} else if (kind === "task") {
this._setStatus("Working...");
} else if (kind === "avatar" && message.id) {
this.socket.send({ type: "avatar_result", id: message.id, result: {} });
} else if (kind === "client" && message.id) {
this.socket.send({ type: "client_result", id: message.id, result: {} });
}
}
_maybeSeed() {
if (this._seeded) return;
this._seeded = true;
const seed = this.attr("seed").trim();
if (!seed) return;
if (seed === this._lastHistoryQuestion) return;
this._submit(seed);
}
_submit(raw) {
const text = (raw || "").trim();
if (!text) return;
this._pending = {};
this._lastQuestion = text;
this._userLine(text);
this.field.value = "";
this._autosize();
if (this.socket && this.socket.send({ type: "input", text })) {
this._setStatus("Docii is searching the docs");
} else {
this._errorLine("Not connected.");
}
}
_topic(message) {
const reason = (message.reason || "").trim();
if (message.decision === "new") {
this.log.innerHTML = "";
this._pending = {};
if (this._lastQuestion) this._userLine(this._lastQuestion);
this._noteLine(
reason ? `New topic - ${reason}` : "New topic - starting fresh",
"new",
);
} else {
this._noteLine(reason ? `Follow-up - ${reason}` : "Follow-up", "follow");
}
}
_noteLine(text, kind) {
const row = document.createElement("div");
row.className = `docs-chat-note ${kind}`;
row.textContent = text;
this._append(row);
}
_trace(message) {
const event = message.event;
const name = message.name || "";
const detail = message.detail || "";
if (event === "call") {
const row = this._stepRow(name, detail);
this._append(row);
(this._pending[name] = this._pending[name] || []).push(row);
this._setStatus("");
} else if (event === "ok" || event === "err") {
const queue = this._pending[name];
const row = queue && queue.shift();
if (row) {
row.classList.remove("pending");
row.classList.add(event === "ok" ? "done" : "error");
}
} else {
this._setStatus(this._eventLabel(event));
}
}
_resolvePending() {
Object.values(this._pending).forEach((queue) => {
(queue || []).forEach((row) => {
row.classList.remove("pending");
row.classList.add("done");
});
});
this._pending = {};
}
_stepRow(name, detail) {
const row = document.createElement("div");
row.className = "docs-chat-step pending";
const icon = document.createElement("span");
icon.className = "docs-chat-step-icon";
const label = document.createElement("span");
label.className = "docs-chat-step-label";
label.textContent = this._stepLabel(name, detail);
row.append(icon, label);
return row;
}
_stepLabel(name, detail) {
if (name === "search_docs") {
return detail ? `Searching the docs: ${detail}` : "Searching the docs";
}
return detail ? `${name}: ${detail}` : name;
}
_eventLabel(event) {
const labels = {
compact: "Condensing context",
"plan-gate": "Planning",
"reflect-trigger": "Reconsidering",
"verify-gate": "Verifying",
};
return labels[event] || "Working";
}
_userLine(text) {
const bubble = this._bubble("user");
bubble.textContent = text;
this._append(bubble.parentElement);
}
_agent(text) {
const bubble = this._bubble("agent");
bubble.innerHTML = this._renderMarkdown(text);
this._append(bubble.parentElement);
}
_errorLine(text) {
const bubble = this._bubble("error");
bubble.textContent = text;
this._append(bubble.parentElement);
}
_bubble(kind) {
const row = document.createElement("div");
row.className = `docs-chat-msg ${kind}`;
const bubble = document.createElement("div");
bubble.className = "docs-chat-bubble";
row.appendChild(bubble);
return bubble;
}
_renderMarkdown(text) {
const html = this.markdown.render(text);
if (window.DOMPurify && typeof window.DOMPurify.sanitize === "function") {
return window.DOMPurify.sanitize(html);
}
return html;
}
_renderHistory(messages) {
this.log.innerHTML = "";
(messages || []).forEach((message) => {
if (message.role === "user") {
this._userLine(message.content);
this._lastHistoryQuestion = (message.content || "").trim();
} else if (message.role === "assistant" && message.content) {
this._agent(message.content);
}
});
}
_onLogClick(event) {
const button = event.target.closest(".md-copy");
if (!button) return;
const wrap = button.closest(".md-codewrap");
const code = wrap && wrap.querySelector("code");
if (!code) return;
const label = button.textContent;
const restore = () => {
button.textContent = "Copied";
window.setTimeout(() => {
button.textContent = label;
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code.textContent).then(restore, () => {});
}
}
_append(node) {
const pinned = this._atBottom();
this.log.appendChild(node);
if (pinned) this.log.scrollTop = this.log.scrollHeight;
}
_atBottom() {
return this.log.scrollHeight - this.log.scrollTop - this.log.clientHeight < 80;
}
_autosize() {
this.field.style.height = "auto";
this.field.style.height = `${Math.min(this.field.scrollHeight, 160)}px`;
}
_setStatus(text) {
this.status.textContent = text || "";
this.status.hidden = !text;
}
}
customElements.define("dp-docs-chat", AppDocsChat);
+1
View File
@@ -8,4 +8,5 @@ import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
import "./AppLightbox.js";
import "./AppDocsChat.js";
import "./ContainerTerminal.js";
+3 -2
View File
@@ -5,10 +5,11 @@ const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export default class DeviiSocket {
constructor(handlers) {
constructor(handlers, channel = "main") {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
const base = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
this.url = channel && channel !== "main" ? base + "?channel=" + encodeURIComponent(channel) : base;
this._shouldRun = true;
this._settled = false;
}
+9
View File
@@ -56,6 +56,15 @@
<h3 style="font-size: 0.875rem; font-weight: 700; margin-bottom: 0.75rem; color: var(--text-secondary);">Operational</h3>
<div class="admin-field">
<label for="docs_search_mode">Docs Search</label>
<select id="docs_search_mode" name="docs_search_mode" style="max-width: 220px;">
<option value="agent" {% if settings.get('docs_search_mode', 'agent') == 'agent' %}selected{% endif %}>AI agent (conversational)</option>
<option value="bm25" {% if settings.get('docs_search_mode', 'agent') == 'bm25' %}selected{% endif %}>Keyword (BM25)</option>
</select>
<small class="hint-text">AI agent renders the in-page Devii chat on the docs search page. Keyword uses the classic BM25 results list. Users who exceed their daily AI limit (or guests when Devii is disabled) fall back to keyword search automatically.</small>
</div>
<div class="admin-field">
<label for="registration_open">Registration</label>
<select id="registration_open" name="registration_open" style="max-width: 160px;">
+5
View File
@@ -0,0 +1,5 @@
<div class="docs-search docs-chat-page">
<h1>Ask Docii</h1>
<p class="docs-chat-intro">Docii is the documentation assistant. Ask a question in plain language and it searches the docs, reading the relevant pages until it has your answer.</p>
<dp-docs-chat seed="{{ search_query|default('', true) }}"></dp-docs-chat>
</div>
+3
View File
@@ -1,5 +1,8 @@
<div class="docs-search">
<h1>Search documentation</h1>
{% if quota_fallback %}
<p class="docs-search-hint">You have reached your daily AI assistant limit. Showing keyword search results instead.</p>
{% endif %}
<form class="docs-search-bigform" method="get" action="/docs/search.html" role="search">
<input type="search" name="q" value="{{ search_query|default('', true) }}" placeholder="Search the docs…" aria-label="Search documentation" class="docs-search-input" autofocus>
<button type="submit" class="btn btn-primary">Search</button>
+5 -2
View File
@@ -44,7 +44,11 @@
</aside>
<article class="docs-main">
{% if kind == 'search' %}
{% include "docs/_search.html" %}
{% if search_mode == 'agent' %}
{% include "docs/_chat.html" %}
{% else %}
{% include "docs/_search.html" %}
{% endif %}
{% elif kind == 'api' %}
{% include "docs/_api_page.html" %}
{% elif prose_html %}
@@ -54,7 +58,6 @@
{% endif %}
</article>
</div>
<span data-devii-autoopen hidden></span>
{% endblock %}
{% block extra_js %}
<script type="module" src="/static/js/ApiDocs.js"></script>