mock: give the chat assistant a tool-calling agent loop
The LLM now orchestrates the plan instead of only answering from a
static snapshot. It reads the live plan, calls deterministic client-side
tools, and proposes small fix-ops; the tools validate, compute and
commit, so every edit is undo-able (LLM proposes, tools decide).
This llama.cpp build ignores the native OpenAI `tools` field, so tool
calls use a text protocol: the model emits <tool>{"name","args"}</tool>,
the app executes it and feeds the JSON result back as the next turn; the
loop ends when the model stops calling (capped at 10 turns).
Tools: itinerary_summary, search_places, place_facts, route_between
(real OSRM walk, straight-line fallback), review_plan (day overrun /
meal-hour / duplicate checks), and fix-ops add_stop, remove_stop,
move_stop, set_duration, set_start. Places and stops resolve by id or
name; dayIds are surfaced in the plan snapshot ([D1 · date]) so the model
doesn't guess. A subtle ⚙ activity line shows each tool call in the chat.
Verified end-to-end in headless Chrome: factual routing (route_between),
search+swap (search_places→add_stop, incumbent demoted to backup), and
diagnose+fix (review_plan→move_stop→set_start→re-review), with clean
undo-able commits each time.
This commit is contained in:
parent
af63297297
commit
c7b5ecb8da
256
mock/app.js
256
mock/app.js
|
|
@ -60,30 +60,250 @@ function llmContext() {
|
||||||
layout(dd);
|
layout(dd);
|
||||||
const stops = dd.stops.filter(s => s.state === 'planned').map(
|
const stops = dd.stops.filter(s => s.state === 'planned').map(
|
||||||
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
|
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
|
||||||
L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
|
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
|
||||||
}
|
}
|
||||||
return L.join('\n');
|
return L.join('\n');
|
||||||
}
|
}
|
||||||
function askLLM(v) {
|
// ---------------- LLM agent: tools + calling loop ----------------
|
||||||
const t = typing();
|
// The model is the orchestrator: it reads the plan, calls deterministic
|
||||||
|
// tools (search / route / review), and proposes small fix-ops (add / remove /
|
||||||
|
// move / re-time). The tools decide — they validate, compute, and commit, so
|
||||||
|
// every edit is undo-able. This llama.cpp build does not honor the native
|
||||||
|
// OpenAI `tools` field, so tool calls use a text protocol: the model emits
|
||||||
|
// <tool>{"name":...,"args":{...}}</tool>, we execute it, and feed the JSON
|
||||||
|
// result back as the next turn; the loop ends when the model stops calling.
|
||||||
|
const LLM_TOOL_MAX = 10; // max tool-call turns per user message
|
||||||
|
|
||||||
|
// Live-state lookups (the source of truth is `days`, not the original M.days)
|
||||||
|
function liveStopById(id) {
|
||||||
|
for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function candById(id) {
|
||||||
|
for (const cat of Object.keys(M.candidates)) {
|
||||||
|
const c = M.candidates[cat].find(x => x.id === id);
|
||||||
|
if (c) return { ...c, cat };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Resolve a model-given place ref — "hotel", a stop/candidate id, or a name
|
||||||
|
// (exact then substring) — to {name, at, id}
|
||||||
|
function resolvePlace(ref) {
|
||||||
|
const q = String(ref || '').trim(); const ql = q.toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') {
|
||||||
|
const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' };
|
||||||
|
}
|
||||||
|
const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id };
|
||||||
|
const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id };
|
||||||
|
const all = [];
|
||||||
|
Object.values(days).forEach(d => d.stops.forEach(x => all.push(x)));
|
||||||
|
Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x)));
|
||||||
|
const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql));
|
||||||
|
return hit ? { name: hit.name, at: hit.at, id: hit.id } : null;
|
||||||
|
}
|
||||||
|
// Resolve a live stop by id or name (the model often names stops, not ids)
|
||||||
|
function resolveStop(ref) {
|
||||||
|
const s = liveStopById(ref); if (s) return s;
|
||||||
|
const q = String(ref || '').trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
for (const d of Object.values(days)) {
|
||||||
|
const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q));
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS = {
|
||||||
|
// ---- queries (read-only) ----
|
||||||
|
itinerary_summary: () => ({ plan: llmContext() }),
|
||||||
|
|
||||||
|
search_places: (a) => {
|
||||||
|
const cat = String(a.category || '').toLowerCase();
|
||||||
|
if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` };
|
||||||
|
let region = null;
|
||||||
|
if (a.region) {
|
||||||
|
region = REGION_MAP[String(a.region).toLowerCase()];
|
||||||
|
if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` };
|
||||||
|
}
|
||||||
|
const results = poolFor(cat, region).map(c => ({
|
||||||
|
id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags,
|
||||||
|
pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at),
|
||||||
|
}));
|
||||||
|
return { category: cat, region: a.region || null, count: results.length, results };
|
||||||
|
},
|
||||||
|
|
||||||
|
place_facts: (a) => {
|
||||||
|
const p = resolvePlace(a.id || a.name);
|
||||||
|
if (!p) return { error: `no place matching "${a.id || a.name}"` };
|
||||||
|
const c = candById(p.id) || liveStopById(p.id);
|
||||||
|
return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch,
|
||||||
|
walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null };
|
||||||
|
},
|
||||||
|
|
||||||
|
route_between: async (a) => {
|
||||||
|
const A = resolvePlace(a.from), B = resolvePlace(a.to);
|
||||||
|
if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
|
||||||
|
if (!B) return { error: `cannot resolve "to" = "${a.to}"` };
|
||||||
|
if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' };
|
||||||
|
let minutes, km, source;
|
||||||
|
const res = await routePts([A.at, B.at], 'foot');
|
||||||
|
if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; }
|
||||||
|
else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10;
|
||||||
|
source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; }
|
||||||
|
return { from: A.name, to: B.name, minutes, km, source };
|
||||||
|
},
|
||||||
|
|
||||||
|
review_plan: (a) => {
|
||||||
|
const ids = a.dayId ? [a.dayId] : Object.keys(days);
|
||||||
|
const issues = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; }
|
||||||
|
layout(d);
|
||||||
|
const prim = d.stops.filter(s => s.state === 'planned');
|
||||||
|
const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0);
|
||||||
|
const endMin = d.startMin + planned;
|
||||||
|
const capMin = d.startMin + (d.wakingHours || 11) * 60;
|
||||||
|
if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`);
|
||||||
|
prim.forEach(s => {
|
||||||
|
if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`);
|
||||||
|
if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`);
|
||||||
|
});
|
||||||
|
const seen = {};
|
||||||
|
prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; });
|
||||||
|
if (!prim.length) issues.push(`${d.label}: has no planned stops`);
|
||||||
|
}
|
||||||
|
return { issues: issues.length ? issues : ['no problems found'] };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- fix-ops (mutations; each commits → undo-able) ----
|
||||||
|
add_stop: (a) => {
|
||||||
|
const SLOTS = ['lunch', 'dinner', 'visit'];
|
||||||
|
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` };
|
||||||
|
const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` };
|
||||||
|
const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat;
|
||||||
|
if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' };
|
||||||
|
const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned');
|
||||||
|
const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id);
|
||||||
|
if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan
|
||||||
|
d.stops.push(stop);
|
||||||
|
rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]);
|
||||||
|
return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null };
|
||||||
|
},
|
||||||
|
|
||||||
|
remove_stop: (a) => {
|
||||||
|
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||||
|
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||||||
|
d.stops.splice(d.stops.indexOf(stop), 1);
|
||||||
|
rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll();
|
||||||
|
return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label };
|
||||||
|
},
|
||||||
|
|
||||||
|
move_stop: (a) => {
|
||||||
|
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||||
|
const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` };
|
||||||
|
const fromId = Object.keys(days).find(k => days[k].stops.includes(stop));
|
||||||
|
days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1);
|
||||||
|
to.stops.push(stop);
|
||||||
|
rebuildLegs(days[fromId]); rebuildLegs(to);
|
||||||
|
commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll();
|
||||||
|
return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label };
|
||||||
|
},
|
||||||
|
|
||||||
|
set_duration: (a) => {
|
||||||
|
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||||
|
const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' };
|
||||||
|
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||||||
|
stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' };
|
||||||
|
rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]);
|
||||||
|
return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) };
|
||||||
|
},
|
||||||
|
|
||||||
|
set_start: (a) => {
|
||||||
|
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
|
||||||
|
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
|
||||||
|
d.startMin = h * 60 + mn;
|
||||||
|
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
|
||||||
|
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function llmSystemPrompt() {
|
||||||
|
const cats = Object.keys(M.candidates);
|
||||||
|
const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label);
|
||||||
|
return (
|
||||||
|
'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' +
|
||||||
|
'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' +
|
||||||
|
'<tool>\n{"name":"TOOL_NAME","args":{...}}\n</tool>\n' +
|
||||||
|
'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' +
|
||||||
|
'TOOLS (name — args — what it does):\n' +
|
||||||
|
' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' +
|
||||||
|
' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' +
|
||||||
|
' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
|
||||||
|
' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' +
|
||||||
|
' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
|
||||||
|
' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' +
|
||||||
|
' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' +
|
||||||
|
' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' +
|
||||||
|
' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' +
|
||||||
|
' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n\n' +
|
||||||
|
'Guidelines:\n' +
|
||||||
|
'- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' +
|
||||||
|
'- Answer concisely (1–4 sentences), plain text, no markdown.\n' +
|
||||||
|
'- For any factual question about times, distances, or prices, CALL the tool — do not guess.\n' +
|
||||||
|
'- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' +
|
||||||
|
'- Before a big rework, consider review_plan() and mention any issues it reports.\n' +
|
||||||
|
'- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n\n' +
|
||||||
|
'Current plan:\n' + llmContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a <tool>{...}</tool> block from the model output, if present.
|
||||||
|
function parseToolBlock(text) {
|
||||||
|
const m = /<tool>\s*([\s\S]*?)\s*<\/tool>/.exec(text);
|
||||||
|
if (!m) return null;
|
||||||
|
try { return JSON.parse(m[1]); } catch { return null; }
|
||||||
|
}
|
||||||
|
// A subtle activity line so the user sees the model working its tools.
|
||||||
|
function toolLine(name, args) {
|
||||||
|
const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' <span class="ta">' + esc(JSON.stringify(args)) + '</span>' : ''));
|
||||||
|
$('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askLLM(v) {
|
||||||
scriptBusy = true;
|
scriptBusy = true;
|
||||||
const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' +
|
let t = typing();
|
||||||
'Answer the user\'s message concisely (2–5 sentences), using the plan as ground truth — never invent times, prices, or stops. ' +
|
const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
|
||||||
'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' +
|
const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
|
||||||
'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext();
|
try {
|
||||||
fetch('/llm-chat', {
|
let finalText = null;
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
||||||
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }),
|
const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) });
|
||||||
})
|
const j = await r.json();
|
||||||
.then(r => r.json())
|
const content = (j.choices?.[0]?.message?.content || '').trim();
|
||||||
.then(j => {
|
const call = parseToolBlock(content);
|
||||||
const c = (j.choices?.[0]?.message?.content || '').trim();
|
if (call) {
|
||||||
t.remove();
|
t.remove();
|
||||||
msg('ai', c ? esc(c).replace(/\n+/g, '<br>')
|
if (TOOLS[call.name]) toolLine(call.name, call.args || {});
|
||||||
: 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
t = typing(); // the assistant keeps "thinking"
|
||||||
|
messages.push({ role: 'assistant', content });
|
||||||
|
let res;
|
||||||
|
if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
|
||||||
|
else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') };
|
||||||
|
messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
finalText = content; break;
|
||||||
|
}
|
||||||
|
if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.');
|
||||||
|
else if (finalText) finish(esc(finalText).replace(/\n+/g, '<br>'));
|
||||||
|
else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
||||||
|
} catch (e) {
|
||||||
|
t.remove();
|
||||||
|
msg('ai', 'LLM call failed — falling back to canned replies.');
|
||||||
scriptBusy = false;
|
scriptBusy = false;
|
||||||
})
|
}
|
||||||
.catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; });
|
|
||||||
}
|
}
|
||||||
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
|
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
|
||||||
// OSRM encodes with precision 5 (unlike Google’s default 6)
|
// OSRM encodes with precision 5 (unlike Google’s default 6)
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,8 @@ button { font: inherit; }
|
||||||
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
||||||
.msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; }
|
.msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; }
|
||||||
|
.msg.tool { align-self: flex-start; background: #f5f1fb; border: 1px solid #e5dbf2; border-radius: 10px; padding: 6px 10px; font-size: 11.5px; font-family: ui-monospace, Menlo, Consolas, monospace; color: #5d3a8a; max-width: 94%; }
|
||||||
|
.msg.tool .ta { color: #8a6116; }
|
||||||
.msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; }
|
.msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; }
|
||||||
.msg .src a { color: var(--blue); text-decoration: none; }
|
.msg .src a { color: var(--blue); text-decoration: none; }
|
||||||
.typing { display: inline-flex; gap: 4px; padding: 12px 14px; }
|
.typing { display: inline-flex; gap: 4px; padding: 12px 14px; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user