Add document-review UIWorker example
Synthesis example: a ReplyToolMixin UIWorker adds a start_review tool that fans out to clarity/tone peers via start_user_job_group, translates each reviewer response into an add_note command in on_job_response, handles a client note_click event via @on_ui_event, and keeps history across turns.
This commit is contained in:
26
examples/multi-worker/ui-worker/document-review/client/.gitignore
vendored
Normal file
26
examples/multi-worker/ui-worker/document-review/client/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.vite
|
||||
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document review — UIAgent demo</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Manuscript review</h1>
|
||||
<button id="connect" type="button">Connect</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="document" aria-label="Draft">
|
||||
<article>
|
||||
<h2>The quiet revolution of asynchronous work</h2>
|
||||
|
||||
<p class="lede">A draft. Select any paragraph and ask for review.</p>
|
||||
|
||||
<p>
|
||||
Five years ago, working remotely was the unusual choice.
|
||||
Today it is the unremarkable one. The shift happened too
|
||||
quickly for most organizations to absorb its second-order
|
||||
effects, and the conversation has barely caught up to where
|
||||
work actually is.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The asynchronous-first model that emerged out of necessity in
|
||||
2020 and has since become structural across knowledge work
|
||||
has produced a strange paradox in which workers report higher
|
||||
autonomy and satisfaction than at any point in the postwar
|
||||
era while simultaneously reporting greater isolation, lower
|
||||
trust in leadership, and a measurable decline in the quality
|
||||
of collaboration on novel problems, which suggests not that
|
||||
remote work is good or bad but that we have not yet learned
|
||||
to use the tool we built ourselves.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
There are real benefits and real costs. Different people
|
||||
experience it differently. Each company has to find its own
|
||||
way.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Anyone who claims that in-person work is more productive is
|
||||
simply wrong. The research is unanimous. Decades of management
|
||||
orthodoxy were built on assumptions that have been
|
||||
comprehensively disproven.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What seems to work best are deliberately structured rhythms:
|
||||
weekly all-hands video calls for shared context, written
|
||||
async updates for status, in-person quarterly gatherings for
|
||||
relationship building, and clear synchronous-only windows for
|
||||
high-stakes decision making. Each rhythm has a different
|
||||
cost; none is free.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The fight over whether remote work is here to stay has ended.
|
||||
The interesting question now is what we have lost in the
|
||||
transition that nobody noticed at the time, and what we are
|
||||
about to lose if we do not redesign for the new shape of
|
||||
work.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<aside class="notes" aria-label="Notes">
|
||||
<h2>Notes</h2>
|
||||
|
||||
<form id="note-form">
|
||||
<label for="note-input">Add a note</label>
|
||||
<textarea
|
||||
id="note-input"
|
||||
name="note"
|
||||
rows="3"
|
||||
placeholder="Type or dictate a note…"
|
||||
></textarea>
|
||||
<button id="note-save" type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="notes-empty" class="empty-state">
|
||||
No notes yet. Select a paragraph and ask the assistant to
|
||||
review it, or dictate a note of your own.
|
||||
</div>
|
||||
|
||||
<ol id="notes-list" aria-label="Existing notes"></ol>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="status" aria-live="polite"></div>
|
||||
<audio id="bot-audio" autoplay data-a11y-exclude></audio>
|
||||
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
475
examples/multi-worker/ui-worker/document-review/client/main.js
Normal file
475
examples/multi-worker/ui-worker/document-review/client/main.js
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Document review — vanilla JS client.
|
||||
*
|
||||
* Combines the patterns from every prior demo into one workspace:
|
||||
*
|
||||
* - Snapshot streaming (every demo).
|
||||
* - ``scroll_to`` and ``select_text`` for the agent to point back at
|
||||
* paragraphs (pointing + deixis).
|
||||
* - ``set_input_value`` and ``click`` for dictating notes (form-fill).
|
||||
* - ``ui-task`` envelopes for the in-flight review card with
|
||||
* per-worker progress and a Cancel button (async-tasks).
|
||||
* - One **custom command**, ``add_note``, registered locally.
|
||||
* - One **client-emitted event**, ``note_click``, sent when the user
|
||||
* clicks a note in the panel. The agent's
|
||||
* ``@on_ui_event("note_click")`` handler drives ``select_text`` to
|
||||
* navigate.
|
||||
*/
|
||||
|
||||
import {
|
||||
PipecatClient,
|
||||
RTVIEvent,
|
||||
findElementByRef,
|
||||
findRefForElement,
|
||||
} from "@pipecat-ai/client-js";
|
||||
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
|
||||
|
||||
const BOT_URL = "http://localhost:7860/api/offer";
|
||||
|
||||
const connectButton = document.getElementById("connect");
|
||||
const status = document.getElementById("status");
|
||||
const botAudio = document.getElementById("bot-audio");
|
||||
const noteInput = document.getElementById("note-input");
|
||||
const noteForm = document.getElementById("note-form");
|
||||
const notesList = document.getElementById("notes-list");
|
||||
const notesEmpty = document.getElementById("notes-empty");
|
||||
const articleEl = document.querySelector("article");
|
||||
|
||||
let client;
|
||||
let unsubscribes = [];
|
||||
|
||||
// In-flight review groups, keyed by task_id. Rendered as cards above
|
||||
// the notes list while running.
|
||||
const reviewGroups = new Map();
|
||||
|
||||
// All notes ever added in this session (transient — not persisted).
|
||||
// We use refs to find them and to drive the has-notes paragraph styling.
|
||||
const notes = [];
|
||||
|
||||
// The last article paragraph the user selected. Tracked separately
|
||||
// from window.getSelection() because the textarea steals selection
|
||||
// focus when the user (or the agent) types into it. Updated only
|
||||
// when the selection lands inside the article.
|
||||
let lastArticleRef = null;
|
||||
|
||||
// Walk up from a node looking for the first ancestor that has a
|
||||
// snapshot ref assigned. Used both at submit time and from the
|
||||
// selection-tracker below.
|
||||
function findRefForAncestor(node) {
|
||||
let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
||||
while (el && el !== document.body) {
|
||||
const ref = findRefForElement(el);
|
||||
if (ref) return { ref, element: el };
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener("selectionchange", () => {
|
||||
const sel = document.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.anchorNode) return;
|
||||
const found = findRefForAncestor(sel.anchorNode);
|
||||
if (!found) return;
|
||||
// Only remember selections inside the article column. Textarea /
|
||||
// notes-pane selections shouldn't override it.
|
||||
if (articleEl && articleEl.contains(found.element)) {
|
||||
lastArticleRef = found.ref;
|
||||
}
|
||||
});
|
||||
|
||||
function setStatus(text, autoHideMs = 0) {
|
||||
status.textContent = text;
|
||||
status.dataset.show = text ? "1" : "0";
|
||||
if (text && autoHideMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (status.textContent === text) status.dataset.show = "0";
|
||||
}, autoHideMs);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshEmptyState() {
|
||||
notesEmpty.hidden = notesList.children.length > 0 || reviewGroups.size > 0;
|
||||
}
|
||||
|
||||
function resolveTarget(payload) {
|
||||
if (payload?.ref) {
|
||||
const el = findElementByRef(payload.ref);
|
||||
if (el) return el;
|
||||
}
|
||||
if (payload?.target_id) {
|
||||
return document.getElementById(payload.target_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Standard command handlers
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function handleScrollTo(payload) {
|
||||
const el = resolveTarget(payload);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function handleSelectText(payload) {
|
||||
const el = resolveTarget(payload);
|
||||
if (!el) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function handleSetInputValue(payload) {
|
||||
const el = resolveTarget(payload);
|
||||
if (!el) return;
|
||||
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement))
|
||||
return;
|
||||
if (el.disabled || el.readOnly || el.type === "hidden") return;
|
||||
const value = String(payload?.value ?? "");
|
||||
const replace = payload?.replace !== false;
|
||||
el.value = replace ? value : (el.value || "") + value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
el.classList.remove("fill-flash");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("fill-flash");
|
||||
setTimeout(() => el.classList.remove("fill-flash"), 1200);
|
||||
}
|
||||
|
||||
function handleClick(payload) {
|
||||
const el = resolveTarget(payload);
|
||||
if (!el) return;
|
||||
if ("disabled" in el && el.disabled) return;
|
||||
el.click();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Custom command: add_note
|
||||
//
|
||||
// Server emits this when a worker produces feedback, when the user's
|
||||
// dictated note is committed, etc. Payload: {source, ref?, text}.
|
||||
// We render a clickable card that — when clicked — sends a note_click
|
||||
// UI event back to the server so the agent can respond by selecting
|
||||
// the related paragraph.
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function handleAddNote(payload) {
|
||||
const source = payload?.source ?? "me";
|
||||
const ref = payload?.ref ?? null;
|
||||
const text = String(payload?.text ?? "").trim();
|
||||
if (!text) return;
|
||||
|
||||
const note = { source, ref, text };
|
||||
notes.push(note);
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "note";
|
||||
if (ref) {
|
||||
li.dataset.ref = ref;
|
||||
li.tabIndex = 0;
|
||||
li.title = "Click to jump to the paragraph";
|
||||
}
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "note-meta";
|
||||
|
||||
const sourceEl = document.createElement("span");
|
||||
sourceEl.className = "note-source";
|
||||
sourceEl.dataset.source = source;
|
||||
sourceEl.textContent = source;
|
||||
meta.appendChild(sourceEl);
|
||||
|
||||
if (ref) {
|
||||
const refEl = document.createElement("span");
|
||||
refEl.className = "note-ref";
|
||||
refEl.textContent = `¶ ${ref}`;
|
||||
meta.appendChild(refEl);
|
||||
}
|
||||
|
||||
li.appendChild(meta);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "note-text";
|
||||
body.textContent = text;
|
||||
li.appendChild(body);
|
||||
|
||||
// Send a UI event when the user clicks the note. The server's
|
||||
// @on_ui_event("note_click") handler turns it into a select_text
|
||||
// command back to us — full round-trip, agent-driven.
|
||||
if (ref) {
|
||||
li.addEventListener("click", () => {
|
||||
client?.sendUIEvent("note_click", { ref });
|
||||
});
|
||||
}
|
||||
|
||||
notesList.prepend(li);
|
||||
refreshEmptyState();
|
||||
|
||||
// Mark the paragraph as having notes so it stands out in the
|
||||
// document column.
|
||||
if (ref) {
|
||||
const para = findElementByRef(ref);
|
||||
if (para) para.classList.add("has-notes");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// In-flight review card (ui-task envelopes)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function renderReviewCard(group) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "review-card";
|
||||
card.dataset.taskId = group.task_id;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "review-card-header";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "review-card-label";
|
||||
label.textContent = group.label ?? `Review ${group.task_id.slice(0, 6)}`;
|
||||
header.appendChild(label);
|
||||
|
||||
if (group.cancellable) {
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "review-card-cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", () => {
|
||||
cancel.disabled = true;
|
||||
cancel.textContent = "Cancelling…";
|
||||
client?.cancelUITask(group.task_id, "user requested");
|
||||
});
|
||||
group.cancelButton = cancel;
|
||||
header.appendChild(cancel);
|
||||
}
|
||||
card.appendChild(header);
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "review-workers";
|
||||
for (const agent of group.agents) {
|
||||
const li = document.createElement("li");
|
||||
li.dataset.agent = agent;
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "review-worker-name";
|
||||
name.textContent = agent;
|
||||
li.appendChild(name);
|
||||
|
||||
const update = document.createElement("span");
|
||||
update.className = "review-worker-update";
|
||||
update.textContent = "starting…";
|
||||
li.appendChild(update);
|
||||
|
||||
const stat = document.createElement("span");
|
||||
stat.className = "review-worker-status";
|
||||
stat.dataset.status = "running";
|
||||
stat.textContent = "running";
|
||||
li.appendChild(stat);
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
card.appendChild(ul);
|
||||
|
||||
group.cardEl = card;
|
||||
group.listEl = ul;
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateWorkerRow(group, agentName, { update, statusValue }) {
|
||||
const li = group.listEl.querySelector(
|
||||
`li[data-agent="${CSS.escape(agentName)}"]`,
|
||||
);
|
||||
if (!li) return;
|
||||
if (update !== undefined) {
|
||||
li.querySelector(".review-worker-update").textContent = update;
|
||||
}
|
||||
if (statusValue !== undefined) {
|
||||
const stat = li.querySelector(".review-worker-status");
|
||||
stat.dataset.status = statusValue;
|
||||
stat.textContent = statusValue;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskEnvelope(env) {
|
||||
switch (env.kind) {
|
||||
case "group_started": {
|
||||
const group = {
|
||||
task_id: env.task_id,
|
||||
label: env.label,
|
||||
cancellable: env.cancellable,
|
||||
agents: env.agents,
|
||||
ref: extractRefFromLabel(env.label),
|
||||
};
|
||||
reviewGroups.set(env.task_id, group);
|
||||
// Place the in-flight card just below the new-note form so it
|
||||
// sits visibly above the existing notes.
|
||||
noteForm.insertAdjacentElement("afterend", renderReviewCard(group));
|
||||
// Mark the paragraph as under review.
|
||||
if (group.ref) {
|
||||
const para = findElementByRef(group.ref);
|
||||
if (para) para.classList.add("under-review");
|
||||
}
|
||||
refreshEmptyState();
|
||||
break;
|
||||
}
|
||||
case "task_update": {
|
||||
const group = reviewGroups.get(env.task_id);
|
||||
if (!group) break;
|
||||
const text = env.data?.text ?? JSON.stringify(env.data);
|
||||
updateWorkerRow(group, env.agent_name, { update: text });
|
||||
break;
|
||||
}
|
||||
case "task_completed": {
|
||||
const group = reviewGroups.get(env.task_id);
|
||||
if (!group) break;
|
||||
updateWorkerRow(group, env.agent_name, {
|
||||
update: env.status === "completed" ? "✓ done" : env.status,
|
||||
statusValue: env.status,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "group_completed": {
|
||||
const group = reviewGroups.get(env.task_id);
|
||||
if (!group) break;
|
||||
// Drop the in-flight card; the notes that arrived via add_note
|
||||
// remain in the list.
|
||||
group.cardEl.remove();
|
||||
reviewGroups.delete(env.task_id);
|
||||
if (group.ref) {
|
||||
const para = findElementByRef(group.ref);
|
||||
if (para) para.classList.remove("under-review");
|
||||
}
|
||||
refreshEmptyState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractRefFromLabel(label) {
|
||||
// The server sends labels like "Reviewing ¶ e5". Extract the ref so
|
||||
// we can mark the paragraph as under-review while workers run.
|
||||
const m = (label ?? "").match(/¶\s+(\S+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function onUICommand(command, handler) {
|
||||
const listener = (data) => {
|
||||
if (data.command !== command) return;
|
||||
handler(data.payload);
|
||||
};
|
||||
client.on(RTVIEvent.UICommand, listener);
|
||||
return () => client.off(RTVIEvent.UICommand, listener);
|
||||
}
|
||||
|
||||
function onUITask(handler) {
|
||||
client.on(RTVIEvent.UITask, handler);
|
||||
return () => client.off(RTVIEvent.UITask, handler);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Form behavior
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// The user (or the agent via fills + click) submits a note. Pull the
|
||||
// textarea content into a synthetic add_note so it shows up in the
|
||||
// list, then clear the textarea. The note attaches to whichever
|
||||
// article paragraph the user last selected (tracked via
|
||||
// selectionchange above) — this works for both flows because the
|
||||
// textarea's selection focus does NOT overwrite ``lastArticleRef``.
|
||||
noteForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = noteInput.value.trim();
|
||||
if (!text) return;
|
||||
handleAddNote({ source: "me", ref: lastArticleRef, text });
|
||||
noteInput.value = "";
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Connection lifecycle
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
async function connect() {
|
||||
connectButton.disabled = true;
|
||||
setStatus("Connecting…");
|
||||
|
||||
client = new PipecatClient({
|
||||
transport: new SmallWebRTCTransport(),
|
||||
enableMic: true,
|
||||
enableCam: false,
|
||||
});
|
||||
|
||||
client.on(RTVIEvent.BotConnected, () => setStatus("Bot connected", 1500));
|
||||
client.on(RTVIEvent.Disconnected, () => {
|
||||
setStatus("Disconnected", 2000);
|
||||
connectButton.dataset.state = "";
|
||||
connectButton.textContent = "Connect";
|
||||
connectButton.disabled = false;
|
||||
teardownUI();
|
||||
});
|
||||
|
||||
client.on(RTVIEvent.TrackStarted, (track, participant) => {
|
||||
if (track.kind !== "audio") return;
|
||||
if (participant?.local) return;
|
||||
botAudio.srcObject = new MediaStream([track]);
|
||||
});
|
||||
|
||||
unsubscribes = [
|
||||
onUICommand("scroll_to", handleScrollTo),
|
||||
onUICommand("select_text", handleSelectText),
|
||||
onUICommand("set_input_value", handleSetInputValue),
|
||||
onUICommand("click", handleClick),
|
||||
onUICommand("add_note", handleAddNote),
|
||||
onUITask(handleTaskEnvelope),
|
||||
];
|
||||
|
||||
try {
|
||||
await client.connect({ webrtcUrl: BOT_URL });
|
||||
client.startUISnapshotStream();
|
||||
connectButton.dataset.state = "connected";
|
||||
connectButton.textContent = "Disconnect";
|
||||
connectButton.disabled = false;
|
||||
setStatus("Connected. Select a paragraph and ask 'review this'.", 5000);
|
||||
} catch (err) {
|
||||
console.error("Connect failed:", err);
|
||||
setStatus(`Connect failed: ${err.message ?? err}`, 4000);
|
||||
teardownUI();
|
||||
connectButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
connectButton.disabled = true;
|
||||
setStatus("Disconnecting…");
|
||||
try {
|
||||
await client?.disconnect();
|
||||
} finally {
|
||||
teardownUI();
|
||||
connectButton.dataset.state = "";
|
||||
connectButton.textContent = "Connect";
|
||||
connectButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function teardownUI() {
|
||||
client?.stopUISnapshotStream();
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
unsubscribes = [];
|
||||
if (botAudio.srcObject) botAudio.srcObject = null;
|
||||
client = undefined;
|
||||
}
|
||||
|
||||
connectButton.addEventListener("click", () => {
|
||||
if (connectButton.dataset.state === "connected") {
|
||||
disconnect();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
|
||||
refreshEmptyState();
|
||||
1128
examples/multi-worker/ui-worker/document-review/client/package-lock.json
generated
Normal file
1128
examples/multi-worker/ui-worker/document-review/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "document-review-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipecat-ai/client-js": "1.9.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
--border: #d4d4d8;
|
||||
--muted: #71717a;
|
||||
--selection: #fde68a;
|
||||
--note-bg: #fff;
|
||||
--note-border: #e4e4e7;
|
||||
--accent: #3b82f6;
|
||||
--running: #3b82f6;
|
||||
--completed: #16a34a;
|
||||
--error: #dc2626;
|
||||
--cancelled: #71717a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#connect {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#connect:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
#connect[data-state="connected"] {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 24rem;
|
||||
gap: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Document pane
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
.document {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem 2.25rem;
|
||||
}
|
||||
|
||||
article h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-family: Charter, Georgia, serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
article .lede {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: #27272a;
|
||||
font-family: Charter, Georgia, serif;
|
||||
scroll-margin-top: 6rem;
|
||||
border-left: 2px solid transparent;
|
||||
padding-left: 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
transition: border-color 0.4s;
|
||||
}
|
||||
|
||||
article p.lede {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
article p.under-review {
|
||||
border-left-color: var(--running);
|
||||
}
|
||||
|
||||
article p.has-notes {
|
||||
border-left-color: #c7d2fe;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection);
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Notes pane
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
.notes {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
align-self: start;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notes h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#note-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#note-form label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
#note-form textarea {
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
scroll-margin-top: 6rem;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
background 0.4s;
|
||||
}
|
||||
|
||||
#note-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
#note-form textarea.fill-flash {
|
||||
animation: field-fill-flash 1.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes field-fill-flash {
|
||||
0% {
|
||||
background: var(--selection);
|
||||
}
|
||||
100% {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
#note-save {
|
||||
align-self: flex-start;
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #18181b;
|
||||
color: white;
|
||||
border: 1px solid #18181b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#note-save:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#notes-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
background: var(--note-bg);
|
||||
border: 1px solid var(--note-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.note:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note-source {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note-source[data-source="clarity"] {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.note-source[data-source="tone"] {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.note-source[data-source="me"] {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
In-flight review card
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
.review-card {
|
||||
background: #f9fafb;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.review-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.review-card-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
.review-card-cancel {
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.review-card-cancel:hover {
|
||||
background: #f4f4f5;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.review-card-cancel[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.review-workers {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.review-workers li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.review-worker-name {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-weight: 500;
|
||||
min-width: 4.5rem;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.review-worker-update {
|
||||
font-style: italic;
|
||||
color: #52525b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-worker-status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.review-worker-status[data-status="running"] {
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.review-worker-status[data-status="completed"] {
|
||||
color: var(--completed);
|
||||
}
|
||||
|
||||
.review-worker-status[data-status="cancelled"] {
|
||||
color: var(--cancelled);
|
||||
}
|
||||
|
||||
.review-worker-status[data-status="failed"],
|
||||
.review-worker-status[data-status="error"] {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
#status {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
background: #18181b;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#status[data-show="1"] {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user