@circuitwall/jarela 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
  19. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
  20. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
  21. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  33. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  35. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  36. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  37. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  38. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
  40. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
  42. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  47. package/.next/standalone/.next/server/app/page.js +31202 -30960
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  51. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  52. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  53. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  54. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  55. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  56. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  57. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  58. package/.next/standalone/.next/server/chunks/46.js +18 -3
  59. package/.next/standalone/.next/server/chunks/466.js +53 -7
  60. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  61. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  62. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9164.js +65 -14
  64. package/.next/standalone/.next/server/chunks/9590.js +18 -3
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  66. package/.next/standalone/.next/server/middleware.js +18 -3
  67. package/.next/standalone/.next/server/pages/404.html +14 -2
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/preview-props.json +3 -3
  70. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  72. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  73. package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
  74. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  75. package/.next/standalone/docs/adoption/changesets.json +2756 -0
  76. package/.next/standalone/package.json +1 -1
  77. package/CHANGELOG.md +33 -0
  78. package/api/client.models.test.ts +10 -0
  79. package/api/client.startup.test.ts +129 -0
  80. package/api/client.ts +51 -9
  81. package/api/thread-metadata.test.ts +72 -0
  82. package/api/types.ts +6 -3
  83. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  84. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  85. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  86. package/app/globals.css +119 -29
  87. package/app/layout.tsx +12 -0
  88. package/components/agents/AgentsPanel.tsx +5 -13
  89. package/components/chat/ChatView.tsx +9 -18
  90. package/components/chat/InputBar.tsx +1 -1
  91. package/components/chat/MessageBubble.tsx +1 -1
  92. package/components/chat/MessageList.test.tsx +45 -4
  93. package/components/chat/MessageList.tsx +123 -89
  94. package/components/chat/ToolList.tsx +88 -84
  95. package/components/chat/useThreadActivity.test.tsx +2 -2
  96. package/components/chat/useThreadActivity.ts +2 -2
  97. package/components/chat/useThreadData.ts +1 -30
  98. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  99. package/components/chat/useThreadMetadataPolling.ts +50 -0
  100. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  101. package/components/chat/useUserProfileAndAgent.ts +13 -2
  102. package/components/dashboard/DashboardPanel.tsx +1 -1
  103. package/components/dashboard/Series.tsx +34 -21
  104. package/components/dashboard/SummaryCards.tsx +1 -1
  105. package/components/layout/AppShell.tsx +22 -7
  106. package/components/layout/MenuPanel.tsx +14 -24
  107. package/components/profile/ProfilePanel.tsx +2 -2
  108. package/components/settings/AppearancePanel.tsx +70 -3
  109. package/components/settings/SettingsPanel.tsx +2 -2
  110. package/components/tools/ToolsPanel.tsx +2 -7
  111. package/components/ui/AppearancePreferences.test.tsx +68 -0
  112. package/components/ui/BootScreen.test.tsx +44 -0
  113. package/components/ui/BootScreen.tsx +24 -11
  114. package/components/ui/NotificationStatus.test.tsx +2 -2
  115. package/components/ui/NotificationStatus.tsx +5 -5
  116. package/components/ui/SubTabBar.tsx +2 -3
  117. package/contexts/ThemeContext.tsx +84 -1
  118. package/hooks/useAgentSession.test.ts +12 -1
  119. package/hooks/useAgentSession.ts +6 -4
  120. package/hooks/useUrlSync.test.ts +13 -0
  121. package/hooks/useUrlSync.ts +0 -4
  122. package/lib/db/migrations.test.ts +61 -0
  123. package/lib/db/migrations.ts +13 -0
  124. package/lib/embeddings/performance.test.ts +114 -0
  125. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  126. package/lib/stores/dashboard-metrics.ts +30 -4
  127. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  128. package/lib/stores/message-embedding-jobs.ts +5 -3
  129. package/lib/stores/message-usage.test.ts +21 -1
  130. package/lib/stores/pricing-cache.test.ts +31 -0
  131. package/lib/stores/pricing.ts +3 -0
  132. package/lib/tools/packages/default-packages.test.ts +21 -0
  133. package/lib/tools/packages/optional-package.test.ts +72 -0
  134. package/lib/tools/packages/optional-package.ts +33 -4
  135. package/lib/ui/navigate.test.ts +13 -0
  136. package/lib/ui/navigate.ts +10 -2
  137. package/package.json +1 -1
  138. package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
  139. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  140. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -1,7 +1,10 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
2
3
  import { getProvider } from "@/lib/providers";
4
+ import { modelCapabilities } from "@/lib/providers/capabilities";
3
5
  import { listModelConfigs, getModelParams } from "@/lib/stores/model-config";
4
6
  import type { ProviderCatalogModel, ProviderParams } from "@/lib/providers/types";
7
+ import { validateBody } from "@/lib/api/responses";
5
8
  import {
6
9
  getKnownContextLength,
7
10
  getKnownMaxOutputTokens,
@@ -26,9 +29,12 @@ export interface CatalogModel {
26
29
 
27
30
  type Params = { params: Promise<{ provider: string }> };
28
31
 
29
- // Live provider catalogs (OpenAI, GitHub Copilot) hit external APIs on every
30
- // call; static ones still cost JSON construction. Cache per-provider for a
31
- // short TTL so flipping panels / model browsers doesn't re-hit upstream.
32
+ const CatalogBody = z.object({
33
+ params: z.record(z.string(), z.unknown()).optional(),
34
+ });
35
+
36
+ // Live provider catalogs hit external APIs. Cache per-provider briefly so
37
+ // opening the model browser repeatedly doesn't re-hit upstream.
32
38
  const PROVIDER_CATALOG_TTL_MS = 10 * 60 * 1000;
33
39
  type CachedCatalog = { data: CatalogModel[]; fetchedAt: number };
34
40
  const catalogCache = new Map<string, CachedCatalog>();
@@ -74,10 +80,11 @@ function catalogErrorResponse(err: unknown): NextResponse {
74
80
  // these responses since they're keyed on user-supplied secrets.
75
81
  export async function POST(req: NextRequest, { params }: Params) {
76
82
  const { provider } = await params;
77
- let body: { params?: ProviderParams } = {};
78
- try { body = await req.json(); } catch { /* empty body is fine */ }
83
+ const body = await validateBody(req, CatalogBody);
84
+ if (body instanceof NextResponse) return body;
79
85
  try {
80
86
  const models = await fetchCatalog(provider, body.params);
87
+ catalogCache.delete(provider);
81
88
  return NextResponse.json(models, { headers: { "Cache-Control": "no-store" } });
82
89
  } catch (err) {
83
90
  return catalogErrorResponse(err);
@@ -86,15 +93,70 @@ export async function POST(req: NextRequest, { params }: Params) {
86
93
 
87
94
  async function fetchCatalog(provider: string, overrides?: ProviderParams): Promise<CatalogModel[]> {
88
95
  switch (provider) {
89
- case "openai": return fetchOpenAICatalog();
96
+ case "openai": return fetchOpenAICatalog(overrides);
90
97
  case "github-copilot": return fetchGitHubCopilotCatalog();
91
- case "anthropic": return anthropicKnownModels();
92
- case "gemini": return geminiKnownModels();
93
- case "deepseek": return deepseekKnownModels();
98
+ case "anthropic": return fetchAnthropicCatalog(overrides);
99
+ case "gemini": return fetchGeminiCatalog(overrides);
100
+ case "deepseek": return fetchDeepseekCatalog(overrides);
94
101
  default: return fetchExternalCatalog(provider, overrides);
95
102
  }
96
103
  }
97
104
 
105
+ const PROVIDER_API_KEY_ENV: Record<string, string> = {
106
+ anthropic: "ANTHROPIC_API_KEY",
107
+ openai: "OPENAI_API_KEY",
108
+ gemini: "GEMINI_API_KEY",
109
+ deepseek: "DEEPSEEK_API_KEY",
110
+ };
111
+
112
+ function catalogParamsCandidates(provider: string, overrides?: ProviderParams): ProviderParams[] {
113
+ const envKey = process.env[PROVIDER_API_KEY_ENV[provider] ?? ""];
114
+ const configs = listModelConfigs().filter((cfg) => cfg.provider === provider);
115
+ const candidates = configs.map((cfg) => {
116
+ const params = { ...getModelParams(cfg), ...(overrides ?? {}) };
117
+ if (!params.api_key && envKey) params.api_key = envKey;
118
+ return params;
119
+ });
120
+
121
+ if (overrides) {
122
+ const params = { ...overrides };
123
+ if (!params.api_key && envKey) params.api_key = envKey;
124
+ candidates.push(params);
125
+ }
126
+ if (candidates.length === 0 && envKey) candidates.push({ api_key: envKey });
127
+ if (candidates.length === 0) candidates.push({});
128
+ return candidates;
129
+ }
130
+
131
+ async function fetchConfiguredCatalog(
132
+ provider: string,
133
+ overrides: ProviderParams | undefined,
134
+ fetcher: (params: ProviderParams, apiKey: string) => Promise<CatalogModel[]>,
135
+ fallback: () => CatalogModel[],
136
+ ): Promise<CatalogModel[]> {
137
+ const candidates = catalogParamsCandidates(provider, overrides)
138
+ .filter((params): params is ProviderParams & { api_key: string } =>
139
+ typeof params.api_key === "string" && params.api_key.length > 0,
140
+ );
141
+ if (candidates.length === 0) return fallback();
142
+
143
+ let lastErr: unknown;
144
+ for (const params of candidates) {
145
+ try {
146
+ return await fetcher(params, params.api_key);
147
+ } catch (err) {
148
+ lastErr = err;
149
+ }
150
+ }
151
+ throw lastErr ?? new Error(`${provider} catalog request failed`);
152
+ }
153
+
154
+ async function requestCatalogJson<T>(provider: string, url: URL, init: RequestInit): Promise<T> {
155
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) });
156
+ if (!res.ok) throw new Error(`${provider} catalog error: ${res.status}`);
157
+ return res.json() as Promise<T>;
158
+ }
159
+
98
160
  async function fetchExternalCatalog(providerName: string, overrides?: ProviderParams): Promise<CatalogModel[]> {
99
161
  let provider;
100
162
  try {
@@ -132,40 +194,197 @@ async function fetchExternalCatalog(providerName: string, overrides?: ProviderPa
132
194
 
133
195
  // ── OpenAI ────────────────────────────────────────────────────────────────────
134
196
 
135
- async function fetchOpenAICatalog(): Promise<CatalogModel[]> {
136
- const cfg = listModelConfigs().find((c) => c.provider === "openai");
137
- if (!cfg) throw new Error("No OpenAI model config found.");
138
- const params = getModelParams(cfg);
139
- const apiKey = (params.api_key as string | undefined) ?? process.env.OPENAI_API_KEY;
140
- if (!apiKey) throw new Error("OpenAI: no api_key configured.");
141
-
142
- const res = await fetch("https://api.openai.com/v1/models", {
143
- headers: { Authorization: `Bearer ${apiKey}` },
197
+ async function fetchOpenAICatalog(overrides?: ProviderParams): Promise<CatalogModel[]> {
198
+ return fetchConfiguredCatalog("openai", overrides, async (params, apiKey) => {
199
+ const url = endpointUrl(params.base_url, "https://api.openai.com/v1", "/models");
200
+ const data = await requestCatalogJson<{ data?: Array<{ id: string }> }>("OpenAI", url, {
201
+ headers: {
202
+ ...params.extra_headers,
203
+ Authorization: "Bearer " + apiKey,
204
+ },
205
+ });
206
+ const chatPrefixes = ["gpt-", "o1", "o3", "o4", "chatgpt-"];
207
+ return (data.data ?? [])
208
+ .filter((m) => chatPrefixes.some((p) => m.id.startsWith(p)))
209
+ .map((m): CatalogModel => ({
210
+ id: m.id,
211
+ context_length: getKnownContextLength("openai", m.id),
212
+ max_output_tokens: getKnownMaxOutputTokens("openai", m.id),
213
+ hosted_on: "azure/openai",
214
+ capabilities: {
215
+ vision: m.id.includes("vision") || m.id.startsWith("gpt-4"),
216
+ tools: true,
217
+ streaming: true,
218
+ json_mode: true,
219
+ web_search: false,
220
+ audio: m.id.includes("audio") || m.id.startsWith("gpt-4o") || m.id.startsWith("gpt-5"),
221
+ files: m.id.startsWith("gpt-4") || m.id.startsWith("gpt-5") || m.id.startsWith("o"),
222
+ },
223
+ }))
224
+ .sort((a, b) => a.id.localeCompare(b.id));
225
+ }, () => {
226
+ throw new Error("OpenAI: no api_key configured.");
144
227
  });
145
- if (!res.ok) throw new Error(`OpenAI catalog error: ${res.status}`);
146
- const data = await res.json() as { data: Array<{ id: string }> };
147
-
148
- const chatPrefixes = ["gpt-", "o1", "o3", "o4", "chatgpt-"];
149
- const chatModels = data.data
150
- .filter((m) => chatPrefixes.some((p) => m.id.startsWith(p)))
151
- .map((m): CatalogModel => ({
152
- id: m.id,
153
- context_length: getKnownContextLength("openai", m.id),
154
- max_output_tokens: getKnownMaxOutputTokens("openai", m.id),
155
- hosted_on: "azure/openai",
156
- capabilities: {
157
- vision: m.id.includes("vision") || m.id.startsWith("gpt-4"),
158
- tools: true,
159
- streaming: true,
160
- json_mode: true,
161
- web_search: false,
162
- audio: m.id.includes("audio") || m.id.startsWith("gpt-4o") || m.id.startsWith("gpt-5"),
163
- files: m.id.startsWith("gpt-4") || m.id.startsWith("gpt-5") || m.id.startsWith("o"),
228
+ }
229
+
230
+ async function fetchAnthropicCatalog(overrides?: ProviderParams): Promise<CatalogModel[]> {
231
+ return fetchConfiguredCatalog("anthropic", overrides, async (params, apiKey) => {
232
+ const url = endpointUrl(params.base_url, "https://api.anthropic.com", "/v1/models");
233
+ url.searchParams.set("limit", "1000");
234
+ const models: Array<{
235
+ id: string;
236
+ max_input_tokens?: number | null;
237
+ max_tokens?: number | null;
238
+ capabilities?: {
239
+ image_input?: { supported?: boolean };
240
+ pdf_input?: { supported?: boolean };
241
+ structured_outputs?: { supported?: boolean };
242
+ server_tools?: { web_search?: { supported?: boolean } };
243
+ };
244
+ }> = [];
245
+ let afterId: string | undefined;
246
+
247
+ do {
248
+ const pageUrl = new URL(url);
249
+ if (afterId) pageUrl.searchParams.set("after_id", afterId);
250
+ const page = await requestCatalogJson<{
251
+ data?: typeof models;
252
+ has_more?: boolean;
253
+ last_id?: string | null;
254
+ }>("Anthropic", pageUrl, {
255
+ headers: {
256
+ ...params.extra_headers,
257
+ "x-api-key": apiKey,
258
+ "anthropic-version": "2023-06-01",
259
+ },
260
+ });
261
+ models.push(...(page.data ?? []));
262
+ if (page.has_more && !page.last_id) throw new Error("Anthropic catalog response is missing its next-page cursor");
263
+ if (page.has_more && page.last_id === afterId) throw new Error("Anthropic catalog pagination did not advance");
264
+ afterId = page.has_more ? page.last_id ?? undefined : undefined;
265
+ } while (afterId);
266
+
267
+ return models.map((model): CatalogModel => {
268
+ const known = modelCapabilities("anthropic", model.id);
269
+ const vision = model.capabilities?.image_input?.supported ?? known.vision;
270
+ return {
271
+ id: model.id,
272
+ context_length: model.max_input_tokens ?? getKnownContextLength("anthropic", model.id),
273
+ max_output_tokens: model.max_tokens ?? getKnownMaxOutputTokens("anthropic", model.id),
274
+ hosted_on: "anthropic",
275
+ capabilities: {
276
+ vision,
277
+ tools: known.tools,
278
+ streaming: known.streaming,
279
+ json_mode: model.capabilities?.structured_outputs?.supported ?? known.json_mode,
280
+ web_search: model.capabilities?.server_tools?.web_search?.supported ?? known.web_search,
281
+ audio: known.audio,
282
+ files: model.capabilities?.pdf_input?.supported ?? known.files,
283
+ },
284
+ };
285
+ });
286
+ }, anthropicKnownModels);
287
+ }
288
+
289
+ async function fetchGeminiCatalog(overrides?: ProviderParams): Promise<CatalogModel[]> {
290
+ return fetchConfiguredCatalog("gemini", overrides, async (params, apiKey) => {
291
+ const url = endpointUrl(
292
+ params.gemini_native_base_url,
293
+ "https://generativelanguage.googleapis.com/v1beta",
294
+ "/models",
295
+ );
296
+ url.searchParams.set("pageSize", "1000");
297
+ const models: Array<{
298
+ name: string;
299
+ inputTokenLimit?: number;
300
+ outputTokenLimit?: number;
301
+ supportedGenerationMethods?: string[];
302
+ }> = [];
303
+ let pageToken: string | undefined;
304
+
305
+ do {
306
+ const pageUrl = new URL(url);
307
+ if (pageToken) pageUrl.searchParams.set("pageToken", pageToken);
308
+ const page = await requestCatalogJson<{
309
+ models?: typeof models;
310
+ nextPageToken?: string;
311
+ }>("Gemini", pageUrl, { headers: { "x-goog-api-key": apiKey } });
312
+ models.push(...(page.models ?? []));
313
+ if (page.nextPageToken && page.nextPageToken === pageToken) {
314
+ throw new Error("Gemini catalog pagination did not advance");
315
+ }
316
+ pageToken = page.nextPageToken;
317
+ } while (pageToken);
318
+
319
+ return models
320
+ .map((model): CatalogModel | null => {
321
+ const id = model.name.replace(/^models\//, "");
322
+ if (!id.startsWith("gemini-") ||
323
+ !model.supportedGenerationMethods?.includes("generateContent")) return null;
324
+ return {
325
+ id,
326
+ context_length: model.inputTokenLimit ?? getKnownContextLength("gemini", id),
327
+ max_output_tokens: model.outputTokenLimit ?? getKnownMaxOutputTokens("gemini", id),
328
+ hosted_on: "google",
329
+ capabilities: modelCapabilities("gemini", id),
330
+ };
331
+ })
332
+ .filter((model): model is CatalogModel => model !== null)
333
+ .sort((a, b) => a.id.localeCompare(b.id));
334
+ }, geminiKnownModels);
335
+ }
336
+
337
+ async function fetchDeepseekCatalog(overrides?: ProviderParams): Promise<CatalogModel[]> {
338
+ return fetchConfiguredCatalog("deepseek", overrides, async (params, apiKey) => {
339
+ const url = endpointUrl(params.base_url, "https://api.deepseek.com", "/models");
340
+ const data = await requestCatalogJson<{
341
+ data?: Array<{
342
+ id: string;
343
+ context_window?: number;
344
+ max_output_tokens?: number;
345
+ input_modalities?: string[];
346
+ }>;
347
+ }>("DeepSeek", url, {
348
+ headers: {
349
+ ...params.extra_headers,
350
+ Authorization: "Bearer " + apiKey,
164
351
  },
165
- }))
166
- .sort((a, b) => a.id.localeCompare(b.id));
352
+ });
353
+ return (data.data ?? []).map((model): CatalogModel => {
354
+ const known = modelCapabilities("deepseek", model.id);
355
+ const vision = model.input_modalities?.includes("image") ?? known.vision;
356
+ return {
357
+ id: model.id,
358
+ context_length: model.context_window ?? getKnownContextLength("deepseek", model.id),
359
+ max_output_tokens: model.max_output_tokens ?? getKnownMaxOutputTokens("deepseek", model.id),
360
+ hosted_on: "deepseek",
361
+ capabilities: {
362
+ ...known,
363
+ vision,
364
+ audio: model.input_modalities?.includes("audio") ?? known.audio,
365
+ },
366
+ };
367
+ });
368
+ }, deepseekKnownModels);
369
+ }
167
370
 
168
- return chatModels;
371
+ function endpointUrl(baseUrl: unknown, defaultBaseUrl: string, path: string): URL {
372
+ const base = typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim() : defaultBaseUrl;
373
+ let url: URL;
374
+ try {
375
+ url = new URL(base);
376
+ } catch {
377
+ throw new Error("Provider base URL must be a valid absolute URL");
378
+ }
379
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
380
+ throw new Error("Provider base URL must use HTTP or HTTPS");
381
+ }
382
+ const normalizedPath = url.pathname.replace(/\/+$/, "");
383
+ const suffix = path === "/v1/models" && normalizedPath.endsWith("/v1") ? "/models" : path;
384
+ url.pathname = `${normalizedPath}${suffix}` || "/";
385
+ url.search = "";
386
+ url.hash = "";
387
+ return url;
169
388
  }
170
389
 
171
390
  // ── GitHub Copilot ────────────────────────────────────────────────────────────
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { NextRequest, NextResponse } from "next/server";
10
+ import { z } from "zod";
10
11
  import {
11
12
  deleteThread,
12
13
  getMessagesAfter,
@@ -23,6 +24,7 @@ import { parseStoredTopics } from "@/lib/agents/conversation-summary";
23
24
  import { getThreadActivity } from "@/lib/agents/thread-activity";
24
25
  import { getCheckpointer } from "@/lib/agents/checkpointer";
25
26
  import { errorMessage } from "@/lib/utils/error";
27
+ import { errorResponse, noStoreJson } from "@/lib/api/responses";
26
28
 
27
29
  type Params = { params: Promise<{ thread_id: string }> };
28
30
 
@@ -41,6 +43,9 @@ export async function GET(req: NextRequest, { params }: Params) {
41
43
  if (!thread) return NextResponse.json({ error: "Thread not found" }, { status: 404 });
42
44
 
43
45
  const url = new URL(req.url);
46
+ const view = z.enum(["messages", "metadata"]).default("messages")
47
+ .safeParse(url.searchParams.get("view") ?? undefined);
48
+ if (!view.success) return errorResponse("Invalid thread view", 400);
44
49
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || DEFAULT_PAGE));
45
50
  // Cursors are the message `seq` (SQLite rowid) — a monotonic order key,
46
51
  // not a timestamp — so pagination can't skip/duplicate rows on a
@@ -48,28 +53,6 @@ export async function GET(req: NextRequest, { params }: Params) {
48
53
  const before = parseSeqParam(url.searchParams.get("before"));
49
54
  const after = parseSeqParam(url.searchParams.get("after"));
50
55
 
51
- // `after` is a forward-fetch shortcut: caller (typically ChatView's
52
- // run-completion handler) already has the recent messages and just wants
53
- // the freshly-persisted ones. has_more is fixed to false because the
54
- // forward window is not paginated — caller already has everything older.
55
- let messages: MessageRow[];
56
- let has_more: boolean;
57
- if (after !== undefined) {
58
- messages = getMessagesAfter(thread_id, after, limit);
59
- has_more = false;
60
- } else {
61
- const page = getMessagesPage(thread_id, limit, before);
62
- messages = page.messages;
63
- has_more = page.has_more;
64
- }
65
-
66
- // Attach per-assistant-turn usage so the chat UI can render a context
67
- // utilisation bar without an extra round-trip per message. User turns
68
- // and legacy assistant rows (pre-message_usage table) simply omit the
69
- // field.
70
- const assistantIds = messages.filter((m) => m.role === "assistant").map((m) => m.msg_id);
71
- const usageById = getMessageUsageByIds(assistantIds);
72
-
73
56
  // Resolve the agent's effective context-window size once for the whole
74
57
  // thread. The bar scales against this cap so the visual matches the
75
58
  // budget the agent applies at run time.
@@ -82,7 +65,7 @@ export async function GET(req: NextRequest, { params }: Params) {
82
65
  DEFAULT_CONTEXT_WINDOW_TOKENS,
83
66
  );
84
67
 
85
- return NextResponse.json({
68
+ const metadata = {
86
69
  ...thread,
87
70
  warm_summary_topics: parseStoredTopics(thread.warm_summary_topics),
88
71
  warm_channel_summaries: currentChannelSummaries(thread_id, thread.hot_since_seq),
@@ -91,6 +74,26 @@ export async function GET(req: NextRequest, { params }: Params) {
91
74
  pending_hot_since_seq: pendingCompactionBoundarySeq(thread_id),
92
75
  manual_compaction_pending: isManualCompactionRunning(thread_id),
93
76
  activity: getThreadActivity(thread_id),
77
+ };
78
+ if (view.data === "metadata") return noStoreJson(metadata);
79
+
80
+ // `after` is a forward-fetch shortcut: caller already has the recent
81
+ // messages and just wants the freshly persisted ones.
82
+ let messages: MessageRow[];
83
+ let has_more: boolean;
84
+ if (after !== undefined) {
85
+ messages = getMessagesAfter(thread_id, after, limit);
86
+ has_more = false;
87
+ } else {
88
+ const page = getMessagesPage(thread_id, limit, before);
89
+ messages = page.messages;
90
+ has_more = page.has_more;
91
+ }
92
+ const assistantIds = messages.filter((m) => m.role === "assistant").map((m) => m.msg_id);
93
+ const usageById = getMessageUsageByIds(assistantIds);
94
+
95
+ return noStoreJson({
96
+ ...metadata,
94
97
  // No server-side filtering: clients receive every message with its
95
98
  // `category` tag and apply the chat-panel filter toolbar on the
96
99
  // render side. Keeping the raw transcript over the wire means audit
package/app/globals.css CHANGED
@@ -115,6 +115,23 @@
115
115
  }
116
116
  }
117
117
 
118
+ html[data-accent-color="blue"] {
119
+ --color-accent: #2563eb;
120
+ --color-accent-hover: #1d4ed8;
121
+ }
122
+ html[data-accent-color="teal"] {
123
+ --color-accent: #0f766e;
124
+ --color-accent-hover: #115e59;
125
+ }
126
+ html[data-accent-color="violet"] {
127
+ --color-accent: #6d28d9;
128
+ --color-accent-hover: #5b21b6;
129
+ }
130
+ html[data-accent-color="rose"] {
131
+ --color-accent: #be123c;
132
+ --color-accent-hover: #9f1239;
133
+ }
134
+
118
135
  /* Tailwind v4 changed the default border colour from gray-200 to
119
136
  * currentColor. Restore the v3 behaviour so existing `border` shorthand
120
137
  * (without an explicit colour) keeps rendering as the themed divider. */
@@ -195,31 +212,27 @@ body {
195
212
  * pronounced top sheen. *
196
213
  * ---------------------------------------------------------------------- */
197
214
  .glass {
198
- background-color: rgba(24, 24, 27, 0.32);
199
- background-image: linear-gradient(
200
- 180deg,
201
- rgba(255, 255, 255, 0.07) 0%,
202
- transparent 65%
203
- );
215
+ background-color: rgba(24, 24, 27, 0.38);
216
+ background-image:
217
+ linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.035) 34%, transparent 68%),
218
+ linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 68%);
204
219
  -webkit-backdrop-filter: blur(24px) saturate(2);
205
220
  backdrop-filter: blur(24px) saturate(2);
206
221
  box-shadow:
207
- inset 0 1px 0 0 rgba(255, 255, 255, 0.18),
208
- inset 0 -1px 0 0 rgba(255, 255, 255, 0.04),
209
- inset 0 0 0 1px rgba(255, 255, 255, 0.05);
222
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.25),
223
+ inset 0 -1px 0 0 rgba(255, 255, 255, 0.06),
224
+ inset 0 0 0 1px rgba(255, 255, 255, 0.07);
210
225
  }
211
226
  .glass-elevated {
212
- background-color: rgba(24, 24, 27, 0.42);
213
- background-image: linear-gradient(
214
- 180deg,
215
- rgba(255, 255, 255, 0.09) 0%,
216
- transparent 60%
217
- );
227
+ background-color: rgba(24, 24, 27, 0.5);
228
+ background-image:
229
+ linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.04) 34%, transparent 68%),
230
+ linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, transparent 65%);
218
231
  -webkit-backdrop-filter: blur(32px) saturate(2.1);
219
232
  backdrop-filter: blur(32px) saturate(2.1);
220
233
  box-shadow:
221
- inset 0 1px 0 0 rgba(255, 255, 255, 0.22),
222
- inset 0 0 0 1px rgba(255, 255, 255, 0.06),
234
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.3),
235
+ inset 0 0 0 1px rgba(255, 255, 255, 0.08),
223
236
  0 24px 60px -12px rgba(0, 0, 0, 0.5);
224
237
  }
225
238
  .glass-bubble {
@@ -253,15 +266,19 @@ body {
253
266
  * inner edge flips to a hairline of slate so the glass reads as raised
254
267
  * rather than dissolved into the page. */
255
268
  [data-theme="light"] .glass {
256
- background-color: rgba(255, 255, 255, 0.55);
257
- background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, transparent 65%);
269
+ background-color: rgba(255, 255, 255, 0.48);
270
+ background-image:
271
+ linear-gradient(135deg, rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.18) 34%, transparent 68%),
272
+ linear-gradient(180deg, rgba(255, 255, 255, 0.28) 0%, transparent 65%);
258
273
  box-shadow:
259
- inset 0 1px 0 0 rgba(255, 255, 255, 0.85),
260
- inset 0 0 0 1px rgba(15, 23, 42, 0.07);
274
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.98),
275
+ inset 0 0 0 1px rgba(15, 23, 42, 0.09);
261
276
  }
262
277
  [data-theme="light"] .glass-elevated {
263
- background-color: rgba(255, 255, 255, 0.65);
264
- background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, transparent 60%);
278
+ background-color: rgba(255, 255, 255, 0.58);
279
+ background-image:
280
+ linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.22) 34%, transparent 68%),
281
+ linear-gradient(180deg, rgba(255, 255, 255, 0.36) 0%, transparent 60%);
265
282
  box-shadow:
266
283
  inset 0 1px 0 0 rgba(255, 255, 255, 0.9),
267
284
  inset 0 0 0 1px rgba(15, 23, 42, 0.07),
@@ -276,15 +293,19 @@ body {
276
293
  }
277
294
  @media (prefers-color-scheme: light) {
278
295
  [data-theme="system"] .glass {
279
- background-color: rgba(255, 255, 255, 0.55);
280
- background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, transparent 65%);
296
+ background-color: rgba(255, 255, 255, 0.48);
297
+ background-image:
298
+ linear-gradient(135deg, rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.18) 34%, transparent 68%),
299
+ linear-gradient(180deg, rgba(255, 255, 255, 0.28) 0%, transparent 65%);
281
300
  box-shadow:
282
- inset 0 1px 0 0 rgba(255, 255, 255, 0.85),
283
- inset 0 0 0 1px rgba(15, 23, 42, 0.07);
301
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.98),
302
+ inset 0 0 0 1px rgba(15, 23, 42, 0.09);
284
303
  }
285
304
  [data-theme="system"] .glass-elevated {
286
- background-color: rgba(255, 255, 255, 0.65);
287
- background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, transparent 60%);
305
+ background-color: rgba(255, 255, 255, 0.58);
306
+ background-image:
307
+ linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.22) 34%, transparent 68%),
308
+ linear-gradient(180deg, rgba(255, 255, 255, 0.36) 0%, transparent 60%);
288
309
  box-shadow:
289
310
  inset 0 1px 0 0 rgba(255, 255, 255, 0.9),
290
311
  inset 0 0 0 1px rgba(15, 23, 42, 0.07),
@@ -310,6 +331,75 @@ body {
310
331
  }
311
332
  }
312
333
 
334
+ [data-surface-style="calm"] body {
335
+ background-image: none;
336
+ }
337
+ [data-surface-style="calm"] .dashboard-atmosphere {
338
+ display: none;
339
+ }
340
+ [data-surface-style="calm"] .glass,
341
+ [data-surface-style="calm"] .glass-elevated {
342
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
343
+ backdrop-filter: blur(14px) saturate(1.2);
344
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, transparent 65%);
345
+ }
346
+ [data-surface-style="calm"] .glass-bubble,
347
+ [data-surface-style="calm"] .glass-bubble-accent {
348
+ -webkit-backdrop-filter: none;
349
+ backdrop-filter: none;
350
+ background-image: none;
351
+ }
352
+ [data-surface-style="calm"] .glass,
353
+ [data-surface-style="calm"] .glass-elevated {
354
+ background-color: color-mix(in srgb, var(--color-surface-2) 78%, transparent);
355
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 70%, transparent);
356
+ }
357
+ [data-surface-style="calm"] .glass-elevated {
358
+ box-shadow:
359
+ inset 0 0 0 1px color-mix(in srgb, var(--color-border) 70%, transparent),
360
+ 0 12px 32px -16px rgba(0, 0, 0, 0.35);
361
+ }
362
+ [data-surface-style="calm"] .glass-bubble {
363
+ background-color: var(--color-surface-3);
364
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 50%, transparent);
365
+ }
366
+ [data-surface-style="calm"] .glass-bubble-accent {
367
+ background-color: color-mix(in srgb, var(--color-accent) 82%, var(--color-surface-2));
368
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 45%, transparent);
369
+ }
370
+
371
+ [data-surface-style="liquid"] .glass,
372
+ [data-surface-style="liquid"] .glass-elevated {
373
+ background-color: color-mix(in srgb, var(--color-surface-2) 30%, transparent);
374
+ background-image:
375
+ radial-gradient(ellipse 120% 150% at 0% 0%, rgba(255, 255, 255, 0.2), transparent 54%),
376
+ linear-gradient(115deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.035) 24%, transparent 50%, color-mix(in srgb, var(--color-accent) 8%, transparent) 100%),
377
+ linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 68%);
378
+ -webkit-backdrop-filter: blur(22px) saturate(1.65) contrast(1.06);
379
+ backdrop-filter: blur(22px) saturate(1.65) contrast(1.06);
380
+ box-shadow:
381
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.48),
382
+ inset 0 -1px 0 0 rgba(0, 0, 0, 0.08),
383
+ inset 0 0 0 1px rgba(255, 255, 255, 0.16),
384
+ 0 8px 24px -18px rgba(0, 0, 0, 0.45);
385
+ }
386
+ [data-surface-style="liquid"] .glass-elevated {
387
+ background-color: color-mix(in srgb, var(--color-surface-2) 38%, transparent);
388
+ box-shadow:
389
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.56),
390
+ inset 0 0 0 1px rgba(255, 255, 255, 0.2),
391
+ 0 24px 60px -12px rgba(0, 0, 0, 0.42),
392
+ 0 0 30px -22px color-mix(in srgb, var(--color-accent) 40%, transparent);
393
+ }
394
+ @supports not (backdrop-filter: blur(1px)) {
395
+ [data-surface-style="liquid"] .glass,
396
+ [data-surface-style="liquid"] .glass-elevated {
397
+ background-color: var(--color-surface-2);
398
+ background-image: none;
399
+ box-shadow: inset 0 0 0 1px var(--color-border);
400
+ }
401
+ }
402
+
313
403
  /* Disable the blue tap flash on iOS / Android; we use explicit :active styles. */
314
404
  * { -webkit-tap-highlight-color: transparent; }
315
405
 
package/app/layout.tsx CHANGED
@@ -90,6 +90,18 @@ const themeBootstrap = `(() => {
90
90
  var t = localStorage.getItem("jarela-theme");
91
91
  if (t !== "light" && t !== "dark" && t !== "system") t = "system";
92
92
  document.documentElement.setAttribute("data-theme", t);
93
+ var accent = localStorage.getItem("jarela-accent-color");
94
+ if (["blue", "teal", "violet", "rose"].includes(accent)) {
95
+ document.documentElement.setAttribute("data-accent-color", accent);
96
+ } else {
97
+ document.documentElement.removeAttribute("data-accent-color");
98
+ }
99
+ var surface = localStorage.getItem("jarela-surface-style");
100
+ if (surface === "calm" || surface === "ambient" || surface === "liquid") {
101
+ document.documentElement.setAttribute("data-surface-style", surface);
102
+ } else {
103
+ document.documentElement.removeAttribute("data-surface-style");
104
+ }
93
105
  // Collapse any media-scoped theme-color metas Next emits to a single tag
94
106
  // so the PWA window chrome matches the user's explicit choice (not just
95
107
  // prefers-color-scheme). ThemeContext keeps this in sync on change.