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:
Mark Backman
2026-05-21 17:21:08 -04:00
parent 07725429b2
commit 950fc10f05
9 changed files with 2790 additions and 0 deletions

View 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

View File

@@ -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>

View 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();

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5173,
},
});