@flame0510/project-aether 1.1.15 → 1.3.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.
- package/README.md +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/Skeleton.tsx +132 -0
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +461 -140
- package/app/credentials/loading.tsx +19 -5
- package/app/gateway/PageClient.tsx +257 -673
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +73 -9
- package/docs/ARCHITECTURE.md +92 -33
- package/docs/FRONTEND-ARCHITECTURE.md +36 -6
- package/docs/REV4A.md +62 -30
- package/docs/dev/API-REFERENCE.md +490 -227
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +236 -92
- package/docs/dev/PROVIDERS.md +44 -44
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +20 -18
- package/docs/rag/REV4A-OVERVIEW.md +28 -32
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/lib/model-catalogue.ts +140 -27
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -3,143 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/provider/v1/models
|
|
5
5
|
*
|
|
6
|
-
* Returns models available through the Rev4a Gateway
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Returns the models available through the Rev4a Gateway, in OpenAI-compatible
|
|
7
|
+
* format. This is the endpoint agent containers call to discover what they can
|
|
8
|
+
* ask for, so it must agree with what `gateway/sync.ts` writes into their config.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* It did not. This route used to carry its own copy of the catalogue and its own
|
|
11
|
+
* loader, and the two diverged in ways that mattered:
|
|
12
|
+
*
|
|
13
|
+
* - **User toggles were ignored here.** Disabling a model from the Gateway page
|
|
14
|
+
* writes to `model-overrides.json`, which `loadModelsConfig()` applies. This
|
|
15
|
+
* route read `models.config.json` raw, so a model switched off in the UI was
|
|
16
|
+
* still advertised to agents.
|
|
17
|
+
* - **A read failure overwrote the catalogue.** If the file was missing or
|
|
18
|
+
* momentarily unparseable, the `catch` wrote a hardcoded 30-model default over
|
|
19
|
+
* it — replacing the real catalogue, user overrides included.
|
|
20
|
+
*
|
|
21
|
+
* Both are gone: the catalogue now comes from `lib/model-catalogue`, the single
|
|
22
|
+
* loader shared with the create route, the provider page and the sync.
|
|
12
23
|
*/
|
|
13
24
|
import { NextResponse, NextRequest } from 'next/server';
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import * as path from 'path';
|
|
17
|
-
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
25
|
+
import { isGatewayClient } from '@/app/api/provider/auth';
|
|
26
|
+
import { loadOfferedModels } from '@/lib/model-catalogue';
|
|
18
27
|
|
|
19
28
|
export const dynamic = 'force-dynamic';
|
|
20
29
|
export const runtime = 'nodejs';
|
|
21
30
|
|
|
22
|
-
interface ModelEntry {
|
|
23
|
-
id: string;
|
|
24
|
-
object: string;
|
|
25
|
-
created: number;
|
|
26
|
-
owned_by: string;
|
|
27
|
-
permission: string[];
|
|
28
|
-
root: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ModelsConfigEntry {
|
|
32
|
-
id: string;
|
|
33
|
-
name: string;
|
|
34
|
-
provider: string;
|
|
35
|
-
enabled: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const CONFIG_PATH = MODELS_CONFIG_FILE;
|
|
39
|
-
|
|
40
|
-
/** Default model catalogue (known models for each provider) */
|
|
41
|
-
const DEFAULT_CATALOGUE: ModelsConfigEntry[] = [
|
|
42
|
-
// OpenAI
|
|
43
|
-
{ id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', provider: 'openai', enabled: true },
|
|
44
|
-
{ id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'openai', enabled: true },
|
|
45
|
-
{ id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'openai', enabled: true },
|
|
46
|
-
{ id: 'openai/gpt-5.5', name: 'GPT-5.5', provider: 'openai', enabled: true },
|
|
47
|
-
{ id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'openai', enabled: true },
|
|
48
|
-
{ id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', enabled: true },
|
|
49
|
-
// Anthropic
|
|
50
|
-
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', provider: 'anthropic', enabled: false },
|
|
51
|
-
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', provider: 'anthropic', enabled: false },
|
|
52
|
-
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', provider: 'anthropic', enabled: false },
|
|
53
|
-
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic', enabled: false },
|
|
54
|
-
// DeepSeek
|
|
55
|
-
{ id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'deepseek', enabled: true },
|
|
56
|
-
{ id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'deepseek', enabled: true },
|
|
57
|
-
// OpenRouter
|
|
58
|
-
{ id: 'openrouter/auto', name: 'OpenRouter Auto', provider: 'openrouter', enabled: true },
|
|
59
|
-
{ id: 'openrouter/deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash (OR)', provider: 'openrouter', enabled: true },
|
|
60
|
-
{ id: 'openrouter/google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (OR)', provider: 'openrouter', enabled: true },
|
|
61
|
-
{ id: 'openrouter/google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (OR)', provider: 'openrouter', enabled: true },
|
|
62
|
-
{ id: 'openrouter/anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6 (OR)', provider: 'openrouter', enabled: false },
|
|
63
|
-
{ id: 'openrouter/deepseek/deepseek-r1-0528', name: 'DeepSeek R1 0528 (OR)', provider: 'openrouter', enabled: false },
|
|
64
|
-
// Groq
|
|
65
|
-
{ id: 'groq/llama-3.1-8b-instant', name: 'Llama 3.1 8B (Groq)', provider: 'groq', enabled: false },
|
|
66
|
-
{ id: 'groq/llama-3.3-70b-versatile', name: 'Llama 3.3 70B (Groq)', provider: 'groq', enabled: false },
|
|
67
|
-
// Kimi
|
|
68
|
-
{ id: 'kimi/kimi-k3', name: 'Kimi K3', provider: 'kimi', enabled: true },
|
|
69
|
-
{ id: 'kimi/kimi-k2.7-code', name: 'Kimi K2.7 Code', provider: 'kimi', enabled: false },
|
|
70
|
-
{ id: 'kimi/kimi-k2.7-code-highspeed', name: 'Kimi K2.7 Code HighSpeed', provider: 'kimi', enabled: false },
|
|
71
|
-
{ id: 'kimi/kimi-k2.6', name: 'Kimi K2.6', provider: 'kimi', enabled: false },
|
|
72
|
-
// GLM
|
|
73
|
-
{ id: 'glm/glm-5.2', name: 'GLM-5.2', provider: 'glm', enabled: false },
|
|
74
|
-
{ id: 'glm/glm-5.1', name: 'GLM-5.1', provider: 'glm', enabled: false },
|
|
75
|
-
{ id: 'glm/glm-5', name: 'GLM-5', provider: 'glm', enabled: false },
|
|
76
|
-
{ id: 'glm/glm-4.7', name: 'GLM-4.7', provider: 'glm', enabled: false },
|
|
77
|
-
{ id: 'glm/glm-4.7-flash', name: 'GLM-4.7 Flash', provider: 'glm', enabled: false },
|
|
78
|
-
{ id: 'glm/glm-4.7-flashx', name: 'GLM-4.7 FlashX', provider: 'glm', enabled: false },
|
|
79
|
-
{ id: 'glm/glm-5v-turbo', name: 'GLM-5V Turbo', provider: 'glm', enabled: false },
|
|
80
|
-
{ id: 'glm/glm-4.6v-flash', name: 'GLM-4.6V Flash', provider: 'glm', enabled: false },
|
|
81
|
-
// Qwen
|
|
82
|
-
{ id: 'qwen/qwen3.7-max', name: 'Qwen 3.7 Max', provider: 'qwen', enabled: false },
|
|
83
|
-
{ id: 'qwen/qwen3.7-plus', name: 'Qwen 3.7 Plus', provider: 'qwen', enabled: true },
|
|
84
|
-
{ id: 'qwen/qwen3.7-flash', name: 'Qwen 3.7 Flash', provider: 'qwen', enabled: false },
|
|
85
|
-
{ id: 'qwen/qwen3-coder-plus', name: 'Qwen 3 Coder Plus', provider: 'qwen', enabled: false },
|
|
86
|
-
{ id: 'qwen/qwen-long', name: 'Qwen Long', provider: 'qwen', enabled: false },
|
|
87
|
-
// Rev4a aliases (convenience models for the Rev4a proxy)
|
|
88
|
-
{ id: 'rev4a/deepseek-v4-flash', name: 'DeepSeek V4 Flash (Rev4a)', provider: 'rev4a', enabled: true },
|
|
89
|
-
{ id: 'rev4a/deepseek-v4-pro', name: 'DeepSeek V4 Pro (Rev4a)', provider: 'rev4a', enabled: true },
|
|
90
|
-
{ id: 'rev4a/fast', name: 'Rev4a Fast', provider: 'rev4a', enabled: false },
|
|
91
|
-
{ id: 'rev4a/code', name: 'Rev4a Code', provider: 'rev4a', enabled: false },
|
|
92
|
-
{ id: 'rev4a/reason', name: 'Rev4a Reason', provider: 'rev4a', enabled: false },
|
|
93
|
-
{ id: 'rev4a/fallback-general', name: 'Rev4a Fallback General', provider: 'rev4a', enabled: false },
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
/** Load the models config from file, or create from defaults */
|
|
97
|
-
function loadConfig(): ModelsConfigEntry[] {
|
|
98
|
-
try {
|
|
99
|
-
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
100
|
-
const parsed = JSON.parse(raw);
|
|
101
|
-
if (Array.isArray(parsed.models)) {
|
|
102
|
-
return parsed.models as ModelsConfigEntry[];
|
|
103
|
-
}
|
|
104
|
-
return DEFAULT_CATALOGUE;
|
|
105
|
-
} catch {
|
|
106
|
-
// File doesn't exist or invalid — write defaults
|
|
107
|
-
try {
|
|
108
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ models: DEFAULT_CATALOGUE }, null, 2), 'utf-8');
|
|
109
|
-
} catch {
|
|
110
|
-
// Can't write, fall back to defaults in memory
|
|
111
|
-
}
|
|
112
|
-
return DEFAULT_CATALOGUE;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Resolve the provider from the Authorization token by matching against provider-keys.json */
|
|
117
|
-
function resolveProviderFromToken(authHeader: string): string | null {
|
|
118
|
-
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
119
|
-
if (!token) return null;
|
|
120
|
-
|
|
121
|
-
const providerKeys = readProviderKeys();
|
|
122
|
-
|
|
123
|
-
// Match the token value against stored provider keys
|
|
124
|
-
for (const [, key] of Object.entries(providerKeys)) {
|
|
125
|
-
if (token === key) return 'authenticated';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Also try matching the rev4a API key env var (legacy compat)
|
|
129
|
-
if (token === process.env.REV4A_API_KEY) return 'all';
|
|
130
|
-
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
31
|
export async function GET(request: NextRequest) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const provider = resolveProviderFromToken(authHeader);
|
|
140
|
-
|
|
141
|
-
if (!provider) {
|
|
142
|
-
// No valid token — return empty list to avoid leaking model info
|
|
32
|
+
if (!isGatewayClient(request)) {
|
|
33
|
+
// An empty list rather than a 401: this endpoint should not confirm the
|
|
34
|
+
// catalogue to a caller that cannot use it. The cost is that a container
|
|
35
|
+
// configured with the wrong key sees "no models" instead of an auth error.
|
|
143
36
|
return NextResponse.json({
|
|
144
37
|
object: 'list',
|
|
145
38
|
total: 0,
|
|
@@ -147,10 +40,10 @@ export async function GET(request: NextRequest) {
|
|
|
147
40
|
});
|
|
148
41
|
}
|
|
149
42
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
activeModels =
|
|
43
|
+
// Same set as gateway/sync.ts writes into the containers, so what an agent can
|
|
44
|
+
// discover matches what it was actually given. This route used to skip the
|
|
45
|
+
// provider-key half of that test and advertise models nobody could be billed for.
|
|
46
|
+
const activeModels = loadOfferedModels();
|
|
154
47
|
|
|
155
48
|
return NextResponse.json({
|
|
156
49
|
object: 'list',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { useModels } from '../lib/models-context';
|
|
5
6
|
import Link from 'next/link';
|
|
6
7
|
|
|
7
8
|
const STORAGE_KEY = 'rev4a_pulse_chat';
|
|
@@ -79,51 +80,36 @@ export default function PulseChat() {
|
|
|
79
80
|
const [input, setInput] = useState('');
|
|
80
81
|
const [streaming, setStreaming] = useState(false);
|
|
81
82
|
const [selectedModel, setSelectedModel] = useState<string>('');
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// The shared catalogue, not a private copy. It refreshes when the Gateway page
|
|
84
|
+
// changes what is enabled, so this select cannot go stale mid-session.
|
|
85
|
+
const { providers: modelGroups, loaded: modelsLoaded } = useModels();
|
|
86
|
+
const modelsLoading = !modelsLoaded;
|
|
84
87
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
85
88
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
86
89
|
const abortRef = useRef<AbortController | null>(null);
|
|
87
90
|
|
|
88
|
-
//
|
|
91
|
+
// Keep the choice valid against the current catalogue.
|
|
92
|
+
//
|
|
93
|
+
// Runs whenever the shared list changes, which includes the moment a model is
|
|
94
|
+
// unchecked on the Gateway page: a selection that just became unavailable is
|
|
95
|
+
// replaced rather than left to fail at send time. The saved preference is only
|
|
96
|
+
// honoured while it is still on offer.
|
|
89
97
|
useEffect(() => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Restore saved model if valid, otherwise pick first
|
|
104
|
-
try {
|
|
105
|
-
const saved = localStorage.getItem(MODEL_STORAGE_KEY);
|
|
106
|
-
if (saved && allIds.includes(saved)) {
|
|
107
|
-
setSelectedModel(saved);
|
|
108
|
-
} else if (allIds.length > 0) {
|
|
109
|
-
setSelectedModel(allIds[0]);
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
if (allIds.length > 0) setSelectedModel(allIds[0]);
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
// API unavailable — leave selectedModel empty, select stays disabled
|
|
116
|
-
} finally {
|
|
117
|
-
if (!cancelled) setModelsLoading(false);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
fetchModels();
|
|
122
|
-
return () => { cancelled = true; };
|
|
123
|
-
}, []);
|
|
124
|
-
|
|
125
|
-
// Save model preference on change
|
|
98
|
+
if (!modelsLoaded) return;
|
|
99
|
+
const allIds = modelGroups.flatMap((g) => g.models.map((m) => m.id));
|
|
100
|
+
if (allIds.length === 0) { setSelectedModel(''); return; }
|
|
101
|
+
if (selectedModel && allIds.includes(selectedModel)) return;
|
|
102
|
+
|
|
103
|
+
let saved: string | null = null;
|
|
104
|
+
try { saved = localStorage.getItem(MODEL_STORAGE_KEY); } catch { /* blocked */ }
|
|
105
|
+
setSelectedModel(saved && allIds.includes(saved) ? saved : allIds[0]);
|
|
106
|
+
}, [modelGroups, modelsLoaded, selectedModel]);
|
|
107
|
+
|
|
108
|
+
// Save model preference on change. Not while empty: on mount the selection is ''
|
|
109
|
+
// until the list arrives, and saving that erased the preference before it could
|
|
110
|
+
// be read back.
|
|
126
111
|
useEffect(() => {
|
|
112
|
+
if (!selectedModel) return;
|
|
127
113
|
try {
|
|
128
114
|
localStorage.setItem(MODEL_STORAGE_KEY, selectedModel);
|
|
129
115
|
} catch {
|
|
@@ -202,3 +202,135 @@ export function CardRowSkeleton({ count = 3, height = 56 }: { count?: number; he
|
|
|
202
202
|
</div>
|
|
203
203
|
);
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Credential provider cards.
|
|
208
|
+
*
|
|
209
|
+
* The credentials page renders one card per entry in CREDENTIAL_PROVIDERS,
|
|
210
|
+
* which is a static array of five — so the card count here is known, not
|
|
211
|
+
* guessed, and the grid will not reflow when the data lands.
|
|
212
|
+
*
|
|
213
|
+
* Each shape mirrors the real text it stands in for: the label and category
|
|
214
|
+
* widths track the actual provider names ("Cloud" is short, "Project
|
|
215
|
+
* Management" is not), and the description line count follows how long each
|
|
216
|
+
* provider's blurb actually is. That is what keeps the shimmer from jumping
|
|
217
|
+
* when it is replaced.
|
|
218
|
+
*
|
|
219
|
+
* `configuredCards` is the one genuine unknown: whether a provider has stored
|
|
220
|
+
* profiles is only known once /api/credentials answers. Cards with a profile
|
|
221
|
+
* are markedly taller, so rendering none would guarantee a jump downward for
|
|
222
|
+
* anyone who has set some up. Two of five is the middle of the realistic range
|
|
223
|
+
* and keeps the error small in both directions.
|
|
224
|
+
*/
|
|
225
|
+
export function CredentialCardsSkeleton({ configuredCards = 2 }: { configuredCards?: number }) {
|
|
226
|
+
// Ordered as CREDENTIAL_PROVIDERS: GitHub, Trello, Vercel, Supabase, Notion.
|
|
227
|
+
const cards = [
|
|
228
|
+
{ label: 46, cat: 84, pill: 64, desc: ['100%', '58%'], delivery: '56%' },
|
|
229
|
+
{ label: 44, cat: 98, pill: 54, desc: ['92%'], delivery: '82%' },
|
|
230
|
+
{ label: 44, cat: 34, pill: 64, desc: ['100%', '64%'], delivery: '90%' },
|
|
231
|
+
{ label: 58, cat: 50, pill: 54, desc: ['100%', '86%'], delivery: '78%' },
|
|
232
|
+
{ label: 44, cat: 98, pill: 54, desc: ['100%', '92%'], delivery: '72%' },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
style={{
|
|
239
|
+
display: 'grid',
|
|
240
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
241
|
+
gap: 'clamp(12px, 3vw, 16px)',
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{cards.map((c, i) => (
|
|
245
|
+
<div
|
|
246
|
+
key={i}
|
|
247
|
+
style={{
|
|
248
|
+
background: 'var(--bg2)',
|
|
249
|
+
border: '1px solid var(--border)',
|
|
250
|
+
display: 'flex',
|
|
251
|
+
flexDirection: 'column',
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{/* Header: provider badge, name over category, status pill */}
|
|
255
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
256
|
+
<Skeleton width={28} height={28} accent style={{ flexShrink: 0 }} />
|
|
257
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
258
|
+
<Skeleton width={c.label} height={12} />
|
|
259
|
+
<Skeleton width={c.cat} height={9} style={{ marginTop: 4 }} />
|
|
260
|
+
</div>
|
|
261
|
+
<Skeleton width={c.pill} height={16} style={{ flexShrink: 0 }} />
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Description */}
|
|
265
|
+
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
266
|
+
{c.desc.map((w, j) => (
|
|
267
|
+
<Skeleton key={j} width={w} height={9} style={{ marginTop: j === 0 ? 0 : 5 }} />
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Delivery method — one mono line */}
|
|
272
|
+
<div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
273
|
+
<Skeleton width={c.delivery} height={9} />
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* A stored profile: label, masked secret + status + sync time, actions,
|
|
277
|
+
then the per-agent sync rows underneath. */}
|
|
278
|
+
{i < configuredCards && (
|
|
279
|
+
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
280
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
281
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
282
|
+
<Skeleton width="52%" height={11} />
|
|
283
|
+
<div style={{ display: 'flex', gap: 10, marginTop: 5 }}>
|
|
284
|
+
<Skeleton width={76} height={9} />
|
|
285
|
+
<Skeleton width={8} height={9} />
|
|
286
|
+
<Skeleton width={62} height={9} />
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<Skeleton width={38} height={22} style={{ flexShrink: 0 }} />
|
|
290
|
+
<Skeleton width={22} height={22} style={{ flexShrink: 0 }} />
|
|
291
|
+
</div>
|
|
292
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 9 }}>
|
|
293
|
+
<Skeleton width="74%" height={9} />
|
|
294
|
+
<Skeleton width="66%" height={9} />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{/* Footer action, pinned to the bottom as in the real card */}
|
|
300
|
+
<div style={{ padding: '10px 14px', marginTop: 'auto' }}>
|
|
301
|
+
<Skeleton width={i < configuredCards ? 92 : 74} height={26} />
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Per-agent sync rows inside a credential profile.
|
|
311
|
+
*
|
|
312
|
+
* Stands in for the block fed by /api/credentials/detect, which shells into
|
|
313
|
+
* every agent container and takes seconds. The agent list itself comes from that
|
|
314
|
+
* same response, so before it lands the count is unknown — three rows is a
|
|
315
|
+
* placeholder, not a prediction, and the block is deliberately the only thing
|
|
316
|
+
* that waits: the cards, the stored profiles and the Add buttons all come from
|
|
317
|
+
* the cheap endpoint and render immediately.
|
|
318
|
+
*
|
|
319
|
+
* No Sync/De-sync button is drawn here on purpose. Their label depends on
|
|
320
|
+
* whether the credential is installed, which is exactly what is still unknown —
|
|
321
|
+
* a button rendered now would have to guess, and guessing wrong invites a click
|
|
322
|
+
* that does the opposite of what it says.
|
|
323
|
+
*/
|
|
324
|
+
export function AgentSyncRowsSkeleton({ rows = 3 }: { rows?: number }) {
|
|
325
|
+
const widths = ['62%', '48%', '55%'];
|
|
326
|
+
return (
|
|
327
|
+
<div aria-hidden="true" style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
328
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
329
|
+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
330
|
+
<Skeleton width={8} height={8} variant="circle" style={{ flexShrink: 0 }} />
|
|
331
|
+
<Skeleton width={widths[i % widths.length]} height={9} />
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remove an item from a list.
|
|
5
|
+
*
|
|
6
|
+
* A small `×` at the end of a row, rather than a "Remove" label: the word reads
|
|
7
|
+
* as an action already carried out, which is wrong wherever the change only
|
|
8
|
+
* lands on save.
|
|
9
|
+
*
|
|
10
|
+
* One style everywhere — the `danger` treatment the credentials page already
|
|
11
|
+
* used. Whether the removal is immediate or pending is said in the tooltip, not
|
|
12
|
+
* in the colour: two tones for the same gesture made the button look like two
|
|
13
|
+
* different controls.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately separate from dismissing a dialog, which looks similar and means
|
|
16
|
+
* something else. See rule 8 in docs/FRONTEND-ARCHITECTURE.md.
|
|
17
|
+
*/
|
|
18
|
+
import { Button } from './Button';
|
|
19
|
+
|
|
20
|
+
export function RemoveButton({
|
|
21
|
+
onClick,
|
|
22
|
+
label,
|
|
23
|
+
disabled,
|
|
24
|
+
title,
|
|
25
|
+
}: {
|
|
26
|
+
onClick: () => void;
|
|
27
|
+
/** What is being removed. Becomes the accessible name: "Remove <label>". */
|
|
28
|
+
label: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Override when the consequence is worth spelling out, e.g. a pending edit. */
|
|
31
|
+
title?: string;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<Button
|
|
35
|
+
type="button"
|
|
36
|
+
variant="danger"
|
|
37
|
+
size="sm"
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
aria-label={`Remove ${label}`}
|
|
40
|
+
title={title ?? `Remove ${label}`}
|
|
41
|
+
onClick={onClick}
|
|
42
|
+
>
|
|
43
|
+
×
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -5,7 +5,8 @@ import { useId, type SelectHTMLAttributes, type ReactNode } from 'react';
|
|
|
5
5
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
6
6
|
label?: string;
|
|
7
7
|
error?: string;
|
|
8
|
-
|
|
8
|
+
/** `disabled` shows a value that exists but may not be chosen, e.g. a current setting no longer on offer. */
|
|
9
|
+
options: { value: string; label: ReactNode; disabled?: boolean }[];
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export function Select({ label, error, options, id, className = '', ...rest }: SelectProps) {
|
|
@@ -17,7 +18,7 @@ export function Select({ label, error, options, id, className = '', ...rest }: S
|
|
|
17
18
|
{label && <label className="ui-field__label" htmlFor={selectId}>{label}</label>}
|
|
18
19
|
<select id={selectId} className={`ui-select ${error ? 'ui-select--error' : ''} ${className}`} {...rest}>
|
|
19
20
|
{options.map((o) => (
|
|
20
|
-
<option key={o.value} value={o.value}>{o.label}</option>
|
|
21
|
+
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
|
|
21
22
|
))}
|
|
22
23
|
</select>
|
|
23
24
|
{error && <div className="ui-field__error">{error}</div>}
|