forked from retoor/devplacepy
feat: add multi-channel Devii sessions with docs search mode and channel-aware conversation persistence
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user