main
js 160 lines 5.23 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5
6 const API_PATH = "/plugins/_context_window/context_window";
7 const ROWS = [
8 { key: "messages", label: "Messages" },
9 { key: "system_tools", label: "System tools" },
10 { key: "skills", label: "Skills" },
11 { key: "mcp_tools", label: "MCP tools" },
12 { key: "system_prompt", label: "System prompt" },
13 { key: "extras", label: "Extras" },
14 ];
15 const COST_FORMATTER = new Intl.NumberFormat("en-US", {
16 style: "currency",
17 currency: "USD",
18 maximumSignificantDigits: 3,
19 });
20
21 preferencesStore.registerUiControlVisibility("contextWindowUsage", {
22 mobile: true,
23 desktop: true,
24 });
25
26 function formatTokens(value) {
27 const amount = Math.max(Number(value) || 0, 0);
28 for (const [size, suffix] of [[1_000_000, "M"], [1_000, "K"]]) {
29 if (amount >= size) return `${(amount / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
30 }
31 return String(Math.round(amount));
32 }
33
34 function formatPercent(value) {
35 const rounded = Math.round(Math.max(Number(value) || 0, 0) * 10) / 10;
36 return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
37 }
38
39 function optionalNumber(value, key) {
40 if (!value || !Object.prototype.hasOwnProperty.call(value, key)) return null;
41 const number = Number(value[key]);
42 return Number.isFinite(number) && number >= 0 ? number : null;
43 }
44
45 function formatCost(value) {
46 if (value === 0) return "$0";
47 return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value);
48 }
49
50 function buildProviderUsage(value = {}) {
51 const input = optionalNumber(value, "input_tokens");
52 const cached = optionalNumber(value, "cached_tokens");
53 const output = optionalNumber(value, "output_tokens");
54 const cost = optionalNumber(value, "cost");
55
56 const tokenSummary = input === null && output === null
57 ? ""
58 : `${input === null ? "" : formatTokens(input)}${output === null ? "" : formatTokens(output)}`;
59
60 const cachePercent = input > 0 && cached !== null
61 ? Math.min((cached / input) * 100, 100)
62 : null;
63 return {
64 hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary),
65 price: {
66 hasData: cost !== null,
67 label: cost === null ? "" : formatCost(cost),
68 },
69 cache: {
70 hasData: cachePercent !== null,
71 label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`,
72 },
73 tokens: tokenSummary,
74 };
75 }
76
77 function buildUsage(data = {}) {
78 const tokens = Math.max(Number(data.tokens) || 0, 0);
79 const contextWindow = Math.max(Number(data.context_window) || 0, 0);
80 const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {};
81 const percent = contextWindow > 0 ? (tokens / contextWindow) * 100 : 0;
82 const rows = ROWS.map(row => {
83 const rowTokens = Math.max(Number(breakdown[row.key]) || 0, 0);
84 const rowPercent = contextWindow > 0 ? (rowTokens / contextWindow) * 100 : 0;
85 return {
86 ...row,
87 tokensLabel: formatTokens(rowTokens),
88 percentLabel: formatPercent(rowPercent),
89 };
90 });
91 const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
92 if (hasBreakdown) {
93 const freeTokens = Math.max(contextWindow - tokens, 0);
94 const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0;
95 rows.push({
96 key: "free_space",
97 label: "Free space",
98 tokensLabel: formatTokens(freeTokens),
99 percentLabel: formatPercent(freePercent),
100 });
101 }
102 const percentLabel = formatPercent(percent);
103 return {
104 rows: hasBreakdown ? rows : [],
105 hasBreakdown,
106 missingBreakdown: !hasBreakdown,
107 ariaLabel: `Context window ${percentLabel} used`,
108 ringLabel: contextWindow ? `${Math.round(percent)}%` : "",
109 ringDasharray: `${Math.min(percent, 100)} 100`,
110 summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : ""} tokens`,
111 summaryPercent: `${percentLabel} used`,
112 meterStyle: `width:${Math.min(percent, 100)}%`,
113 provider: buildProviderUsage(data.provider_usage),
114 };
115 }
116
117 const model = {
118 usage: buildUsage(),
119 loadSeq: 0,
120 open: false,
121
122 get contextId() {
123 return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
124 },
125
126 async onMount(watch) {
127 await this.refresh();
128 watch("$store.chats.selected", value => this.refresh(value || ""));
129 watch("$store.chats.selectedContext?.running", (running, previous) => {
130 if (previous && !running) void this.refresh();
131 });
132 },
133
134 cleanup() {
135 this.open = false;
136 this.loadSeq += 1;
137 },
138
139 toggle() {
140 this.open = !this.open;
141 if (this.open) void this.refresh();
142 },
143
144 async refresh(contextId = this.contextId) {
145 const requestSeq = ++this.loadSeq;
146 if (!contextId) {
147 this.usage = buildUsage();
148 return this.usage;
149 }
150 try {
151 const data = await callJsonApi(API_PATH, { context: contextId });
152 if (requestSeq === this.loadSeq) this.usage = buildUsage(data);
153 } catch (error) {
154 if (requestSeq === this.loadSeq) console.error("Context window load failed:", error);
155 }
156 return this.usage;
157 },
158 };
159
160 export const store = createStore("contextWindow", model);