forked from retoor/devplacepy
feat: add multi-channel Devii sessions with docs search mode and channel-aware conversation persistence
This commit is contained in:
+15
-1
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -8,4 +8,5 @@ import "./AppToast.js";
|
||||
import "./AppDialog.js";
|
||||
import "./AppContextMenu.js";
|
||||
import "./AppLightbox.js";
|
||||
import "./AppDocsChat.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user