Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/starter/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ OPENAI_BASE_URL=http://host.docker.internal:11434/v1

# Optional — read by the bank_name resolver.
# BANK_NAME=Northwind Bank

# Optional — a per-conversation *cumulative* token budget: every turn's input +
# output tokens, summed over the life of the conversation. This is not the
# model's context window. Once a conversation has spent this many tokens in
# total, further turns are refused with a 429; the widget shows how full the
# budget is so the user sees it coming.
# CONTEXT_MAX_TOKENS=2000
15 changes: 15 additions & 0 deletions src/agent_manager/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
SendMessageRequest,
SendMessageResponse,
StreamEventOut,
TokenBudgetResponse,
ToolRecord,
)
from agent_manager.application import (
Expand Down Expand Up @@ -50,6 +51,20 @@ async def list_messages(conversation_id: str, service: Service) -> list[MessageO
return [MessageOut(role=m.role, content=m.content, created_at=m.created_at) for m in msgs]


@router.get("/conversations/{conversation_id}/usage", response_model=TokenBudgetResponse)
async def get_usage(conversation_id: str, service: Service) -> TokenBudgetResponse:
try:
usage = await service.usage(conversation_id)
except ConversationNotFound as exc:
raise HTTPException(status_code=404, detail="conversation not found") from exc
return TokenBudgetResponse(
used_tokens=usage.used_tokens,
max_tokens=usage.max_tokens,
percent=usage.percent,
severity=usage.severity,
)


@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
conversation_id: str, body: SendMessageRequest, service: Service
Expand Down
9 changes: 8 additions & 1 deletion src/agent_manager/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from pydantic import BaseModel

from agent_manager.domain import Role
from agent_manager.domain import BudgetSeverity, Role


class CreateConversationRequest(BaseModel):
Expand Down Expand Up @@ -49,6 +49,13 @@ class SendMessageResponse(BaseModel):
used_tools: list[ToolRecord]


class TokenBudgetResponse(BaseModel):
used_tokens: int
max_tokens: int | None = None
percent: float = 0.0
severity: BudgetSeverity = BudgetSeverity.NORMAL


class StreamEventOut(BaseModel):
type: str
content: str | None = None
Expand Down
11 changes: 5 additions & 6 deletions src/agent_manager/api/static/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,15 @@ <h1>Drop-in chat widget</h1>
<em>your</em> product, paste these two lines — pointing at your backend:</p>

<pre>&lt;script type="module" src="https://your-backend/widget.js"&gt;&lt;/script&gt;
&lt;agent-chat title="Home Assistant" color="#2563eb"&gt;&lt;/agent-chat&gt;</pre>
&lt;agent-chat title="Support" color="#18181b"&gt;&lt;/agent-chat&gt;</pre>

<p class="hint">↘ The launcher is in the bottom-right corner. Click it and try
“turn on the kitchen lights”, then “now turn it off”.</p>
<p class="hint">↘ The launcher is in the bottom-right corner. Click it and ask a question.</p>

<!-- The actual embed: defaults to this page's origin. -->
<agent-chat
title="Home Assistant"
color="#2563eb"
greeting="Hi! I can control your home — try ‘turn on the kitchen lights’."
title="Support"
color="#18181b"
greeting="How can I help you today?"
></agent-chat>
<script type="module" src="/widget.js"></script>
</body>
Expand Down
136 changes: 132 additions & 4 deletions src/agent_manager/api/static/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -52141,6 +52141,19 @@ var AgentChatClient = class {
used_tools: Array.isArray(data.used_tools) ? data.used_tools : void 0
};
}
async getUsage(conversationId) {
const response = await fetch(`${this.endpoint}/conversations/${conversationId}/usage`);
if (!response.ok) {
throw new AgentChatHttpError(response.status);
}
const data = await response.json();
return {
used_tokens: Number(data.used_tokens) || 0,
max_tokens: data.max_tokens == null ? null : Number(data.max_tokens),
percent: Number(data.percent) || 0,
severity: data.severity ?? "normal"
};
}
async *streamMessage(conversationId, message) {
const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages/stream`, {
method: "POST",
Expand Down Expand Up @@ -53051,7 +53064,19 @@ function useConversation(client, endpoint) {
return [];
}
}, [client, endpoint]);
return (0, import_react9.useMemo)(() => ({ send, stream, loadHistory }), [send, stream, loadHistory]);
const loadUsage = (0, import_react9.useCallback)(async () => {
const stored = getStoredConversationId(endpoint);
if (!stored) return null;
try {
return await client.getUsage(stored);
} catch {
return null;
}
}, [client, endpoint]);
return (0, import_react9.useMemo)(
() => ({ send, stream, loadHistory, loadUsage }),
[send, stream, loadHistory, loadUsage]
);
}

// src/agent_manager/api/static/widget/react/AgentChatApp.tsx
Expand All @@ -53072,14 +53097,19 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) {
const [loaded, setLoaded] = (0, import_react10.useState)(false);
const [sending, setSending] = (0, import_react10.useState)(false);
const [entries, setEntries] = (0, import_react10.useState)([]);
const [usage, setUsage] = (0, import_react10.useState)(null);
const launcherRef = (0, import_react10.useRef)(null);
const inputRef = (0, import_react10.useRef)(null);
const refreshUsage = (0, import_react10.useCallback)(async () => {
setUsage(await conversation.loadUsage());
}, [conversation]);
const loadHistory = (0, import_react10.useCallback)(async () => {
if (loaded) return;
setLoaded(true);
const history = await conversation.loadHistory();
if (history.length) setEntries(history.map(toEntry));
}, [conversation, loaded]);
await refreshUsage();
}, [conversation, loaded, refreshUsage]);
(0, import_react10.useEffect)(() => {
if (inline) void loadHistory();
}, [inline, loadHistory]);
Expand Down Expand Up @@ -53134,9 +53164,10 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) {
await sendWithoutStreaming(text10, pending.id);
} finally {
setSending(false);
void refreshUsage();
}
},
[conversation, onAnswer, replaceEntry, sendWithoutStreaming]
[conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming]
);
const toggle = () => void (open ? closeChat() : openChat());
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
Expand Down Expand Up @@ -53184,7 +53215,10 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) {
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(PromptInputFooter, { children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "prompt-hint", children: "Enter to send \xB7 Shift+Enter for a new line" }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "footer-start", children: [
usage ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BudgetMeter, { usage }) : null,
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "prompt-hint", children: "Enter to send \xB7 Shift+Enter for a new line" })
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PromptInputSubmit, { disabled: sending })
] })
] }),
Expand Down Expand Up @@ -53280,6 +53314,71 @@ function Welcome({ title }) {
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "welcome-title", children: title })
] });
}
var RING_SIZE = 18;
var RING_STROKE = 2.5;
var RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
var RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
function BudgetMeter({ usage }) {
const { used_tokens: used, max_tokens: max, percent, severity } = usage;
if (!max) return null;
const center = RING_SIZE / 2;
const ring = { cx: center, cy: center, r: RING_RADIUS, fill: "none", strokeWidth: RING_STROKE };
const rounded = Math.round(percent);
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"span",
{
className: `budget-meter ${severity}`,
role: "img",
"aria-label": `Token budget ${rounded}% used`,
tabIndex: 0,
children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"svg",
{
className: "budget-ring",
width: RING_SIZE,
height: RING_SIZE,
viewBox: `0 0 ${RING_SIZE} ${RING_SIZE}`,
"aria-hidden": true,
children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("circle", { className: "budget-ring-track", ...ring }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"circle",
{
className: "budget-ring-value",
...ring,
strokeLinecap: "round",
strokeDasharray: RING_CIRCUMFERENCE,
strokeDashoffset: RING_CIRCUMFERENCE * (1 - percent / 100)
}
)
]
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "budget-percent", children: [
rounded,
"%"
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "budget-popover", role: "tooltip", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "budget-popover-head", children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: "Token budget" }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "budget-popover-count", children: [
formatTokens(Math.min(used, max)),
" of ",
formatTokens(max)
] })
] }),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "budget-bar", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "budget-bar-fill", style: { width: `${percent}%` } }) })
] })
]
}
);
}
function formatTokens(tokens) {
if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1).replace(/\.0$/, "")}k`;
return `${tokens}`;
}
function avatarStyle(avatar) {
if (!avatar) return void 0;
return { backgroundImage: `url("${avatar.replace(/"/g, "%22")}")` };
Expand Down Expand Up @@ -53428,6 +53527,34 @@ function styles(config) {
.prompt-footer { grid-column: 1 / -1; display: flex; align-items: center;
justify-content: space-between; gap: 10px; color: #a1a1aa; font-size: 11.5px; }
.prompt-hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.footer-start { display: flex; align-items: center; gap: 8px; min-width: 0; }
.budget-meter { position: relative; display: inline-flex; align-items: center; gap: 5px;
flex: 0 0 auto; color: #71717a; font-size: 11.5px; cursor: default; outline: none; }
.budget-ring { transform: rotate(-90deg); }
.budget-ring-track { stroke: #e4e4e7; }
.budget-ring-value { stroke: #3f3f46;
transition: stroke-dashoffset .3s ease, stroke .3s ease; }
.budget-percent { font-variant-numeric: tabular-nums; }
.budget-meter.warning .budget-ring-value { stroke: #f59e0b; }
.budget-meter.warning .budget-percent { color: #b45309; }
.budget-meter.critical .budget-ring-value { stroke: #ef4444; }
.budget-meter.critical .budget-percent { color: #b91c1c; }
.budget-popover { position: absolute; bottom: calc(100% + 8px); left: 0; width: 200px;
background: #fff; color: #18181b; border: 1px solid #e4e4e7; border-radius: 10px;
padding: 10px 12px; box-shadow: 0 10px 25px rgba(0,0,0,.12);
opacity: 0; transform: translateY(4px); pointer-events: none;
transition: opacity .15s ease, transform .15s ease; z-index: 5; }
.budget-meter:hover .budget-popover, .budget-meter:focus-visible .budget-popover {
opacity: 1; transform: none; }
.budget-popover-head { display: flex; align-items: baseline; justify-content: space-between;
gap: 12px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.budget-popover-count { color: #71717a; font-weight: 400; font-variant-numeric: tabular-nums; }
.budget-bar { display: block; margin-top: 8px; height: 4px; background: #f4f4f5;
border-radius: 999px; overflow: hidden; }
.budget-bar-fill { display: block; height: 100%; background: #3f3f46;
border-radius: 999px; transition: width .3s ease; }
.budget-meter.warning .budget-bar-fill { background: #f59e0b; }
.budget-meter.critical .budget-bar-fill { background: #ef4444; }
.powered { text-align: center; padding: 0 14px 10px; color: #a1a1aa;
font-size: 11px; letter-spacing: .01em; }
@media (prefers-reduced-motion: reduce) {
Expand All @@ -53442,6 +53569,7 @@ function styles(config) {
.panel.open .composer { animation: none; }
.welcome { animation: none; }
.msg-action svg { animation: none; }
.budget-ring-value, .budget-bar-fill, .budget-popover { transition: none; }
}
@media (max-width: 480px) {
.panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh;
Expand Down
16 changes: 15 additions & 1 deletion src/agent_manager/api/static/widget/api/AgentChatClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatMessage, SendMessageResponse, StreamEvent } from "../types";
import type { ChatMessage, TokenBudget, SendMessageResponse, StreamEvent } from "../types";

export class AgentChatHttpError extends Error {
constructor(readonly status: number) {
Expand Down Expand Up @@ -44,6 +44,20 @@ export class AgentChatClient {
};
}

async getUsage(conversationId: string): Promise<TokenBudget> {
const response = await fetch(`${this.endpoint}/conversations/${conversationId}/usage`);
if (!response.ok) {
throw new AgentChatHttpError(response.status);
}
const data = await response.json();
return {
used_tokens: Number(data.used_tokens) || 0,
max_tokens: data.max_tokens == null ? null : Number(data.max_tokens),
percent: Number(data.percent) || 0,
severity: data.severity ?? "normal",
};
}

async *streamMessage(conversationId: string, message: string): AsyncGenerator<StreamEvent> {
const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages/stream`, {
method: "POST",
Expand Down
Loading
Loading