@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.14
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/NOTICE +6 -0
- package/README.md +102 -0
- package/dist/bin.js +122 -37
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +26 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +70 -4
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +37 -0
- package/dist/commands/create-flow.d.ts.map +1 -0
- package/dist/commands/create-flow.js +212 -0
- package/dist/commands/create-flow.js.map +1 -0
- package/dist/commands/create.d.ts +27 -6
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +53 -13
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +14 -3
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +295 -37
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +73 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +130 -5
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts +97 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +141 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +103 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts +5 -5
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +22 -6
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/env-files.d.ts +12 -0
- package/dist/env-files.d.ts.map +1 -0
- package/dist/env-files.js +29 -0
- package/dist/env-files.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +36 -15
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +32 -16
- package/dist/manifest.js.map +1 -1
- package/dist/migrations.d.ts +3 -6
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +3 -6
- package/dist/migrations.js.map +1 -1
- package/dist/model-catalogue.d.ts +14 -0
- package/dist/model-catalogue.d.ts.map +1 -0
- package/dist/model-catalogue.js +34 -0
- package/dist/model-catalogue.js.map +1 -0
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +84 -0
- package/dist/registry-items.d.ts.map +1 -0
- package/dist/registry-items.js +187 -0
- package/dist/registry-items.js.map +1 -0
- package/package.json +36 -10
- package/src/bin.ts +316 -0
- package/src/commands/add.ts +255 -0
- package/src/commands/create-flow.ts +284 -0
- package/src/commands/create.ts +149 -0
- package/src/commands/doctor.ts +487 -0
- package/src/commands/migrate-check.ts +298 -0
- package/src/commands/migrate.ts +228 -0
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +534 -0
- package/src/commands/upgrade-check.ts +160 -0
- package/src/env-files.ts +31 -0
- package/src/index.ts +39 -0
- package/src/manifest.ts +147 -0
- package/src/migrations-dir.ts +25 -0
- package/src/migrations.ts +133 -0
- package/src/model-catalogue.ts +35 -0
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +237 -0
- package/templates/admin-page/admin-page.tsx.tpl +2 -3
- package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
- package/templates/app-scaffold/auth-route.ts.tpl +10 -0
- package/templates/app-scaffold/components.json.tpl +6 -2
- package/templates/app-scaffold/db-schema.ts.tpl +29 -0
- package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
- package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
- package/templates/app-scaffold/env.example.tpl +65 -1
- package/templates/app-scaffold/globals.css.tpl +172 -281
- package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
- package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
- package/templates/app-scaffold/intelligo.ts.tpl +95 -41
- package/templates/app-scaffold/layout.tsx.tpl +18 -1
- package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
- package/templates/app-scaffold/next.config.mjs.tpl +2 -0
- package/templates/app-scaffold/package.json.tpl +31 -17
- package/templates/app-scaffold/page.tsx.tpl +16 -0
- package/templates/app-scaffold/plans.ts.tpl +32 -13
- package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
- package/templates/app-scaffold/sonner.tsx.tpl +42 -20
- package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
- package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
- package/templates/app-scaffold/tsconfig.json.tpl +20 -4
- package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
- package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
- package/templates/maintenance/maintenance-route.ts.tpl +119 -0
- package/templates/manifest.json +75 -17
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +60 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +52 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +111 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2945 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +116 -0
- package/templates/registry-requires.json +180 -0
- package/templates/usage-page/usage-page.tsx.tpl +23 -8
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
- package/templates/billing-page/billing-page.tsx.tpl +0 -72
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceCreated } from "@intelligo-dev/auth";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runs once, when a user's personal workspace is created — at signup,
|
|
7
|
+
* or the first time the app finds the user without one: the place for
|
|
8
|
+
* trial credits, referral bonuses or any other one-time provisioning.
|
|
9
|
+
* `lib/intelligo.ts` sets it with `setWorkspaceCreatedHandler`, so
|
|
10
|
+
* `@intelligo-dev/auth` never imports billing.
|
|
11
|
+
*
|
|
12
|
+
* A failure is logged and does not fail the signup.
|
|
13
|
+
*/
|
|
14
|
+
export async function onWorkspaceCreated(
|
|
15
|
+
_created: WorkspaceCreated
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
// Provisions nothing by default.
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Example: grant the registered trial with `@intelligo-dev/billing`
|
|
21
|
+
// (replace the export above):
|
|
22
|
+
//
|
|
23
|
+
// import { provisionTrialCredits } from "@intelligo-dev/billing";
|
|
24
|
+
//
|
|
25
|
+
// export async function onWorkspaceCreated({
|
|
26
|
+
// workspaceId,
|
|
27
|
+
// email,
|
|
28
|
+
// }: WorkspaceCreated): Promise<void> {
|
|
29
|
+
// await provisionTrialCredits({ workspaceId, email });
|
|
30
|
+
// }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintenance route: the periodic work the framework relies on.
|
|
3
|
+
*
|
|
4
|
+
* Mount a scheduler at GET /api/cron/maintenance with
|
|
5
|
+
* `Authorization: Bearer $CRON_SECRET` every five minutes. A stale
|
|
6
|
+
* execution is reconciled once it is ten minutes old, so the cadence
|
|
7
|
+
* decides how long an abandoned run keeps its credit hold.
|
|
8
|
+
*
|
|
9
|
+
* On Vercel, `intelligo add maintenance` writes the `crons` entry to
|
|
10
|
+
* vercel.json (`0-59/5 * * * *`) when the app has none, and Vercel
|
|
11
|
+
* sends the header itself. Vercel Hobby runs a cron at most once a day
|
|
12
|
+
* and refuses a deployment that asks for more: on Hobby, or on any
|
|
13
|
+
* other host, drop the entry and call the route from a scheduler of
|
|
14
|
+
* your own —
|
|
15
|
+
*
|
|
16
|
+
* curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
|
|
17
|
+
* https://your-app.example/api/cron/maintenance
|
|
18
|
+
*
|
|
19
|
+
* What one run does, and why each step exists:
|
|
20
|
+
*
|
|
21
|
+
* - **Reconcile stale executions.** A row still `running` or `settling`
|
|
22
|
+
* past the chat route's `maxDuration` is usage nobody finished
|
|
23
|
+
* accounting for. `executions.reconcile` confirms a charge the
|
|
24
|
+
* ledger already holds, re-runs settlement from the recorded usage,
|
|
25
|
+
* or fails an abandoned run and releases its hold.
|
|
26
|
+
* - **Drop expired reservations.** Admission already ignores them; this
|
|
27
|
+
* is table hygiene.
|
|
28
|
+
* - **Drop old rate-limit buckets.** Same.
|
|
29
|
+
* - **Expire trials and send reminders.** `hasActiveTrial` checks the
|
|
30
|
+
* end date inline, so admission is safe without this — feature
|
|
31
|
+
* access and the reminder emails are what it drives.
|
|
32
|
+
* - **Prune finished jobs** older than a week.
|
|
33
|
+
*
|
|
34
|
+
* Draining the job queue is NOT done here: `drain` needs your handlers,
|
|
35
|
+
* so give it its own route (or call it below once you have some).
|
|
36
|
+
*
|
|
37
|
+
* This file is yours. Add product maintenance to it freely.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { timingSafeEqual } from "node:crypto";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
cleanupExpiredReservations,
|
|
44
|
+
cleanupRateLimitEntries,
|
|
45
|
+
processTrialExpirations,
|
|
46
|
+
} from "@intelligo-dev/billing";
|
|
47
|
+
import { findStaleExecutions } from "@intelligo-dev/executions";
|
|
48
|
+
import { pruneJobs } from "@intelligo-dev/jobs";
|
|
49
|
+
import { createLogger } from "@intelligo-dev/core/logger";
|
|
50
|
+
|
|
51
|
+
import { composeIntelligo, executions } from "@/lib/intelligo";
|
|
52
|
+
|
|
53
|
+
const log = createLogger("cron.maintenance");
|
|
54
|
+
|
|
55
|
+
export const maxDuration = 60;
|
|
56
|
+
|
|
57
|
+
/** Executions older than this and still non-terminal are reconciled. */
|
|
58
|
+
const STALE_AFTER_MS = 10 * 60_000;
|
|
59
|
+
/** Finished jobs older than this are pruned. */
|
|
60
|
+
const PRUNE_JOBS_AFTER_MS = 7 * 24 * 60 * 60_000;
|
|
61
|
+
|
|
62
|
+
/** Constant-time bearer comparison; length is compared too. */
|
|
63
|
+
function bearerMatches(header: string | null, secret: string): boolean {
|
|
64
|
+
if (!header?.startsWith("Bearer ")) return false;
|
|
65
|
+
const token = Buffer.from(header.slice("Bearer ".length));
|
|
66
|
+
const expected = Buffer.from(secret);
|
|
67
|
+
if (token.length !== expected.length) return false;
|
|
68
|
+
return timingSafeEqual(token, expected);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function GET(request: Request) {
|
|
72
|
+
const secret = process.env.CRON_SECRET;
|
|
73
|
+
if (!secret || secret.length < 32) {
|
|
74
|
+
log.error("CRON_SECRET missing or shorter than 32 chars — refusing");
|
|
75
|
+
return new Response("Forbidden", { status: 403 });
|
|
76
|
+
}
|
|
77
|
+
if (!bearerMatches(request.headers.get("authorization"), secret)) {
|
|
78
|
+
return new Response("Unauthorized", { status: 401 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
composeIntelligo();
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
const step = async <T>(name: string, run: () => Promise<T>): Promise<T | null> => {
|
|
84
|
+
try {
|
|
85
|
+
return await run();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
log.error(`${name} failed`, { error: message });
|
|
89
|
+
errors.push(`${name}: ${message}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const stale = await step("findStaleExecutions", () =>
|
|
95
|
+
findStaleExecutions(new Date(Date.now() - STALE_AFTER_MS))
|
|
96
|
+
);
|
|
97
|
+
const reconciled: Record<string, number> = {};
|
|
98
|
+
for (const row of stale ?? []) {
|
|
99
|
+
const result = await step(`reconcile ${row.id}`, () =>
|
|
100
|
+
executions.reconcile(row.id, { abandonRunningAfterMs: STALE_AFTER_MS })
|
|
101
|
+
);
|
|
102
|
+
if (result) reconciled[result.action] = (reconciled[result.action] ?? 0) + 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const summary = {
|
|
106
|
+
staleExecutions: stale?.length ?? 0,
|
|
107
|
+
reconciled,
|
|
108
|
+
expiredReservationsDeleted: await step("cleanupExpiredReservations", cleanupExpiredReservations),
|
|
109
|
+
rateLimitEntriesDeleted: await step("cleanupRateLimitEntries", cleanupRateLimitEntries),
|
|
110
|
+
trials: await step("processTrialExpirations", processTrialExpirations),
|
|
111
|
+
jobsPruned: await step("pruneJobs", () =>
|
|
112
|
+
pruneJobs(new Date(Date.now() - PRUNE_JOBS_AFTER_MS))
|
|
113
|
+
),
|
|
114
|
+
errors,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
log.info("Maintenance run", summary);
|
|
118
|
+
return Response.json(summary, { status: errors.length ? 207 : 200 });
|
|
119
|
+
}
|
package/templates/manifest.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"usage-page": {
|
|
3
|
-
"templateVersion": "1.0.
|
|
3
|
+
"templateVersion": "1.0.1",
|
|
4
4
|
"description": "A usage page listing executions and cost for the current workspace",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"template": "usage-page/usage-page.tsx.tpl",
|
|
8
|
-
"target": "app/usage/page.tsx"
|
|
8
|
+
"target": "app/[locale]/usage/page.tsx"
|
|
9
9
|
}
|
|
10
10
|
],
|
|
11
11
|
"deprecated": "Superseded by the `usage` registry item \u2014 install it with the shadcn CLI from the Intelligo registry instead; it ships a full page, components and thin actions as consumer-owned source."
|
|
12
12
|
},
|
|
13
13
|
"app-scaffold": {
|
|
14
|
-
"templateVersion": "1.
|
|
15
|
-
"description": "A new application: composition root, plans, an assistant route, config, next-intl i18n plumbing, and a registry-consuming shadcn
|
|
14
|
+
"templateVersion": "1.14.0",
|
|
15
|
+
"description": "A new application: composition root (run once per server process from instrumentation.ts), plans, an assistant route, a Stripe webhook endpoint, config, a drizzle config for the tables you own, next-intl i18n plumbing, and a registry-consuming shadcn base-nova setup with the intelligo design tokens",
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
|
18
18
|
"template": "app-scaffold/package.json.tpl",
|
|
@@ -34,10 +34,22 @@
|
|
|
34
34
|
"template": "app-scaffold/intelligo.ts.tpl",
|
|
35
35
|
"target": "lib/intelligo.ts"
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
"template": "app-scaffold/instrumentation.ts.tpl",
|
|
39
|
+
"target": "instrumentation.ts"
|
|
40
|
+
},
|
|
37
41
|
{
|
|
38
42
|
"template": "app-scaffold/plans.ts.tpl",
|
|
39
43
|
"target": "lib/plans.ts"
|
|
40
44
|
},
|
|
45
|
+
{
|
|
46
|
+
"template": "app-scaffold/workspace-bootstrap.ts.tpl",
|
|
47
|
+
"target": "lib/workspace-bootstrap.ts"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"template": "app-scaffold/auth-route.ts.tpl",
|
|
51
|
+
"target": "app/api/auth/[...all]/route.ts"
|
|
52
|
+
},
|
|
41
53
|
{
|
|
42
54
|
"template": "app-scaffold/assistant-route.ts.tpl",
|
|
43
55
|
"target": "app/api/assistant/route.ts"
|
|
@@ -58,10 +70,18 @@
|
|
|
58
70
|
"template": "app-scaffold/lib-utils.ts.tpl",
|
|
59
71
|
"target": "lib/utils.ts"
|
|
60
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"template": "app-scaffold/use-mobile.ts.tpl",
|
|
75
|
+
"target": "hooks/use-mobile.ts"
|
|
76
|
+
},
|
|
61
77
|
{
|
|
62
78
|
"template": "app-scaffold/layout.tsx.tpl",
|
|
63
79
|
"target": "app/[locale]/layout.tsx"
|
|
64
80
|
},
|
|
81
|
+
{
|
|
82
|
+
"template": "app-scaffold/page.tsx.tpl",
|
|
83
|
+
"target": "app/[locale]/page.tsx"
|
|
84
|
+
},
|
|
65
85
|
{
|
|
66
86
|
"template": "app-scaffold/theme-provider.tsx.tpl",
|
|
67
87
|
"target": "components/shell/theme-provider.tsx"
|
|
@@ -83,34 +103,72 @@
|
|
|
83
103
|
"target": "i18n/request.ts"
|
|
84
104
|
},
|
|
85
105
|
{
|
|
86
|
-
"template": "app-scaffold/
|
|
87
|
-
"target": "
|
|
106
|
+
"template": "app-scaffold/proxy.ts.tpl",
|
|
107
|
+
"target": "proxy.ts"
|
|
88
108
|
},
|
|
89
109
|
{
|
|
90
110
|
"template": "app-scaffold/messages-en-app.json.tpl",
|
|
91
111
|
"target": "messages/en/app.json"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"template": "app-scaffold/drizzle.config.ts.tpl",
|
|
115
|
+
"target": "drizzle.config.ts"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"template": "app-scaffold/db-schema.ts.tpl",
|
|
119
|
+
"target": "lib/db/schema.ts"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"template": "app-scaffold/drizzle-journal.json.tpl",
|
|
123
|
+
"target": "drizzle/meta/_journal.json"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"template": "app-scaffold/stripe-webhook-route.ts.tpl",
|
|
127
|
+
"target": "app/api/webhooks/stripe/route.ts"
|
|
92
128
|
}
|
|
93
129
|
]
|
|
94
130
|
},
|
|
95
|
-
"
|
|
96
|
-
"templateVersion": "1.
|
|
97
|
-
"description": "
|
|
131
|
+
"admin-page": {
|
|
132
|
+
"templateVersion": "1.1.0",
|
|
133
|
+
"description": "Mount the Intelligo operational console at /admin",
|
|
98
134
|
"files": [
|
|
99
135
|
{
|
|
100
|
-
"template": "
|
|
101
|
-
"target": "app/
|
|
136
|
+
"template": "admin-page/admin-page.tsx.tpl",
|
|
137
|
+
"target": "app/[locale]/admin/page.tsx"
|
|
102
138
|
}
|
|
103
|
-
]
|
|
104
|
-
"deprecated": "Superseded by the `pricing` and `billing-settings` registry items \u2014 install them with the shadcn CLI from the Intelligo registry instead."
|
|
139
|
+
]
|
|
105
140
|
},
|
|
106
|
-
"
|
|
141
|
+
"maintenance": {
|
|
142
|
+
"templateVersion": "1.1.0",
|
|
143
|
+
"description": "A CRON_SECRET-gated GET /api/cron/maintenance that reconciles stale executions, drops expired reservations and rate-limit buckets, expires trials and prunes old jobs \u2014 scheduled every five minutes, in vercel.json when the app has none",
|
|
144
|
+
"cron": {
|
|
145
|
+
"path": "/api/cron/maintenance",
|
|
146
|
+
"schedule": "0-59/5 * * * *"
|
|
147
|
+
},
|
|
148
|
+
"files": [
|
|
149
|
+
{
|
|
150
|
+
"template": "maintenance/maintenance-route.ts.tpl",
|
|
151
|
+
"target": "app/api/cron/maintenance/route.ts"
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
"vitest": {
|
|
107
156
|
"templateVersion": "1.0.0",
|
|
108
|
-
"description": "
|
|
157
|
+
"description": "A Vitest setup for the app's own tests: the `@` alias, a `server-only` stub, and the `@intelligo-dev/*` packages inlined so the stub reaches them",
|
|
109
158
|
"files": [
|
|
110
159
|
{
|
|
111
|
-
"template": "
|
|
112
|
-
"target": "
|
|
160
|
+
"template": "vitest/vitest.config.ts.tpl",
|
|
161
|
+
"target": "vitest.config.ts"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"template": "vitest/server-only.ts.tpl",
|
|
165
|
+
"target": "tests/stubs/server-only.ts"
|
|
113
166
|
}
|
|
167
|
+
],
|
|
168
|
+
"nextSteps": [
|
|
169
|
+
"Install the runner: pnpm add -D vitest",
|
|
170
|
+
"Add a script to package.json: \"test\": \"vitest run\"",
|
|
171
|
+
"Write tests beside the code as *.test.ts; mock @ai-sdk/* providers so CI makes no model calls"
|
|
114
172
|
]
|
|
115
173
|
}
|
|
116
174
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-agent-activity",
|
|
4
|
+
"title": "AI Agent Activity",
|
|
5
|
+
"description": "One adaptive stream of what the agent is doing: reasoning text, plan steps, web searches with results, tool calls and structured traces, gliding while it works and folding into a summary when done.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion",
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/ai-shimmer-text"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "base/ui/ai-agent-activity/ai-agent-activity.tsx",
|
|
17
|
+
"content": "\"use client\";\n\n/*\n * One adaptive stream of what the agent is doing: reasoning text, plan\n * steps, web searches with their results, tool calls and structured\n * traces. While the run is working the newest rows glide up under a fade;\n * once complete the stream folds into a one-line summary that opens on\n * demand.\n */\n\nimport * as React from \"react\";\nimport {\n CheckIcon,\n ChevronDownIcon,\n CircleAlertIcon,\n CircleIcon,\n FileTextIcon,\n Globe2Icon,\n ImageIcon,\n MessageSquareIcon,\n PencilLineIcon,\n SearchIcon,\n SparklesIcon,\n SquareTerminalIcon,\n WrenchIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n EASE_OUT,\n Disclosure,\n SPRING_LAYOUT,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport { ShimmerText } from \"@/components/ui/ai-shimmer-text\";\nimport { cn } from \"@/lib/utils\";\n\n/* ----------------------------------------------------------------------------\n * Items\n * ------------------------------------------------------------------------- */\n\nexport type AgentActivityStatus = \"working\" | \"complete\";\nexport type AgentStepStatus = \"pending\" | \"active\" | \"complete\";\n\nexport interface AgentActivityStep {\n id: string;\n type: \"step\";\n label: React.ReactNode;\n status?: AgentStepStatus;\n meta?: React.ReactNode;\n}\n\nexport interface AgentActivityText {\n id: string;\n type: \"text\";\n content: React.ReactNode;\n}\n\nexport interface AgentSearchResult {\n id: string;\n title: React.ReactNode;\n domain?: React.ReactNode;\n url?: string;\n icon?: React.ReactNode;\n}\n\n/** A row's own state inside the stream; rows are complete by default. */\nexport type AgentRowStatus = \"running\" | \"complete\" | \"error\";\n\nexport interface AgentActivitySearch {\n id: string;\n type: \"search\";\n query: React.ReactNode;\n results?: AgentSearchResult[];\n moreCount?: number;\n status?: AgentRowStatus;\n /** Shown under the row when the reader opens it — the raw call, say. */\n details?: React.ReactNode;\n}\n\nexport interface AgentActivityTool {\n id: string;\n type: \"tool\";\n action: \"read\" | \"edit\" | \"run\" | (string & {});\n target?: React.ReactNode;\n additions?: number;\n deletions?: number;\n status?: AgentRowStatus;\n /** Shown under the row when the reader opens it — the raw call, say. */\n details?: React.ReactNode;\n}\n\nexport type AgentTraceKind =\n | \"thinking\"\n | \"message\"\n | \"write\"\n | \"run\"\n | \"read\"\n | (string & {});\n\nexport interface AgentActivityTrace {\n id: string;\n type: \"trace\";\n kind: AgentTraceKind;\n label: React.ReactNode;\n detail?: React.ReactNode;\n icon?: React.ReactNode;\n}\n\nexport type AgentActivityItem =\n | AgentActivityStep\n | AgentActivityText\n | AgentActivitySearch\n | AgentActivityTool\n | AgentActivityTrace;\n\nexport type AgentActivityContentType = AgentActivityItem[\"type\"] | \"mixed\";\n\n/* ----------------------------------------------------------------------------\n * Labels — every visible string, with an English default.\n * ------------------------------------------------------------------------- */\n\nexport interface AgentActivitySummaryLabels {\n /** Step- or text-only runs: `duration` is already formatted. */\n thought: (duration: string) => React.ReactNode;\n searched: string;\n tools: (count: number) => string;\n trace: (toolCalls: number, messages: number) => string;\n steps: (count: number) => string;\n}\n\nconst DEFAULT_ACTIVE_LABELS: Record<AgentActivityContentType, string> = {\n step: \"Thinking…\",\n text: \"Thinking…\",\n search: \"Searching the web…\",\n tool: \"Running tools…\",\n trace: \"Working through the run…\",\n mixed: \"Working through it…\",\n};\n\nconst DEFAULT_SUMMARY_LABELS: AgentActivitySummaryLabels = {\n thought: (duration) => (\n <>\n Thought for <span className=\"tabular-nums\">{duration}</span>\n </>\n ),\n searched: \"Searched the web\",\n tools: (count) => `Ran ${count} ${count === 1 ? \"tool\" : \"tools\"}`,\n trace: (toolCalls, messages) =>\n `${toolCalls} ${toolCalls === 1 ? \"tool call\" : \"tool calls\"}, ${messages} ${messages === 1 ? \"message\" : \"messages\"}`,\n steps: (count) => `Completed ${count} ${count === 1 ? \"step\" : \"steps\"}`,\n};\n\nfunction defaultFormatDuration(duration: number) {\n const seconds = Math.max(0, Math.round(duration));\n if (seconds < 60) return `${seconds}s`;\n\n const minutes = Math.floor(seconds / 60);\n const remainder = seconds % 60;\n return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;\n}\n\nfunction defaultMoreLabel(count: number) {\n return `+${count} more`;\n}\n\nfunction defaultActionLabel(action: string) {\n return action.charAt(0).toUpperCase() + action.slice(1);\n}\n\n/* ----------------------------------------------------------------------------\n * Rows\n * ------------------------------------------------------------------------- */\n\nfunction StepRow({ item }: { item: AgentActivityStep }) {\n const reduce = useReducedMotion() ?? false;\n const state = item.status ?? \"complete\";\n\n return (\n <div\n data-slot=\"agent-activity-step\"\n data-status={state}\n className=\"flex min-h-7 items-start gap-2.5 rounded-md px-1.5 py-1\"\n >\n <span\n aria-hidden=\"true\"\n className=\"mt-0.5 grid size-4 shrink-0 place-items-center text-muted-foreground/70\"\n >\n {state === \"complete\" ? (\n <CheckIcon className=\"size-4\" strokeWidth={1.8} />\n ) : state === \"active\" ? (\n <span className=\"relative grid size-3 place-items-center\">\n <motion.span\n className=\"absolute inset-0 rounded-full bg-foreground/10\"\n animate={\n reduce ? { opacity: 0.6 } : { opacity: [0.35, 0.8, 0.35] }\n }\n transition={\n reduce\n ? { duration: 0 }\n : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }\n }\n />\n <span className=\"size-1.5 rounded-full bg-foreground/60\" />\n </span>\n ) : (\n <CircleIcon className=\"size-3\" strokeWidth={1.5} />\n )}\n </span>\n <span\n className={cn(\n \"min-w-0 flex-1 leading-5\",\n state === \"pending\"\n ? \"text-muted-foreground/55\"\n : \"text-foreground/90\"\n )}\n >\n {item.label}\n </span>\n {item.meta ? (\n <span className=\"shrink-0 leading-5 text-muted-foreground/55\">\n {item.meta}\n </span>\n ) : null}\n </div>\n );\n}\n\nfunction TextRow({ item }: { item: AgentActivityText }) {\n return (\n <div\n data-slot=\"agent-activity-text\"\n className=\"rounded-md px-1.5 py-1 leading-5 text-muted-foreground\"\n >\n {item.content}\n </div>\n );\n}\n\nfunction SearchResultRow({ result }: { result: AgentSearchResult }) {\n const content = (\n <>\n <span\n aria-hidden=\"true\"\n className=\"grid size-5 shrink-0 place-items-center text-muted-foreground\"\n >\n {result.icon ?? <Globe2Icon className=\"size-3\" strokeWidth={2} />}\n </span>\n <span className=\"min-w-0 truncate font-medium text-foreground/90\">\n {result.title}\n </span>\n {result.domain ? (\n <span className=\"min-w-0 truncate text-muted-foreground/55\">\n {result.domain}\n </span>\n ) : null}\n </>\n );\n const className = cn(\n \"flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none transition-colors\",\n result.url && \"hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring\"\n );\n\n return result.url ? (\n <a\n data-slot=\"agent-activity-search-result\"\n href={result.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={className}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"agent-activity-search-result\" className={className}>\n {content}\n </div>\n );\n}\n\n/** A row's glyph, pulsing while its call runs; an alert once it failed. */\nfunction RowStatusIcon({\n status,\n children,\n}: {\n status: AgentRowStatus;\n children: React.ReactNode;\n}) {\n const reduce = useReducedMotion() ?? false;\n if (status === \"error\") {\n return <CircleAlertIcon className=\"size-4 text-destructive\" strokeWidth={1.8} />;\n }\n if (status === \"running\") {\n return (\n <motion.span\n className=\"grid size-4 place-items-center\"\n animate={reduce ? { opacity: 0.7 } : { opacity: [0.4, 1, 0.4] }}\n transition={\n reduce\n ? { duration: 0 }\n : { duration: 1.4, repeat: Number.POSITIVE_INFINITY }\n }\n >\n {children}\n </motion.span>\n );\n }\n return <>{children}</>;\n}\n\n/** A row that opens onto its details, when it has any. */\nfunction RowWithDetails({\n details,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\" | \"className\"> & {\n details?: React.ReactNode;\n className: string;\n children: React.ReactNode;\n}) {\n const reduce = useReducedMotion() ?? false;\n const [open, setOpen] = React.useState(false);\n const detailsId = React.useId();\n\n if (!details) {\n return (\n <div className={className} {...props}>\n {children}\n </div>\n );\n }\n\n return (\n <div {...props}>\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={detailsId}\n onClick={() => setOpen(!open)}\n className={cn(\n className,\n \"group/row w-full text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring\"\n )}\n >\n {children}\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: open ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className={cn(\n \"inline-flex shrink-0 text-muted-foreground/60 transition-opacity group-hover/row:opacity-100 group-focus-visible/row:opacity-100\",\n open ? \"opacity-100\" : \"opacity-0\"\n )}\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n <Disclosure id={detailsId} open={open}>\n <div data-slot=\"agent-activity-details\" className=\"pt-1 pr-1.5 pb-2 pl-8\">\n {details}\n </div>\n </Disclosure>\n </div>\n );\n}\n\nfunction SearchRow({\n item,\n moreLabel,\n}: {\n item: AgentActivitySearch;\n moreLabel: (count: number) => string;\n}) {\n const reduce = useReducedMotion() ?? false;\n const enter = reduce ? { opacity: 1 } : { opacity: 0, y: 6 };\n const visible = { opacity: 1, y: 0 };\n const exit = reduce ? { opacity: 0 } : { opacity: 0, y: -3 };\n const transition = reduce\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n };\n\n return (\n <div\n data-slot=\"agent-activity-search\"\n data-status={item.status ?? \"complete\"}\n className=\"space-y-0.5\"\n >\n <RowWithDetails\n details={item.details}\n className=\"flex min-h-7 items-center gap-2.5 rounded-md px-1.5 py-1 text-muted-foreground\"\n >\n <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center\">\n <RowStatusIcon status={item.status ?? \"complete\"}>\n <SearchIcon className=\"size-4\" strokeWidth={1.7} />\n </RowStatusIcon>\n </span>\n <span className=\"min-w-0 flex-1 truncate\">{item.query}</span>\n </RowWithDetails>\n {item.results?.length ? (\n <div className=\"space-y-0.5 pl-4\">\n <AnimatePresence initial mode=\"popLayout\">\n {item.results.map((result) => (\n <motion.div\n layout=\"position\"\n key={result.id}\n initial={enter}\n animate={visible}\n exit={exit}\n transition={transition}\n >\n <SearchResultRow result={result} />\n </motion.div>\n ))}\n </AnimatePresence>\n </div>\n ) : null}\n <AnimatePresence initial>\n {item.moreCount ? (\n <motion.div\n key=\"more-results\"\n initial={enter}\n animate={visible}\n exit={exit}\n transition={transition}\n className=\"px-1.5 py-1 pl-8 text-muted-foreground/55\"\n >\n {moreLabel(item.moreCount)}\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n );\n}\n\nfunction ActionIcon({ action }: { action: string }) {\n if (action === \"read\") return <FileTextIcon className=\"size-4\" />;\n if (action === \"edit\" || action === \"write\") {\n return <PencilLineIcon className=\"size-4\" />;\n }\n if (action === \"run\") return <SquareTerminalIcon className=\"size-4\" />;\n return <WrenchIcon className=\"size-4\" />;\n}\n\nfunction ToolRow({\n item,\n actionLabels,\n}: {\n item: AgentActivityTool;\n actionLabels?: Record<string, string>;\n}) {\n const action = actionLabels?.[item.action] ?? defaultActionLabel(item.action);\n const status = item.status ?? \"complete\";\n\n return (\n <RowWithDetails\n data-slot=\"agent-activity-tool\"\n data-action={item.action}\n data-status={status}\n details={item.details}\n className=\"flex min-h-8 min-w-0 items-center gap-2.5 rounded-md px-1.5 py-0.5 leading-5\"\n >\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 shrink-0 place-items-center text-muted-foreground/70\"\n >\n <RowStatusIcon status={status}>\n <ActionIcon action={item.action} />\n </RowStatusIcon>\n </span>\n <span className=\"shrink-0 font-medium text-foreground/90\">{action}</span>\n {item.target ? (\n <span\n className={cn(\n \"min-w-0 flex-1 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground\",\n status === \"error\" && \"bg-transparent px-0 font-sans text-destructive\"\n )}\n >\n {item.target}\n </span>\n ) : (\n <span className=\"flex-1\" />\n )}\n {typeof item.additions === \"number\" ||\n typeof item.deletions === \"number\" ? (\n <span className=\"flex shrink-0 items-center gap-2 font-mono tabular-nums\">\n {typeof item.additions === \"number\" ? (\n <span className=\"text-success\">+{item.additions}</span>\n ) : null}\n {typeof item.deletions === \"number\" ? (\n <span className=\"text-destructive\">−{item.deletions}</span>\n ) : null}\n </span>\n ) : null}\n </RowWithDetails>\n );\n}\n\nfunction TraceIcon({ kind }: { kind: AgentTraceKind }) {\n if (kind === \"thinking\") return <SparklesIcon className=\"size-4\" />;\n if (kind === \"message\") return <MessageSquareIcon className=\"size-4\" />;\n if (kind === \"write\") return <PencilLineIcon className=\"size-4\" />;\n if (kind === \"run\") return <SquareTerminalIcon className=\"size-4\" />;\n if (kind === \"read\") return <ImageIcon className=\"size-4\" />;\n return <WrenchIcon className=\"size-4\" />;\n}\n\nfunction TraceRow({ item }: { item: AgentActivityTrace }) {\n return (\n <div\n data-slot=\"agent-activity-trace\"\n data-kind={item.kind}\n className=\"grid min-h-8 items-center gap-2.5 rounded-md px-1.5 py-0.5\"\n style={{ gridTemplateColumns: \"1rem auto minmax(0, 1fr)\" }}\n >\n <span\n aria-hidden=\"true\"\n className=\"grid size-4 place-items-center text-muted-foreground/70\"\n >\n {item.icon ?? <TraceIcon kind={item.kind} />}\n </span>\n <span className=\"font-medium text-foreground/90\">{item.label}</span>\n {item.detail ? (\n <span className=\"min-w-0 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground/70\">\n {item.detail}\n </span>\n ) : (\n <span />\n )}\n </div>\n );\n}\n\nexport interface AgentActivityRowProps {\n item: AgentActivityItem;\n /** \"+3 more\" under a search's results. */\n moreLabel?: (count: number) => string;\n /** The word shown for a tool action (`read` → \"Read\"); capitalised by default. */\n actionLabels?: Record<string, string>;\n}\n\nfunction AgentActivityRow({\n item,\n moreLabel = defaultMoreLabel,\n actionLabels,\n}: AgentActivityRowProps) {\n if (item.type === \"text\") return <TextRow item={item} />;\n if (item.type === \"search\")\n return <SearchRow item={item} moreLabel={moreLabel} />;\n if (item.type === \"tool\")\n return <ToolRow item={item} actionLabels={actionLabels} />;\n if (item.type === \"trace\") return <TraceRow item={item} />;\n return <StepRow item={item} />;\n}\n\n/* ----------------------------------------------------------------------------\n * The stream\n * ------------------------------------------------------------------------- */\n\nexport interface AgentActivityProps extends Omit<\n React.ComponentProps<\"div\">,\n \"children\"\n> {\n /** Chronological activity entries. Append or update items as events stream. */\n items: AgentActivityItem[];\n /** Expected activity kind before the first streamed item arrives. */\n contentType?: AgentActivityContentType;\n /** Current run phase. Active runs always stay expanded. */\n status?: AgentActivityStatus;\n /** Elapsed run time, in seconds. Used by the step-only summary. */\n duration?: number;\n /** Controlled expanded state used after the run completes. */\n open?: boolean;\n /** Initial expanded state used after the run completes. */\n defaultOpen?: boolean;\n /** Called when the completed activity disclosure changes state. */\n onOpenChange?: (open: boolean) => void;\n /** Collapse the disclosure when status changes from working to complete. */\n collapseOnComplete?: boolean;\n /** The status line while the run is active; overrides `activeLabels`. */\n activeLabel?: string;\n /** The status line per content kind while the run is active. */\n activeLabels?: Partial<Record<AgentActivityContentType, string>>;\n /** The completed summary; overrides `summaryLabels`. */\n summary?: React.ReactNode;\n /** The completed summary per content kind. */\n summaryLabels?: Partial<AgentActivitySummaryLabels>;\n /** \"45s\", \"2m 5s\" — for the step-only summary. */\n formatDuration?: (seconds: number) => string;\n /** \"+3 more\" under a search's results. */\n moreLabel?: (count: number) => string;\n /** The word shown for a tool action (`read` → \"Read\"); capitalised by default. */\n actionLabels?: Record<string, string>;\n /** Optional renderer for the contents of the active status row. */\n renderWorkingStatus?: (context: {\n label: string;\n duration: number;\n }) => React.ReactNode;\n /** Optional renderer for the contents before the built-in disclosure chevron. */\n renderCompletedStatus?: (context: {\n summary: React.ReactNode;\n duration: number;\n }) => React.ReactNode;\n /** Maximum visible activity height before the stream begins gliding. */\n maxHeight?: number;\n contentClassName?: string;\n}\n\nfunction useControllableOpen({\n open,\n defaultOpen,\n onOpenChange,\n}: {\n open?: boolean;\n defaultOpen: boolean;\n onOpenChange?: (open: boolean) => void;\n}) {\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const controlled = open !== undefined;\n const currentOpen = open ?? internalOpen;\n\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (!controlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [controlled, onOpenChange]\n );\n\n return [currentOpen, setOpen] as const;\n}\n\nfunction getContentType(items: AgentActivityItem[]): AgentActivityContentType {\n const first = items[0]?.type;\n return first && items.every((item) => item.type === first) ? first : \"mixed\";\n}\n\nfunction getSummary(\n type: AgentActivityContentType,\n items: AgentActivityItem[],\n duration: string,\n labels: AgentActivitySummaryLabels\n): React.ReactNode {\n if (type === \"step\" || type === \"text\") return labels.thought(duration);\n if (type === \"search\") return labels.searched;\n if (type === \"tool\") return labels.tools(items.length);\n if (type === \"trace\") {\n const messages = items.filter(\n (item) =>\n item.type === \"trace\" &&\n (item.kind === \"thinking\" || item.kind === \"message\")\n ).length;\n return labels.trace(items.length - messages, messages);\n }\n return labels.steps(items.length);\n}\n\nfunction AgentActivity({\n items,\n contentType: initialContentType,\n status = \"working\",\n duration = 0,\n open,\n defaultOpen = false,\n onOpenChange,\n collapseOnComplete = true,\n activeLabel,\n activeLabels,\n summary,\n summaryLabels,\n formatDuration = defaultFormatDuration,\n moreLabel = defaultMoreLabel,\n actionLabels,\n renderWorkingStatus,\n renderCompletedStatus,\n maxHeight = 208,\n className,\n contentClassName,\n ...props\n}: AgentActivityProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = React.useId();\n const triggerId = `${baseId}-trigger`;\n const contentId = `${baseId}-content`;\n const contentRef = React.useRef<HTMLDivElement>(null);\n const viewportRef = React.useRef<HTMLDivElement>(null);\n const previousStatus = React.useRef(status);\n const [contentHeight, setContentHeight] = React.useState(0);\n const [currentOpen, setOpen] = useControllableOpen({\n open,\n defaultOpen,\n onOpenChange,\n });\n const working = status === \"working\";\n const expanded = working || currentOpen;\n const contentType = items.length\n ? getContentType(items)\n : (initialContentType ?? \"mixed\");\n // While working the viewport is a fixed window the content glides up\n // through; once complete it shrinks to fit (capped) and scrolls.\n const cappedHeight = Math.min(contentHeight, Math.max(0, maxHeight));\n const viewportHeight = working ? Math.max(0, maxHeight) : cappedHeight;\n const capped = contentHeight > maxHeight;\n const streamOffset = working\n ? Math.min(0, viewportHeight - contentHeight)\n : 0;\n\n React.useLayoutEffect(() => {\n const node = contentRef.current;\n if (!node) return;\n\n const measure = () => setContentHeight(node.offsetHeight);\n measure();\n\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n React.useEffect(() => {\n if (previousStatus.current === \"working\" && status === \"complete\") {\n setOpen(!collapseOnComplete);\n }\n previousStatus.current = status;\n }, [collapseOnComplete, setOpen, status]);\n\n const toggle = () => {\n const next = !currentOpen;\n setOpen(next);\n if (next) {\n requestAnimationFrame(() => viewportRef.current?.scrollTo({ top: 0 }));\n }\n };\n\n const liveLabel =\n activeLabel ??\n activeLabels?.[contentType] ??\n DEFAULT_ACTIVE_LABELS[contentType];\n const completedSummary =\n summary ??\n getSummary(contentType, items, formatDuration(duration), {\n ...DEFAULT_SUMMARY_LABELS,\n ...summaryLabels,\n });\n // Alpha-only masks: the colour words never paint.\n const maskImage = capped\n ? working\n ? \"linear-gradient(to bottom, transparent, black 12px)\"\n : \"linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent)\"\n : undefined;\n\n return (\n <div\n data-slot=\"agent-activity\"\n data-state={working ? \"working\" : expanded ? \"open\" : \"closed\"}\n data-content={contentType}\n aria-busy={working}\n className={cn(\"w-full text-sm\", className)}\n {...props}\n >\n {working ? (\n <div\n data-slot=\"agent-activity-status\"\n id={triggerId}\n role=\"status\"\n className=\"flex h-7 min-w-0 items-center text-muted-foreground\"\n >\n {renderWorkingStatus ? (\n renderWorkingStatus({ label: liveLabel, duration })\n ) : (\n // This row is the live region; the shimmer must not nest a second one.\n <ShimmerText role={undefined} className=\"font-medium\">\n {liveLabel}\n </ShimmerText>\n )}\n </div>\n ) : (\n <button\n data-slot=\"agent-activity-trigger\"\n id={triggerId}\n type=\"button\"\n aria-expanded={expanded}\n aria-controls={contentId}\n onClick={toggle}\n className=\"group flex h-7 min-w-0 items-center gap-1.5 rounded-md text-left font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n >\n <span className=\"truncate\">\n {renderCompletedStatus\n ? renderCompletedStatus({ summary: completedSummary, duration })\n : completedSummary}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: expanded ? 180 : 0 }}\n transition={reduce ? { duration: 0 } : SPRING_SWAP}\n className=\"inline-flex shrink-0 text-muted-foreground/70 group-hover:text-foreground\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n )}\n\n <Disclosure\n id={contentId}\n role=\"region\"\n aria-labelledby={triggerId}\n open={expanded}\n openHeight={viewportHeight}\n >\n <div\n data-slot=\"agent-activity-viewport\"\n ref={viewportRef}\n className={cn(\n \"pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n capped && expanded && !working\n ? \"overflow-y-auto\"\n : \"overflow-y-hidden\"\n )}\n style={{\n height: viewportHeight,\n maskImage,\n WebkitMaskImage: maskImage,\n }}\n >\n <motion.div\n data-slot=\"agent-activity-list\"\n ref={contentRef}\n role=\"list\"\n initial={false}\n animate={{ y: streamOffset }}\n transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n className={cn(\"space-y-0.5 py-2\", contentClassName)}\n >\n <AnimatePresence mode=\"popLayout\">\n {items.map((item) => (\n <motion.div\n data-slot=\"agent-activity-item\"\n data-type={item.type}\n layout=\"position\"\n key={item.id}\n role=\"listitem\"\n initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={\n reduce\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n }\n }\n >\n <AgentActivityRow\n item={item}\n moreLabel={moreLabel}\n actionLabels={actionLabels}\n />\n </motion.div>\n ))}\n </AnimatePresence>\n </motion.div>\n </div>\n </Disclosure>\n </div>\n );\n}\n\nexport { AgentActivity, AgentActivityRow };\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": "components/ui/ai-agent-activity.tsx"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"type": "registry:ui"
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-agent-progress",
|
|
4
|
+
"title": "AI Agent Progress",
|
|
5
|
+
"description": "A long-running step: a pulsing grid beside the verb and an elapsed-time counter.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"motion"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/ai-motion"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "base/ui/ai-agent-progress/ai-agent-progress.tsx",
|
|
15
|
+
"content": "\"use client\";\n\n/*\n * A long-running step: a 3×3 grid pulses in sequence beside the verb and\n * an elapsed-time counter.\n */\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { EASE_IN_OUT } from \"@/components/ui/ai-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst GRID_CELLS = [\n { id: \"top-left\", delay: 0 },\n { id: \"top-center\", delay: 0.14 },\n { id: \"top-right\", delay: 0.28 },\n { id: \"middle-left\", delay: 0.42 },\n { id: \"middle-center\", delay: 0.56 },\n { id: \"middle-right\", delay: 0.7 },\n { id: \"bottom-left\", delay: 0.84 },\n { id: \"bottom-center\", delay: 0.98 },\n { id: \"bottom-right\", delay: 1.12 },\n];\n\nexport interface AgentProgressProps\n extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n /** Verb describing the agent's current activity. */\n label?: string;\n /** Assistive-tech name; defaults to \"{label}, in progress\". */\n statusLabel?: string;\n /** Controlled elapsed time in seconds. */\n elapsedSeconds?: number;\n /** Starting time for the internal timer, in seconds. */\n initialSeconds?: number;\n /** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */\n running?: boolean;\n /** Renders the counter; \"1m 4.2s\" by default. */\n formatElapsed?: (totalSeconds: number) => string;\n}\n\nfunction defaultFormatElapsed(totalSeconds: number) {\n const safeSeconds = Math.max(0, totalSeconds);\n const minutes = Math.floor(safeSeconds / 60);\n const seconds = (safeSeconds % 60).toFixed(1);\n return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;\n}\n\nfunction AgentProgress({\n label = \"Churning\",\n statusLabel,\n elapsedSeconds,\n initialSeconds = 0,\n running = true,\n formatElapsed = defaultFormatElapsed,\n className,\n ...props\n}: AgentProgressProps) {\n const reduced = useReducedMotion() ?? false;\n const [internalSeconds, setInternalSeconds] = React.useState(initialSeconds);\n\n React.useEffect(() => {\n if (elapsedSeconds !== undefined || !running) return;\n\n const startedAt = performance.now() - initialSeconds * 1000;\n const timer = window.setInterval(() => {\n setInternalSeconds((performance.now() - startedAt) / 1000);\n }, 100);\n\n return () => window.clearInterval(timer);\n }, [elapsedSeconds, initialSeconds, running]);\n\n const elapsed = elapsedSeconds ?? internalSeconds;\n\n return (\n <span\n data-slot=\"agent-progress\"\n role=\"status\"\n aria-label={statusLabel ?? `${label}, in progress`}\n className={cn(\n \"inline-flex items-center gap-3 font-mono text-sm text-muted-foreground\",\n className\n )}\n {...props}\n >\n <span\n data-slot=\"agent-progress-grid\"\n aria-hidden=\"true\"\n className=\"grid size-5 shrink-0 grid-cols-3 gap-0.5\"\n >\n {GRID_CELLS.map(({ id, delay }) => (\n <motion.span\n key={id}\n className=\"rounded-xs bg-current\"\n animate={\n reduced\n ? { opacity: [0.35, 0.8, 0.35] }\n : {\n opacity: [0.28, 1, 0.28],\n scale: [0.72, 1, 0.72],\n }\n }\n transition={{\n duration: 1.55,\n ease: EASE_IN_OUT,\n repeat: Infinity,\n delay,\n }}\n />\n ))}\n </span>\n <span data-slot=\"agent-progress-label\" className=\"font-sans font-medium\">\n {label}\n </span>\n <span\n data-slot=\"agent-progress-elapsed\"\n aria-hidden=\"true\"\n className=\"tabular-nums text-muted-foreground/70\"\n >\n {formatElapsed(elapsed)}\n </span>\n </span>\n );\n}\n\nexport { AgentProgress };\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/ai-agent-progress.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-approval-card",
|
|
4
|
+
"title": "AI Approval Card",
|
|
5
|
+
"description": "A decision surface: approve, reject or request changes, or step through single-choice, multiple-choice and freeform questions and submit the answers.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"@intelligo/button",
|
|
13
|
+
"@intelligo/checkbox",
|
|
14
|
+
"@intelligo/input",
|
|
15
|
+
"@intelligo/radio-group",
|
|
16
|
+
"@intelligo/spinner"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "base/ui/ai-approval-card/ai-approval-card.tsx",
|
|
21
|
+
"content": "\"use client\";\n\n/*\n * A decision surface the agent hands to the user: approve / reject /\n * request changes, or a short run of questions (single choice, multiple\n * choice, freeform) answered one step at a time and submitted together.\n */\n\nimport * as React from \"react\";\nimport {\n ArrowLeftIcon,\n ArrowRightIcon,\n CheckIcon,\n CircleHelpIcon,\n MessageSquareTextIcon,\n XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_SWAP,\n SwapText,\n} from \"@/components/ui/ai-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ApprovalCardStatus =\n | \"pending\"\n | \"submitting\"\n | \"approved\"\n | \"rejected\"\n | \"changes-requested\"\n | \"answered\";\n\nexport interface ApprovalCardOption {\n value: string;\n label: string;\n disabled?: boolean;\n}\n\nexport interface ApprovalCardQuestion {\n id: string;\n title: React.ReactNode;\n description?: React.ReactNode;\n options?: ApprovalCardOption[];\n /** Several options may be chosen; otherwise a single choice. */\n multiple?: boolean;\n /** Move on shortly after a single choice (default true). */\n autoAdvance?: boolean;\n /** Offer a freeform answer beside (or instead of) the options. */\n allowCustom?: boolean;\n customPlaceholder?: string;\n}\n\nexport interface ApprovalCardAnswer {\n selected: string[];\n custom?: string;\n}\n\nexport type ApprovalCardAnswers = Record<string, ApprovalCardAnswer>;\n\nexport interface ApprovalCardProps\n extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onSubmit\"> {\n title?: React.ReactNode;\n description?: React.ReactNode;\n questions?: ApprovalCardQuestion[];\n status?: ApprovalCardStatus;\n answers?: ApprovalCardAnswers;\n defaultAnswers?: ApprovalCardAnswers;\n onAnswersChange?: (answers: ApprovalCardAnswers) => void;\n step?: number;\n defaultStep?: number;\n onStepChange?: (step: number) => void;\n onSubmit?: (answers: ApprovalCardAnswers) => void;\n onApprove?: () => void;\n onReject?: () => void;\n onRequestChanges?: () => void;\n onDismiss?: () => void;\n /** What to show once the decision has landed; defaults to the status label. */\n result?: React.ReactNode;\n approveLabel?: React.ReactNode;\n rejectLabel?: React.ReactNode;\n requestChangesLabel?: React.ReactNode;\n submitLabel?: React.ReactNode;\n dismissLabel?: string;\n previousLabel?: string;\n nextLabel?: string;\n customPlaceholder?: string;\n /** Screen-reader progress, e.g. \"Question 2 of 5\". */\n progressLabel?: (current: number, total: number) => string;\n statusLabels?: Partial<Record<ApprovalCardStatus, string>>;\n}\n\nconst EMPTY_ANSWER: ApprovalCardAnswer = { selected: [], custom: \"\" };\n\nconst DEFAULT_STATUS_LABELS: Record<ApprovalCardStatus, string> = {\n pending: \"Input required\",\n submitting: \"Submitting\",\n approved: \"Approved\",\n rejected: \"Rejected\",\n \"changes-requested\": \"Changes requested\",\n answered: \"Response submitted\",\n};\n\nconst ICON_CLASS: Partial<Record<ApprovalCardStatus, string>> = {\n approved: \"text-success\",\n answered: \"text-success\",\n rejected: \"text-destructive\",\n \"changes-requested\": \"text-warning\",\n};\n\nconst BADGE_CLASS: Record<ApprovalCardStatus, string> = {\n pending: \"border-warning/30 bg-warning/10 text-warning\",\n \"changes-requested\": \"border-warning/30 bg-warning/10 text-warning\",\n submitting: \"border-info/30 bg-info/10 text-info\",\n approved: \"border-success/30 bg-success/10 text-success\",\n answered: \"border-success/30 bg-success/10 text-success\",\n rejected: \"border-destructive/30 bg-destructive/10 text-destructive\",\n};\n\nfunction isAnswered(answer: ApprovalCardAnswer) {\n return answer.selected.length > 0 || Boolean(answer.custom?.trim());\n}\n\n/* ----------------------------------------------------------------------------\n * One question's controls: checkboxes, radios and/or a freeform input.\n * ------------------------------------------------------------------------- */\n\nconst OPTION_ROW =\n \"flex min-h-9 items-center gap-3 rounded-lg px-1.5 py-1 text-sm text-foreground select-none\";\n\nfunction OptionRow({\n disabled,\n children,\n}: {\n disabled: boolean;\n children: React.ReactNode;\n}) {\n return (\n <label\n data-slot=\"approval-card-option\"\n className={cn(\n OPTION_ROW,\n disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-pointer\"\n )}\n >\n {children}\n </label>\n );\n}\n\nfunction QuestionOptions({\n question,\n answer,\n disabled,\n customPlaceholder,\n onChange,\n onSingleSelect,\n}: {\n question: ApprovalCardQuestion;\n answer: ApprovalCardAnswer;\n disabled: boolean;\n customPlaceholder: string;\n onChange: (answer: ApprovalCardAnswer) => void;\n onSingleSelect?: () => void;\n}) {\n const custom = answer.custom ?? \"\";\n\n return (\n <div data-slot=\"approval-card-question\" className=\"mt-3\">\n {question.options?.length ? (\n question.multiple ? (\n <div className=\"grid gap-0.5\">\n {question.options.map((option) => {\n const rowDisabled = disabled || Boolean(option.disabled);\n return (\n <OptionRow key={option.value} disabled={rowDisabled}>\n <Checkbox\n checked={answer.selected.includes(option.value)}\n disabled={rowDisabled}\n onCheckedChange={(checked) =>\n onChange({\n ...answer,\n selected: checked\n ? [...answer.selected, option.value]\n : answer.selected.filter(\n (value) => value !== option.value\n ),\n })\n }\n />\n <span>{option.label}</span>\n </OptionRow>\n );\n })}\n </div>\n ) : (\n <RadioGroup\n value={answer.selected[0] ?? \"\"}\n onValueChange={(value) => {\n onChange({ selected: [String(value)], custom: \"\" });\n onSingleSelect?.();\n }}\n className=\"gap-0.5\"\n >\n {question.options.map((option) => {\n const rowDisabled = disabled || Boolean(option.disabled);\n return (\n <OptionRow key={option.value} disabled={rowDisabled}>\n <RadioGroupItem value={option.value} disabled={rowDisabled} />\n <span>{option.label}</span>\n </OptionRow>\n );\n })}\n </RadioGroup>\n )\n ) : null}\n\n {question.allowCustom ? (\n <Input\n data-slot=\"approval-card-custom\"\n value={custom}\n disabled={disabled}\n placeholder={question.customPlaceholder ?? customPlaceholder}\n aria-label={question.customPlaceholder ?? customPlaceholder}\n onChange={(event) =>\n onChange({\n selected: question.multiple ? answer.selected : [],\n custom: event.target.value,\n })\n }\n className={cn(\n \"h-10 rounded-lg border-0 bg-background/70 px-3 text-sm focus-visible:bg-background\",\n question.options?.length && \"mt-1.5\"\n )}\n />\n ) : null}\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Progress dots: one per question, the current one full size.\n * ------------------------------------------------------------------------- */\n\nfunction ProgressDots({\n current,\n ids,\n label,\n}: {\n current: number;\n ids: string[];\n label: string;\n}) {\n const reduced = useReducedMotion() ?? false;\n\n return (\n <span data-slot=\"approval-card-progress\" className=\"flex gap-1.5\">\n <span className=\"sr-only\">{label}</span>\n {ids.map((id, index) => (\n <motion.span\n key={id}\n aria-hidden=\"true\"\n initial={false}\n animate={{\n scale: reduced ? 1 : index === current ? 1 : 0.75,\n opacity: index <= current ? 1 : 0.35,\n }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"size-1.5 rounded-full bg-foreground\"\n />\n ))}\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * ApprovalCard: the card.\n * ------------------------------------------------------------------------- */\n\nfunction ApprovalCard({\n title = \"Approval required\",\n description,\n children,\n questions = [],\n status = \"pending\",\n answers,\n defaultAnswers = {},\n onAnswersChange,\n step,\n defaultStep = 0,\n onStepChange,\n onSubmit,\n onApprove,\n onReject,\n onRequestChanges,\n onDismiss,\n result,\n approveLabel = \"Approve\",\n rejectLabel = \"Reject\",\n requestChangesLabel = \"Request changes\",\n submitLabel = \"Submit response\",\n dismissLabel = \"Dismiss\",\n previousLabel = \"Previous question\",\n nextLabel = \"Next question\",\n customPlaceholder = \"Add another response…\",\n progressLabel = (current, total) => `Question ${current} of ${total}`,\n statusLabels,\n className,\n ...props\n}: ApprovalCardProps) {\n const reduced = useReducedMotion() ?? false;\n const [internalAnswers, setInternalAnswers] =\n React.useState<ApprovalCardAnswers>(defaultAnswers);\n const [internalStep, setInternalStep] = React.useState(defaultStep);\n const autoAdvanceTimer = React.useRef<number | undefined>(undefined);\n const currentAnswers = answers ?? internalAnswers;\n const currentStep = Math.min(\n Math.max(0, step ?? internalStep),\n Math.max(0, questions.length - 1)\n );\n const question = questions[currentStep];\n const questionMode = questions.length > 0;\n const lastStep = currentStep === questions.length - 1;\n const pending = status === \"pending\";\n const busy = status === \"submitting\";\n const interactive = pending || busy;\n const currentAnswer = question\n ? (currentAnswers[question.id] ?? EMPTY_ANSWER)\n : EMPTY_ANSWER;\n const displayTitle = question?.title ?? title;\n const titleKey = question?.id ?? String(status);\n const statusLabel = statusLabels?.[status] ?? DEFAULT_STATUS_LABELS[status];\n\n const clearAutoAdvance = React.useCallback(() => {\n if (autoAdvanceTimer.current === undefined) return;\n window.clearTimeout(autoAdvanceTimer.current);\n autoAdvanceTimer.current = undefined;\n }, []);\n\n React.useEffect(() => clearAutoAdvance, [clearAutoAdvance]);\n\n const setAnswers = React.useCallback(\n (next: ApprovalCardAnswers) => {\n if (answers === undefined) setInternalAnswers(next);\n onAnswersChange?.(next);\n },\n [answers, onAnswersChange]\n );\n\n const setStep = (next: number) => {\n clearAutoAdvance();\n if (step === undefined) setInternalStep(next);\n onStepChange?.(next);\n };\n\n const updateCurrentAnswer = (next: ApprovalCardAnswer) => {\n if (!question) return;\n setAnswers({ ...currentAnswers, [question.id]: next });\n };\n\n const continueQuestion = () => {\n if (currentStep < questions.length - 1) {\n setStep(currentStep + 1);\n return;\n }\n onSubmit?.(currentAnswers);\n };\n\n // A single choice reads as \"done\"; the next question follows after a beat\n // so the selection is seen before the panel slides.\n const queueAutoAdvance = () => {\n if (\n !question ||\n question.multiple ||\n question.autoAdvance === false ||\n currentStep >= questions.length - 1 ||\n busy\n ) {\n return;\n }\n\n clearAutoAdvance();\n autoAdvanceTimer.current = window.setTimeout(() => {\n setStep(currentStep + 1);\n }, 240);\n };\n\n return (\n <div\n data-slot=\"approval-card\"\n data-state={status}\n aria-busy={busy || undefined}\n className={cn(\n \"w-full overflow-hidden rounded-xl bg-muted p-4 text-sm\",\n className\n )}\n {...props}\n >\n <div className=\"flex items-start gap-3\">\n <span\n data-slot=\"approval-card-icon\"\n aria-hidden=\"true\"\n className={cn(\n \"grid size-5 shrink-0 place-items-center text-muted-foreground\",\n ICON_CLASS[status]\n )}\n >\n {busy ? (\n <Spinner className=\"size-4\" />\n ) : interactive ? (\n questionMode ? (\n <CircleHelpIcon className=\"size-4\" />\n ) : (\n <MessageSquareTextIcon className=\"size-4\" />\n )\n ) : status === \"rejected\" ? (\n <XIcon className=\"size-4\" />\n ) : (\n <CheckIcon className=\"size-4\" />\n )}\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <div data-slot=\"approval-card-header\" className=\"flex min-w-0 items-start gap-3\">\n <h3\n data-slot=\"approval-card-title\"\n className=\"min-w-0 flex-1 text-base leading-5 font-medium text-foreground\"\n >\n <SwapText value={titleKey}>{displayTitle}</SwapText>\n </h3>\n {questionMode && interactive ? (\n <span\n data-slot=\"approval-card-counter\"\n className=\"shrink-0 text-xs text-muted-foreground/65 tabular-nums\"\n >\n {currentStep + 1}/{questions.length}\n </span>\n ) : (\n <span\n data-slot=\"approval-card-status\"\n className={cn(\n \"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors\",\n BADGE_CLASS[status]\n )}\n >\n {statusLabel}\n </span>\n )}\n {onDismiss ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={dismissLabel}\n onClick={onDismiss}\n className=\"-mt-0.5 shrink-0 text-muted-foreground hover:text-foreground\"\n >\n <XIcon className=\"size-4\" />\n </Button>\n ) : null}\n </div>\n\n <Disclosure open={interactive}>\n {questionMode && question ? (\n <AnimatePresence initial={false} mode=\"wait\">\n <motion.div\n key={question.id}\n data-slot=\"approval-card-step\"\n initial={reduced ? { opacity: 1 } : { opacity: 0, x: 8 }}\n animate={{ opacity: 1, x: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }}\n transition={{ duration: reduced ? 0 : 0.2, ease: EASE_OUT }}\n >\n {question.description ? (\n <p className=\"mt-1 leading-5 text-muted-foreground\">\n {question.description}\n </p>\n ) : null}\n <QuestionOptions\n question={question}\n answer={currentAnswer}\n disabled={busy}\n customPlaceholder={customPlaceholder}\n onChange={updateCurrentAnswer}\n onSingleSelect={queueAutoAdvance}\n />\n </motion.div>\n </AnimatePresence>\n ) : (\n <div data-slot=\"approval-card-content\">\n {description ? (\n <p className=\"mt-1 leading-5 text-muted-foreground\">\n {description}\n </p>\n ) : null}\n {children ? <div className=\"mt-3\">{children}</div> : null}\n </div>\n )}\n\n {questionMode ? (\n <div\n data-slot=\"approval-card-actions\"\n className=\"mt-4 flex items-center gap-3\"\n >\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n aria-label={previousLabel}\n disabled={busy || currentStep === 0}\n onClick={() => setStep(currentStep - 1)}\n >\n <ArrowLeftIcon className=\"size-4\" />\n </Button>\n <ProgressDots\n current={currentStep}\n ids={questions.map((item) => item.id)}\n label={progressLabel(currentStep + 1, questions.length)}\n />\n <Button\n type=\"button\"\n size={lastStep ? \"sm\" : \"icon\"}\n aria-label={lastStep ? undefined : nextLabel}\n disabled={busy || !isAnswered(currentAnswer)}\n aria-busy={busy || undefined}\n onClick={continueQuestion}\n className=\"ml-auto\"\n >\n {busy ? (\n <Spinner className=\"size-4\" />\n ) : lastStep ? (\n <>\n {submitLabel}\n <ArrowRightIcon className=\"size-3.5\" />\n </>\n ) : (\n <ArrowRightIcon className=\"size-4\" />\n )}\n </Button>\n </div>\n ) : (\n <div\n data-slot=\"approval-card-actions\"\n className=\"mt-4 flex flex-wrap items-center gap-2\"\n >\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={busy}\n aria-busy={busy || undefined}\n onClick={onApprove}\n >\n {approveLabel}\n </Button>\n {onRequestChanges ? (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={busy}\n onClick={onRequestChanges}\n >\n {requestChangesLabel}\n </Button>\n ) : null}\n {onReject ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={busy}\n onClick={onReject}\n className=\"text-muted-foreground hover:text-destructive\"\n >\n {rejectLabel}\n </Button>\n ) : null}\n </div>\n )}\n </Disclosure>\n\n {!interactive ? (\n <p\n data-slot=\"approval-card-result\"\n className=\"mt-1 text-sm text-muted-foreground\"\n >\n {result ?? statusLabel}\n </p>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nexport { ApprovalCard };\n",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"target": "components/ui/ai-approval-card.tsx"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"type": "registry:ui"
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-artifact",
|
|
4
|
+
"title": "AI Artifact",
|
|
5
|
+
"description": "A generated document's frame: header, actions and content.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/button",
|
|
11
|
+
"@intelligo/tooltip"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/ai-artifact/ai-artifact.tsx",
|
|
16
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { type LucideIcon, XIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Artifact({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact\"\n className={cn(\n \"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction ArtifactHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-header\"\n className={cn(\n \"flex items-center justify-between gap-2 border-b bg-muted/50 px-4 py-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction ArtifactTitle({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"artifact-title\"\n className={cn(\"text-sm font-medium text-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactDescription({\n className,\n ...props\n}: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"artifact-description\"\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-actions\"\n className={cn(\"flex items-center gap-1\", className)}\n {...props}\n />\n );\n}\n\nfunction ArtifactClose({\n label,\n children,\n variant = \"ghost\",\n size = \"icon-sm\",\n ...props\n}: React.ComponentProps<typeof Button> & {\n /** Accessible name, e.g. \"Close\". */\n label: string;\n}) {\n return (\n <Button\n data-slot=\"artifact-close\"\n aria-label={label}\n size={size}\n type=\"button\"\n variant={variant}\n {...props}\n >\n {children ?? <XIcon />}\n </Button>\n );\n}\n\nfunction ArtifactAction({\n label,\n tooltip,\n icon: Icon,\n children,\n variant = \"ghost\",\n size = \"icon-sm\",\n ...props\n}: React.ComponentProps<typeof Button> & {\n /** Accessible name. */\n label: string;\n /** Shown on hover and focus when given. */\n tooltip?: string;\n icon?: LucideIcon;\n}) {\n const button = (\n <Button\n data-slot=\"artifact-action\"\n aria-label={label}\n size={size}\n type=\"button\"\n variant={variant}\n {...props}\n >\n {Icon ? <Icon /> : children}\n </Button>\n );\n\n if (!tooltip) return button;\n\n return (\n <Tooltip>\n <TooltipTrigger render={button} />\n <TooltipContent>{tooltip}</TooltipContent>\n </Tooltip>\n );\n}\n\nfunction ArtifactContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"artifact-content\"\n className={cn(\"flex-1 overflow-auto p-4\", className)}\n {...props}\n />\n );\n}\n\nexport {\n Artifact,\n ArtifactHeader,\n ArtifactTitle,\n ArtifactDescription,\n ArtifactActions,\n ArtifactClose,\n ArtifactAction,\n ArtifactContent,\n};\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/ai-artifact.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-branch",
|
|
4
|
+
"title": "AI Branch",
|
|
5
|
+
"description": "A pager over the versions of a reply or an edited message — previous, next, `2 / 3`.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"@intelligo/button"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "base/ui/ai-branch/ai-branch.tsx",
|
|
15
|
+
"content": "\"use client\";\n\n/*\n * For a thread that owns which version is on screen: this is a pager over\n * versions the thread keeps, not a component that clones its children per\n * branch.\n */\n\nimport * as React from \"react\";\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype BranchContextValue = {\n index: number;\n count: number;\n onIndexChange: (index: number) => void;\n};\n\nconst BranchContext = React.createContext<BranchContextValue | null>(null);\n\nfunction useBranch() {\n const context = React.useContext(BranchContext);\n if (!context) {\n throw new Error(\"Branch components must be used within Branch\");\n }\n return context;\n}\n\n/**\n * Versions of one reply — or one edited message — with the thread in\n * charge of `index`. Renders nothing when there is only one.\n */\nfunction Branch({\n index,\n count,\n onIndexChange,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n /** Zero-based; the version on screen. */\n index: number;\n count: number;\n onIndexChange: (index: number) => void;\n}) {\n const value = React.useMemo(\n () => ({ index, count, onIndexChange }),\n [index, count, onIndexChange]\n );\n if (count < 2) return null;\n return (\n <BranchContext.Provider value={value}>\n <div\n data-slot=\"branch\"\n className={cn(\"flex items-center gap-0.5\", className)}\n {...props}\n >\n {children}\n </div>\n </BranchContext.Provider>\n );\n}\n\nfunction BranchPrevious({\n label,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\"> & {\n /** Accessible name, e.g. \"Previous version\". */\n label: string;\n}) {\n const { index, onIndexChange } = useBranch();\n return (\n <Button\n data-slot=\"branch-previous\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={label}\n disabled={index <= 0}\n onClick={() => onIndexChange(index - 1)}\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n >\n <ChevronLeftIcon />\n </Button>\n );\n}\n\nfunction BranchNext({\n label,\n className,\n ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\"> & {\n /** Accessible name, e.g. \"Next version\". */\n label: string;\n}) {\n const { index, count, onIndexChange } = useBranch();\n return (\n <Button\n data-slot=\"branch-next\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={label}\n disabled={index >= count - 1}\n onClick={() => onIndexChange(index + 1)}\n className={cn(\"text-muted-foreground\", className)}\n {...props}\n >\n <ChevronRightIcon />\n </Button>\n );\n}\n\n/** \"2 / 3\". Digits only, so it needs no copy. */\nfunction BranchPage({ className, ...props }: React.ComponentProps<\"span\">) {\n const { index, count } = useBranch();\n return (\n <span\n data-slot=\"branch-page\"\n className={cn(\n \"min-w-8 text-center text-xs tabular-nums text-muted-foreground\",\n className\n )}\n aria-live=\"polite\"\n {...props}\n >\n {index + 1} / {count}\n </span>\n );\n}\n\nexport { Branch, BranchPrevious, BranchNext, BranchPage, useBranch };\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/ai-branch.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ai-citations",
|
|
4
|
+
"title": "AI Citations",
|
|
5
|
+
"description": "The sources an answer leans on: inline source pills with a hover preview, favicon stacks, a sources sheet and a collapsible reference list.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"motion"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/ai-motion",
|
|
12
|
+
"hover-card",
|
|
13
|
+
"@intelligo/sheet"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "base/ui/ai-citations/ai-citations.tsx",
|
|
18
|
+
"content": "\"use client\";\n\n/*\n * The sources an answer leans on: an inline pill naming the site a claim\n * came from, with a preview on hover; favicon stacks; a sources button\n * that opens the full list in a sheet; and the older numbered marker and\n * collapsible list.\n */\n\nimport * as React from \"react\";\nimport {\n BookOpenTextIcon,\n ChevronDownIcon,\n ExternalLinkIcon,\n Globe2Icon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n Disclosure,\n EASE_OUT,\n SPRING_LAYOUT,\n SPRING_SWAP,\n} from \"@/components/ui/ai-motion\";\nimport {\n HoverCard,\n HoverCardContent,\n HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport {\n Sheet,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetTrigger,\n} from \"@/components/ui/sheet\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationItem {\n id: string;\n title: React.ReactNode;\n /** The site, as shown: `wikipedia.org`. */\n domain?: string;\n url?: string;\n /** A line or two from the page. */\n snippet?: React.ReactNode;\n}\n\n/** The 10px index badges; the type scale has no step this small. */\nconst TINY_TEXT: React.CSSProperties = { fontSize: \"0.625rem\" };\n\n/* ----------------------------------------------------------------------------\n * Favicons. Resolve a site's conventional `/favicon.ico` and drop it once\n * it is known to be unusable, so a glyph is drawn instead of a broken\n * image. Plenty of sites answer with a 403 or 404, and `onError` is not\n * enough to catch it: an image the browser starts loading from\n * server-rendered HTML usually fails before React attaches a handler, and\n * that event is never replayed. `decode()` settles on the image's final\n * state instead of relying on an event firing at the right moment.\n * ------------------------------------------------------------------------- */\n\nfunction getFaviconUrl(value: string) {\n try {\n return new URL(\"/favicon.ico\", value).toString();\n } catch {\n return null;\n }\n}\n\nfunction useFavicon(url?: string) {\n const resolved = url ? getFaviconUrl(url) : null;\n const [failedSrc, setFailedSrc] = React.useState<string | null>(null);\n const src = resolved && resolved !== failedSrc ? resolved : null;\n\n const ref = React.useCallback(\n (img: HTMLImageElement | null) => {\n if (!img || !src) return;\n\n let released = false;\n img.decode().catch(() => {\n if (!released) setFailedSrc(src);\n });\n\n // The node is going away or the source changed; a late rejection then\n // describes an image we are no longer showing.\n return () => {\n released = true;\n };\n },\n [src]\n );\n\n return { src, ref };\n}\n\nfunction citationTargetId(prefix: string, citationId: string) {\n return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, \"-\")}`;\n}\n\n/** What a citation reads as when it has to be short: its site, else its title. */\nfunction shortName(citation: CitationItem): React.ReactNode {\n return citation.domain ?? citation.title;\n}\n\n/* ----------------------------------------------------------------------------\n * Citation: the numbered inline marker that jumps to its reference.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationProps\n extends Omit<React.ComponentProps<\"a\">, \"href\" | \"children\"> {\n citationId: string;\n index: number;\n /** Must match the related Citations idPrefix. */\n idPrefix: string;\n /** Assistive-tech name; defaults to \"View citation {index}\". */\n label?: string;\n}\n\nfunction Citation({\n citationId,\n index,\n idPrefix,\n label,\n className,\n style,\n ...props\n}: CitationProps) {\n return (\n <a\n data-slot=\"citation\"\n href={`#${citationTargetId(idPrefix, citationId)}`}\n aria-label={label ?? `View citation ${index}`}\n className={cn(\n \"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-md bg-muted/60 px-1 py-0.5 font-semibold leading-none text-muted-foreground no-underline outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n )}\n style={{ ...TINY_TEXT, ...style }}\n {...props}\n >\n {index}\n </a>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationFavicon and CitationStack.\n * ------------------------------------------------------------------------- */\n\nfunction CitationFavicon({\n url,\n domain,\n className,\n}: {\n url?: string;\n /** Used when there is no url to read the site from. */\n domain?: string;\n className?: string;\n}) {\n const favicon = useFavicon(url ?? (domain ? `https://${domain}` : undefined));\n\n return (\n <span\n data-slot=\"citation-favicon\"\n aria-hidden=\"true\"\n className={cn(\n \"grid size-5 shrink-0 place-items-center text-muted-foreground\",\n className\n )}\n >\n {favicon.src ? (\n // Dynamic cross-site favicons: a plain img keeps the item portable.\n <img\n ref={favicon.ref}\n src={favicon.src}\n alt=\"\"\n width={16}\n height={16}\n referrerPolicy=\"no-referrer\"\n className=\"size-4 rounded-sm object-contain\"\n />\n ) : (\n <Globe2Icon className=\"size-3.5\" />\n )}\n </span>\n );\n}\n\nexport interface CitationStackProps {\n citations: CitationItem[];\n limit?: number;\n className?: string;\n}\n\nfunction CitationStack({ citations, limit = 3, className }: CitationStackProps) {\n return (\n <span\n data-slot=\"citation-stack\"\n aria-hidden=\"true\"\n className={cn(\"flex -space-x-1.5\", className)}\n >\n {citations.slice(0, limit).map((citation) => (\n <CitationFavicon\n key={citation.id}\n url={citation.url}\n domain={citation.domain}\n className=\"size-6 rounded-full bg-background ring-2 ring-background\"\n />\n ))}\n </span>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationCard: one source, read at a glance — site, title, snippet.\n * ------------------------------------------------------------------------- */\n\nfunction CitationCard({\n citation,\n index,\n className,\n}: {\n citation: CitationItem;\n index?: number;\n className?: string;\n}) {\n const content = (\n <>\n <span className=\"flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n <CitationFavicon\n url={citation.url}\n domain={citation.domain}\n className=\"size-4 [&_img]:size-3.5 [&_svg]:size-3\"\n />\n <span className=\"min-w-0 truncate\">{shortName(citation)}</span>\n {index !== undefined ? (\n <span\n className=\"ml-auto grid size-4 shrink-0 place-items-center rounded bg-foreground/5 font-semibold tabular-nums\"\n style={TINY_TEXT}\n >\n {index}\n </span>\n ) : null}\n </span>\n {citation.domain && citation.title !== citation.domain ? (\n <span className=\"line-clamp-2 text-sm leading-5 font-medium text-foreground\">\n {citation.title}\n </span>\n ) : null}\n {citation.snippet ? (\n <span className=\"line-clamp-2 text-xs leading-4 text-muted-foreground\">\n {citation.snippet}\n </span>\n ) : null}\n </>\n );\n const classes = cn(\n \"grid gap-1 rounded-md p-2 text-left outline-none transition-colors\",\n citation.url && \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\",\n className\n );\n\n return citation.url ? (\n <a\n data-slot=\"citation-card\"\n href={citation.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={classes}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"citation-card\" className={classes}>\n {content}\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationPill: the inline source — `wikipedia.org +2` — with the sources\n * it stands for on hover. Clicking it opens the first source.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationPillProps {\n /** The sources this claim cites, in order; the first names the pill. */\n citations: CitationItem[];\n /** Assistive-tech name, e.g. \"Source: wikipedia.org\". */\n label?: string;\n className?: string;\n}\n\nfunction CitationPill({ citations, label, className }: CitationPillProps) {\n const [first, ...rest] = citations;\n if (!first) return null;\n\n const pillClass = cn(\n \"mx-0.5 inline-flex max-w-44 -translate-y-px items-center gap-1 rounded-full bg-muted px-1.5 align-middle text-xs leading-5 font-medium text-muted-foreground no-underline! outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n );\n const pill = (\n <>\n <CitationFavicon\n url={first.url}\n domain={first.domain}\n className=\"size-3.5 [&_img]:size-3 [&_svg]:size-3\"\n />\n <span className=\"min-w-0 truncate\">{shortName(first)}</span>\n {rest.length > 0 ? (\n <span className=\"shrink-0 tabular-nums opacity-70\">+{rest.length}</span>\n ) : null}\n </>\n );\n\n return (\n <HoverCard>\n <HoverCardTrigger\n data-slot=\"citation-pill\"\n delay={150}\n aria-label={label}\n className={pillClass}\n render={\n first.url ? (\n <a href={first.url} target=\"_blank\" rel=\"noreferrer noopener\" />\n ) : (\n <span tabIndex={0} />\n )\n }\n >\n {pill}\n </HoverCardTrigger>\n <HoverCardContent side=\"top\" align=\"start\" className=\"grid w-80 gap-0.5 p-1\">\n {citations.map((citation) => (\n <CitationCard key={citation.id} citation={citation} />\n ))}\n </HoverCardContent>\n </HoverCard>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationSources: the favicons and a count under an answer; the full\n * list opens in a sheet beside it.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationSourcesProps {\n citations: CitationItem[];\n /** The button's text, e.g. \"5 sources\". */\n label: React.ReactNode;\n /** The sheet's heading, e.g. \"Sources\". */\n title: React.ReactNode;\n className?: string;\n}\n\nfunction CitationSources({\n citations,\n label,\n title,\n className,\n}: CitationSourcesProps) {\n if (citations.length === 0) return null;\n\n return (\n <Sheet>\n <SheetTrigger\n data-slot=\"citation-sources\"\n render={<button type=\"button\" />}\n className={cn(\n \"inline-flex h-8 w-fit items-center gap-2 rounded-full border bg-background py-1 pr-3 pl-1 text-sm text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n className\n )}\n >\n <CitationStack citations={citations} className=\"[&>span]:size-5.5\" />\n <span className=\"font-medium\">{label}</span>\n </SheetTrigger>\n <SheetContent side=\"right\" className=\"w-full gap-0 p-0 sm:max-w-md\">\n <SheetHeader className=\"border-b pr-12\">\n <SheetTitle>{title}</SheetTitle>\n </SheetHeader>\n <div className=\"grid min-h-0 flex-1 content-start gap-0.5 overflow-y-auto p-2\">\n {citations.map((citation) => (\n <CitationCard\n key={citation.id}\n citation={citation}\n index={Number.isNaN(Number(citation.id)) ? undefined : Number(citation.id)}\n className=\"p-2.5\"\n />\n ))}\n </div>\n </SheetContent>\n </Sheet>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * CitationList: the numbered references.\n * ------------------------------------------------------------------------- */\n\nfunction CitationRow({\n citation,\n index,\n idPrefix,\n}: {\n citation: CitationItem;\n index: number;\n idPrefix: string;\n}) {\n const content = (\n <>\n <CitationFavicon url={citation.url} domain={citation.domain} />\n <span className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n <span className=\"truncate text-sm font-medium text-foreground/80 transition-colors group-hover/citation:text-foreground\">\n {citation.title}\n </span>\n {citation.domain ? (\n <span className=\"min-w-0 truncate text-xs text-muted-foreground/60\">\n {citation.domain}\n </span>\n ) : null}\n </span>\n <span className=\"flex shrink-0 items-center gap-1.5\">\n <span\n className=\"grid size-5 place-items-center rounded-md bg-foreground/5 font-semibold tabular-nums text-muted-foreground\"\n style={TINY_TEXT}\n >\n {index}\n </span>\n {citation.url ? (\n <ExternalLinkIcon className=\"size-3.5 text-muted-foreground/40 transition-colors group-hover/citation:text-muted-foreground\" />\n ) : null}\n </span>\n </>\n );\n const className =\n \"group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n const id = citationTargetId(idPrefix, citation.id);\n\n return citation.url ? (\n <a\n data-slot=\"citation-row\"\n id={id}\n href={citation.url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={className}\n >\n {content}\n </a>\n ) : (\n <div data-slot=\"citation-row\" id={id} className={className}>\n {content}\n </div>\n );\n}\n\nexport interface CitationListProps {\n citations: CitationItem[];\n idPrefix?: string;\n className?: string;\n}\n\nfunction CitationList({ citations, idPrefix, className }: CitationListProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const resolvedPrefix =\n idPrefix ?? `citation-list-${baseId.replace(/:/g, \"\")}`;\n\n return (\n <div data-slot=\"citation-list\" className={cn(\"grid gap-0.5\", className)}>\n <AnimatePresence mode=\"popLayout\">\n {citations.map((citation, index) => (\n <motion.div\n layout=\"position\"\n key={citation.id}\n initial={reduced ? { opacity: 1 } : { opacity: 0, y: 6 }}\n animate={{ opacity: 1, y: 0 }}\n exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}\n transition={\n reduced\n ? { duration: 0 }\n : {\n opacity: { duration: 0.18, ease: EASE_OUT },\n y: SPRING_LAYOUT,\n layout: SPRING_LAYOUT,\n }\n }\n >\n <CitationRow\n citation={citation}\n index={index + 1}\n idPrefix={resolvedPrefix}\n />\n </motion.div>\n ))}\n </AnimatePresence>\n </div>\n );\n}\n\n/* ----------------------------------------------------------------------------\n * Citations: the collapsible reference collection.\n * ------------------------------------------------------------------------- */\n\nexport interface CitationsProps {\n citations: CitationItem[];\n title?: React.ReactNode;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n idPrefix?: string;\n className?: string;\n}\n\nfunction Citations({\n citations,\n title = \"Sources\",\n open,\n defaultOpen = false,\n onOpenChange,\n idPrefix,\n className,\n}: CitationsProps) {\n const reduced = useReducedMotion() ?? false;\n const baseId = React.useId();\n const contentId = `${baseId}-content`;\n const resolvedPrefix = idPrefix ?? `citation-${baseId.replace(/:/g, \"\")}`;\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const currentOpen = open ?? internalOpen;\n const setOpen = React.useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, open]\n );\n\n return (\n <div\n data-slot=\"citations\"\n data-state={currentOpen ? \"open\" : \"closed\"}\n className={cn(\"w-full text-sm\", className)}\n >\n <button\n data-slot=\"citations-trigger\"\n type=\"button\"\n aria-expanded={currentOpen}\n aria-controls={contentId}\n onClick={() => setOpen(!currentOpen)}\n className=\"group -ml-1 flex min-h-8 items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <BookOpenTextIcon className=\"size-4\" />\n <span className=\"font-medium\">{title}</span>\n <span\n className=\"rounded-full bg-muted px-1.5 py-0.5 font-semibold tabular-nums\"\n style={TINY_TEXT}\n >\n {citations.length}\n </span>\n <motion.span\n aria-hidden=\"true\"\n animate={{ rotate: currentOpen ? 180 : 0 }}\n transition={reduced ? { duration: 0 } : SPRING_SWAP}\n className=\"text-muted-foreground/60\"\n >\n <ChevronDownIcon className=\"size-3.5\" />\n </motion.span>\n </button>\n\n <Disclosure id={contentId} open={currentOpen}>\n <CitationList\n citations={citations}\n idPrefix={resolvedPrefix}\n className=\"mt-1\"\n />\n </Disclosure>\n </div>\n );\n}\n\nexport {\n Citation,\n CitationCard,\n CitationFavicon,\n CitationList,\n CitationPill,\n Citations,\n CitationSources,\n CitationStack,\n};\n",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"target": "components/ui/ai-citations.tsx"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|