feat(my-deepagent): v0.4 chat UX boost + A/B live verification
Claude-Code 동급 chat 경험으로 끌어올림 + 7개 핵심 흐름 실제 OpenRouter verify.
A — Live verification (scripts/live_verify.py, 7 PASS, 약 $0.02):
- A1 1-turn chat (CLI-eq) → Haiku 4.5 한국어 응답
- A2 sessions resume → 같은 session_id 재투입 시 LangGraph state 복원
- A3 /skill <name> system inject → SKILL.md ("한국어 haiku 3 lines") 가 정확히
3행 한국어 시 생성 (LLM 행동 제어 강력한 증거)
- A4 /plan → /approve → LLM plan markdown only, 차단 도구 시도 없음
- A5 /agents spawn → 실제 sub-agent ainvoke + parent stream push
- A6 auto-compaction → 14 메시지 → 4 archive + 77 토큰 summary
- A7 /workflow wiring → role↔persona 매칭 사전 검증
B1 — Markdown rendering:
- app.js pure-JS 미니 파서: 코드 펜스 / ATX 헤더 / ul/ol / `code`/**bold**/
*italic*/[link](url)
- XSS 정책 유지: createElement + textContent only. 링크 href 는 http(s):
스킴 강제.
B2 — System event card (collapsible):
- _classifySystemMessage 가 [sub-agent .../workflow .../Earlier conversation
history/당신은 plan mode/The user APPROVED/skill] 접두사 분류 후 <details>
카드로 렌더.
B3 — Token streaming via AsyncCallbackHandler:
- ChatOpenAI(streaming=True)
- _StreamingChunkPusher (AsyncCallbackHandler) → asyncio.Queue per session.
- SSE _session_event_stream 이 queue drain → event: chunk SSE. 100ms poll.
- 순서 보장: chunk drain → message rows yield (placeholder 가 메시지로
교체되기 전에 토큰 visible).
- 라이브: 5 chunk events + 1 final message, "안녕하세요, / 무 / 엇을 도와드 /
릴까요?" 토큰 단위 push.
B4 — Cancel mid-turn:
- POST /api/sessions/{id}/abort + app.state.pending_per_session 인덱스.
- 새 user 메시지 도착 시 이전 in-flight task 자동 cancel.
- "■ 중단" 버튼 — 대기 중 visible, 완료/취소 시 hide.
B5 — IME composition-safe Enter:
- compositionstart/compositionend 플래그 — 한글 IME 후보 commit Enter 무시.
- Cmd/Ctrl+Enter 는 항상 전송.
DB hot-fix:
- Database.__init__ pool_pre_ping=True — Postgres asyncpg stale connection
→ SSE 부하에서 500 발생 해결.
기타:
- createNewSession 의 repo_path: "" → "." (min_length=1 검증 통과).
- test_conversation_gui.py fake_invoke 가 chunk_queue kwarg 받도록 업데이트.
게이트:
- ruff / format / mypy: PASS (143 source files)
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
--ignore=tests/integration/test_openrouter_smoke.py: 709 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -424,6 +424,7 @@ const CONV_STATE = {
|
||||
eventSource: null,
|
||||
lastSeq: 0,
|
||||
awaitingReply: false,
|
||||
streamBuffer: "", // v0.4 B3: accumulated token deltas while streaming
|
||||
};
|
||||
|
||||
function $conv(sel) { return document.querySelector(sel); }
|
||||
@@ -433,6 +434,15 @@ function setSendDisabled(disabled) {
|
||||
$conv("#send-btn").disabled = disabled;
|
||||
}
|
||||
|
||||
// v0.4 B4: toggle the abort button visibility based on in-flight state.
|
||||
// `disabled` is what setSendDisabled sees AFTER awaiting reply has started.
|
||||
function setAbortVisible(visible) {
|
||||
const btn = $conv("#abort-btn");
|
||||
if (!btn) return;
|
||||
btn.style.display = visible ? "inline-block" : "none";
|
||||
btn.disabled = !visible;
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
const list = $conv("#messages");
|
||||
list.replaceChildren();
|
||||
@@ -456,7 +466,179 @@ function showConversationEmpty(show, text) {
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessageBubble(role, content, ts) {
|
||||
// v0.4 B1: minimal markdown renderer for assistant messages.
|
||||
// SECURITY: we ONLY emit DOM nodes built via createElement + textContent.
|
||||
// No innerHTML, no insertAdjacentHTML. This is a tiny subset of Markdown
|
||||
// chosen for chat readability — anything we don't understand is rendered as
|
||||
// literal text (textContent fallback in the default case).
|
||||
function _mdRenderInto(target, raw) {
|
||||
// Code-fence-aware splitter — we walk the input line-by-line and group
|
||||
// lines into blocks (paragraph, code-fence, h#, list).
|
||||
const lines = raw.split("\n");
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block: ```lang
|
||||
const fence = line.match(/^```\s*([\w.-]*)\s*$/);
|
||||
if (fence) {
|
||||
const lang = fence[1];
|
||||
const codeLines = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // consume closing ```
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "md-code";
|
||||
const code = document.createElement("code");
|
||||
if (lang) code.className = `language-${lang}`;
|
||||
code.textContent = codeLines.join("\n");
|
||||
pre.appendChild(code);
|
||||
target.appendChild(pre);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX header: # / ## / ### (up to 6)
|
||||
const hdr = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (hdr) {
|
||||
const level = hdr[1].length;
|
||||
const h = document.createElement(`h${level + 2 > 6 ? 6 : level + 2}`);
|
||||
h.className = "md-h";
|
||||
_mdInline(h, hdr[2]);
|
||||
target.appendChild(h);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list block — consecutive "- " or "* "
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "md-ul";
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
||||
const li = document.createElement("li");
|
||||
_mdInline(li, lines[i].replace(/^[-*]\s+/, ""));
|
||||
ul.appendChild(li);
|
||||
i++;
|
||||
}
|
||||
target.appendChild(ul);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list: "1. ", "2. ", …
|
||||
if (/^\d+\.\s+/.test(line)) {
|
||||
const ol = document.createElement("ol");
|
||||
ol.className = "md-ol";
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
||||
const li = document.createElement("li");
|
||||
_mdInline(li, lines[i].replace(/^\d+\.\s+/, ""));
|
||||
ol.appendChild(li);
|
||||
i++;
|
||||
}
|
||||
target.appendChild(ol);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line — paragraph separator; skip.
|
||||
if (line.trim() === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: greedily consume until blank or block-start.
|
||||
const paraLines = [line];
|
||||
i++;
|
||||
while (
|
||||
i < lines.length
|
||||
&& lines[i].trim() !== ""
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^#{1,6}\s+/.test(lines[i])
|
||||
&& !/^[-*]\s+/.test(lines[i])
|
||||
&& !/^\d+\.\s+/.test(lines[i])
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const p = document.createElement("p");
|
||||
p.className = "md-p";
|
||||
_mdInline(p, paraLines.join("\n"));
|
||||
target.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline parser: handles `code`, **bold**, *italic*, [link](url).
|
||||
// Emits DOM nodes; never innerHTML.
|
||||
function _mdInline(target, text) {
|
||||
// Walk the string, matching the earliest-occurring inline pattern.
|
||||
let remaining = text;
|
||||
while (remaining.length > 0) {
|
||||
const matches = [
|
||||
{ re: /`([^`]+)`/, tag: "code" },
|
||||
{ re: /\*\*([^*\n]+)\*\*/, tag: "strong" },
|
||||
{ re: /(?<!\*)\*([^*\n]+)\*(?!\*)/, tag: "em" },
|
||||
{ re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: "a" },
|
||||
];
|
||||
let best = null;
|
||||
for (const m of matches) {
|
||||
const hit = remaining.match(m.re);
|
||||
if (hit && (best === null || hit.index < best.hit.index)) {
|
||||
best = { hit, tag: m.tag };
|
||||
}
|
||||
}
|
||||
if (best === null) {
|
||||
target.appendChild(document.createTextNode(remaining));
|
||||
return;
|
||||
}
|
||||
if (best.hit.index > 0) {
|
||||
target.appendChild(document.createTextNode(remaining.slice(0, best.hit.index)));
|
||||
}
|
||||
const el = document.createElement(best.tag);
|
||||
if (best.tag === "a") {
|
||||
// Link: cap protocol to http/https to avoid javascript: scheme escapes.
|
||||
const href = best.hit[2];
|
||||
if (/^https?:\/\//.test(href)) el.href = href;
|
||||
el.rel = "noopener noreferrer";
|
||||
el.target = "_blank";
|
||||
el.textContent = best.hit[1];
|
||||
} else {
|
||||
el.textContent = best.hit[1];
|
||||
}
|
||||
target.appendChild(el);
|
||||
remaining = remaining.slice(best.hit.index + best.hit[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.4 B2: classify system messages into collapsible "event cards" so the
|
||||
// chat thread doesn't drown in [sub-agent ... spawned] / [workflow ... started]
|
||||
// notices. Returns a label + an emoji-style icon + whether to default to open.
|
||||
function _classifySystemMessage(content) {
|
||||
if (content.startsWith("[sub-agent")) {
|
||||
if (content.includes("result]")) return { label: "Sub-agent result", icon: "🤖", open: true };
|
||||
if (content.includes("error]")) return { label: "Sub-agent error", icon: "⚠️", open: true };
|
||||
return { label: "Sub-agent spawned", icon: "🚀", open: false };
|
||||
}
|
||||
if (content.startsWith("[workflow")) {
|
||||
if (content.includes("started]")) return { label: "Workflow started", icon: "🛠️", open: false };
|
||||
if (content.includes("failed]")) return { label: "Workflow failed", icon: "❌", open: true };
|
||||
return { label: "Workflow event", icon: "✅", open: true };
|
||||
}
|
||||
if (content.startsWith("Earlier conversation history")) {
|
||||
return { label: "Compaction summary", icon: "📝", open: false };
|
||||
}
|
||||
if (content.startsWith("당신은 plan mode")) {
|
||||
return { label: "Plan mode activated", icon: "🧭", open: false };
|
||||
}
|
||||
if (content.startsWith("The user APPROVED")) {
|
||||
return { label: "Approved plan", icon: "✅", open: false };
|
||||
}
|
||||
if (content.startsWith("The user requested skill")) {
|
||||
return { label: "Skill activated", icon: "🪄", open: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendMessageBubble(role, content, ts, opts) {
|
||||
showConversationEmpty(false);
|
||||
const list = $conv("#messages");
|
||||
const bubble = document.createElement("div");
|
||||
@@ -471,13 +653,47 @@ function appendMessageBubble(role, content, ts) {
|
||||
tsSpan.textContent = (ts || "").slice(11, 19);
|
||||
meta.appendChild(roleSpan);
|
||||
if (ts) meta.appendChild(tsSpan);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
body.textContent = content;
|
||||
|
||||
if (role === "system") {
|
||||
// Collapsible event card if we recognise the format; otherwise plain.
|
||||
const cls = _classifySystemMessage(content);
|
||||
if (cls !== null) {
|
||||
bubble.classList.add("role-system-event");
|
||||
const det = document.createElement("details");
|
||||
det.className = "md-system-event";
|
||||
if (cls.open) det.open = true;
|
||||
const sum = document.createElement("summary");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "event-icon";
|
||||
icon.textContent = cls.icon;
|
||||
const label = document.createElement("span");
|
||||
label.className = "event-label";
|
||||
label.textContent = cls.label;
|
||||
sum.appendChild(icon);
|
||||
sum.appendChild(label);
|
||||
det.appendChild(sum);
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "event-body";
|
||||
_mdRenderInto(inner, content);
|
||||
det.appendChild(inner);
|
||||
body.appendChild(det);
|
||||
} else {
|
||||
_mdRenderInto(body, content);
|
||||
}
|
||||
} else if (role === "assistant" || (opts && opts.renderMarkdown)) {
|
||||
_mdRenderInto(body, content);
|
||||
} else {
|
||||
body.textContent = content;
|
||||
}
|
||||
|
||||
bubble.appendChild(meta);
|
||||
bubble.appendChild(body);
|
||||
list.appendChild(bubble);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function appendPendingPlaceholder() {
|
||||
@@ -485,14 +701,48 @@ function appendPendingPlaceholder() {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.id = "pending-placeholder";
|
||||
placeholder.className = "msg-bubble role-assistant pending";
|
||||
placeholder.textContent = "…";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
const roleSpan = document.createElement("span");
|
||||
roleSpan.className = "msg-role";
|
||||
roleSpan.textContent = "assistant";
|
||||
meta.appendChild(roleSpan);
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
body.textContent = "…";
|
||||
placeholder.appendChild(meta);
|
||||
placeholder.appendChild(body);
|
||||
list.appendChild(placeholder);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
// v0.4 B3: keep a buffer for streamed tokens so we can re-render markdown
|
||||
// once the full text arrives.
|
||||
CONV_STATE.streamBuffer = "";
|
||||
}
|
||||
|
||||
function removePendingPlaceholder() {
|
||||
const p = $conv("#pending-placeholder");
|
||||
if (p) p.remove();
|
||||
CONV_STATE.streamBuffer = "";
|
||||
}
|
||||
|
||||
// v0.4 B3: append a streamed token to the pending placeholder's body.
|
||||
function appendStreamDelta(text) {
|
||||
const placeholder = $conv("#pending-placeholder");
|
||||
if (!placeholder) return;
|
||||
if (!CONV_STATE.streamBuffer || CONV_STATE.streamBuffer === "") {
|
||||
// First chunk — replace the "…" indicator.
|
||||
const body = placeholder.querySelector(".msg-body");
|
||||
if (body) body.textContent = "";
|
||||
}
|
||||
CONV_STATE.streamBuffer = (CONV_STATE.streamBuffer || "") + text;
|
||||
const body = placeholder.querySelector(".msg-body");
|
||||
if (body) {
|
||||
// Streaming view: keep plain text for speed, full markdown render only
|
||||
// happens when the final `message` event arrives.
|
||||
body.textContent = CONV_STATE.streamBuffer;
|
||||
}
|
||||
const list = $conv("#messages");
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
function updateSessionStatePill(state) {
|
||||
@@ -550,7 +800,9 @@ async function loadAndAttachSession(sessionId) {
|
||||
|
||||
const messages = detail.messages || [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "system" && !m.is_summary) continue;
|
||||
// v0.4 B2: render system messages too — most map to recognised event
|
||||
// cards (collapsible). Unknown system payloads fall through to plain
|
||||
// markdown rendering.
|
||||
appendMessageBubble(m.role, m.content, m.ts);
|
||||
if (m.seq > CONV_STATE.lastSeq) CONV_STATE.lastSeq = m.seq;
|
||||
}
|
||||
@@ -578,17 +830,34 @@ function attachEventSource(sessionId) {
|
||||
if (data.role === "assistant" && CONV_STATE.awaitingReply) {
|
||||
removePendingPlaceholder();
|
||||
CONV_STATE.awaitingReply = false;
|
||||
setAbortVisible(false);
|
||||
}
|
||||
// Skip system messages except summaries.
|
||||
if (data.role === "system" && !data.is_summary) {
|
||||
CONV_STATE.lastSeq = data.seq;
|
||||
return;
|
||||
}
|
||||
// v0.4 B2: render every system message — most are recognised events
|
||||
// (compaction / sub-agent / workflow / plan / skill) and rendered as
|
||||
// collapsible cards by appendMessageBubble.
|
||||
appendMessageBubble(data.role, data.content, data.ts);
|
||||
CONV_STATE.lastSeq = data.seq;
|
||||
} catch (_) { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
// v0.4 B3: token streaming. Server pushes one chunk per LLM token; we
|
||||
// append to the pending placeholder. When the final "message" SSE arrives
|
||||
// it replaces the streaming text with the markdown-rendered version.
|
||||
src.addEventListener("chunk", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === "delta" && typeof data.text === "string") {
|
||||
appendStreamDelta(data.text);
|
||||
} else if (data.type === "cancelled" || data.type === "error") {
|
||||
// Drop the placeholder; setError already handled or will be by 'message'.
|
||||
removePendingPlaceholder();
|
||||
CONV_STATE.awaitingReply = false;
|
||||
setAbortVisible(false);
|
||||
}
|
||||
// type === "done" is benign — the matching 'message' SSE arrives next.
|
||||
} catch (_) { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
src.addEventListener("done", () => {
|
||||
src.close();
|
||||
if (CONV_STATE.eventSource === src) CONV_STATE.eventSource = null;
|
||||
@@ -610,6 +879,7 @@ async function sendMessage(text) {
|
||||
}
|
||||
if (!text.trim()) return;
|
||||
setSendDisabled(true);
|
||||
setAbortVisible(true);
|
||||
CONV_STATE.awaitingReply = true;
|
||||
appendPendingPlaceholder();
|
||||
try {
|
||||
@@ -623,6 +893,7 @@ async function sendMessage(text) {
|
||||
} catch (e) {
|
||||
removePendingPlaceholder();
|
||||
CONV_STATE.awaitingReply = false;
|
||||
setAbortVisible(false);
|
||||
setError(`전송 실패: ${e.message}`);
|
||||
} finally {
|
||||
setSendDisabled(false);
|
||||
@@ -630,6 +901,19 @@ async function sendMessage(text) {
|
||||
}
|
||||
}
|
||||
|
||||
async function abortInflight() {
|
||||
if (!CONV_STATE.sessionId) return;
|
||||
try {
|
||||
await jsonFetch(`/sessions/${CONV_STATE.sessionId}/abort`, { method: "POST" });
|
||||
removePendingPlaceholder();
|
||||
CONV_STATE.awaitingReply = false;
|
||||
setAbortVisible(false);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(`중단 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewSession() {
|
||||
let personas;
|
||||
try {
|
||||
@@ -647,7 +931,9 @@ async function createNewSession() {
|
||||
const ack = await jsonFetch("/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ persona_name: defaultPersona.name, repo_path: "" }),
|
||||
// CreateSessionRequest requires repo_path min_length=1. We default to
|
||||
// "." (cwd of the serve process) — the backend resolves it to absolute.
|
||||
body: JSON.stringify({ persona_name: defaultPersona.name, repo_path: "." }),
|
||||
});
|
||||
await loadSessionList();
|
||||
$conv("#session-picker").value = ack.session_id;
|
||||
@@ -660,6 +946,7 @@ async function createNewSession() {
|
||||
async function bootstrapConversationPage() {
|
||||
await loadSessionList();
|
||||
$conv("#new-session-btn").addEventListener("click", createNewSession);
|
||||
$conv("#abort-btn").addEventListener("click", abortInflight);
|
||||
$conv("#session-picker").addEventListener("change", (ev) => {
|
||||
const sid = ev.target.value;
|
||||
if (sid) loadAndAttachSession(sid);
|
||||
@@ -669,10 +956,28 @@ async function bootstrapConversationPage() {
|
||||
const input = $conv("#message-input");
|
||||
sendMessage(input.value);
|
||||
});
|
||||
$conv("#message-input").addEventListener("keydown", (ev) => {
|
||||
// v0.4 B5: track IME composition state — Korean/Japanese/Chinese IME emits
|
||||
// Enter to commit the current candidate; we must NOT treat that as send.
|
||||
// compositionend ALSO fires a synthetic Enter that we need to swallow.
|
||||
const input = $conv("#message-input");
|
||||
input._composing = false;
|
||||
input.addEventListener("compositionstart", () => { input._composing = true; });
|
||||
input.addEventListener("compositionend", () => {
|
||||
// The keydown event that ends composition is still pending — defer the
|
||||
// flag flip one tick so the upcoming keydown still sees _composing=true.
|
||||
setTimeout(() => { input._composing = false; }, 0);
|
||||
});
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
// Honor Cmd/Ctrl+Enter as explicit "send" override even during composition.
|
||||
if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
sendMessage(ev.target.value);
|
||||
return;
|
||||
}
|
||||
// Plain Enter during composition (e.g. Korean IME committing 한글) must
|
||||
// pass through to the textarea — do nothing.
|
||||
if (ev.key === "Enter" && input._composing) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
// v0.3 PR #8: deep link `?session=<id>` auto-loads the named session.
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
disabled
|
||||
></textarea>
|
||||
<button id="send-btn" type="submit" disabled>전송</button>
|
||||
<button id="abort-btn" type="button" disabled style="display:none">⏹ 중단</button>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -1093,3 +1093,121 @@ details[open] summary {
|
||||
border-color: rgba(180, 70, 30, 0.5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
v0.4 — Markdown + system event cards in conversation
|
||||
================================================================= */
|
||||
|
||||
.msg-body .md-p {
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.msg-body .md-p:last-child { margin-bottom: 0; }
|
||||
|
||||
.msg-body .md-h {
|
||||
margin: 12px 0 6px 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.msg-body .md-ul,
|
||||
.msg-body .md-ol {
|
||||
margin: 4px 0 8px 0;
|
||||
padding-left: 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.msg-body .md-ul li,
|
||||
.msg-body .md-ol li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.msg-body .md-code {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg-body .md-code code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.msg-body code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.msg-bubble.role-user .msg-body .md-code,
|
||||
.msg-bubble.role-user .msg-body code {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.msg-body a {
|
||||
color: rgb(180, 70, 30);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.msg-bubble.role-user .msg-body a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.msg-body strong { font-weight: 700; }
|
||||
.msg-body em { font-style: italic; }
|
||||
|
||||
/* System event card */
|
||||
.msg-bubble.role-system-event {
|
||||
align-self: stretch;
|
||||
max-width: 100%;
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-style: dashed;
|
||||
font-style: normal;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.md-system-event summary {
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.md-system-event summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.md-system-event summary .event-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.md-system-event summary .event-label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(120, 53, 15);
|
||||
}
|
||||
|
||||
.md-system-event[open] summary {
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px dashed rgba(245, 158, 11, 0.3);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.md-system-event .event-body {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user