handoff-app 2.0.0-8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{Changelog.md → CHANGELOG.md} +85 -1
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +816 -393
- package/RECIPES.md +975 -0
- package/UPGRADE.md +57 -9
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/connections.d.ts +66 -0
- package/dist/ai/connections.js +149 -0
- package/dist/ai/define.d.ts +10 -0
- package/dist/ai/define.js +13 -0
- package/dist/ai/types.d.ts +26 -0
- package/dist/ai/types.js +10 -0
- package/dist/app-builder/build.d.ts +14 -7
- package/dist/app-builder/build.js +195 -66
- package/dist/app-builder/client-config.js +3 -2
- package/dist/app-builder/paths.d.ts +0 -12
- package/dist/app-builder/paths.js +3 -20
- package/dist/app-builder/static-export.js +1 -1
- package/dist/app-builder/vercel-output.d.ts +1 -1
- package/dist/app-builder/vercel-output.js +5 -4
- package/dist/cache/build-cache.d.ts +27 -4
- package/dist/cache/build-cache.js +114 -47
- package/dist/cache/build-inputs.d.ts +13 -0
- package/dist/cache/build-inputs.js +19 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +5 -1
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/cli/create.js +1 -2
- package/dist/commands/build/index.d.ts +1 -1
- package/dist/commands/build/index.js +1 -1
- package/dist/commands/utils.d.ts +3 -3
- package/dist/config/defaults.js +7 -4
- package/dist/config/from-env.d.ts +4 -0
- package/dist/config/from-env.js +38 -1
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +1 -4
- package/dist/config/loader.js +28 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +9 -11
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +12 -7
- package/dist/index.js +18 -22
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/pipeline/figma.d.ts +13 -2
- package/dist/pipeline/figma.js +86 -46
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/validation.d.ts +1 -1
- package/dist/pipeline/validation.js +3 -3
- package/dist/providers.d.ts +10 -0
- package/dist/providers.js +13 -0
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +9 -19
- package/dist/registry/asset-storage/resolve.js +33 -21
- package/dist/registry/asset-storage/types.d.ts +37 -17
- package/dist/registry/asset-storage/types.js +5 -5
- package/dist/registry/auth/ai-keys.d.ts +32 -0
- package/dist/registry/auth/ai-keys.js +69 -0
- package/dist/registry/auth/crypto.d.ts +17 -0
- package/dist/registry/auth/crypto.js +61 -1
- package/dist/registry/auth/index.d.ts +2 -1
- package/dist/registry/auth/index.js +11 -2
- package/dist/registry/checkout/index.d.ts +2 -1
- package/dist/registry/checkout/index.js +57 -127
- package/dist/registry/client.js +53 -17
- package/dist/registry/db/driver.js +3 -3
- package/dist/registry/db/schema.d.ts +261 -3
- package/dist/registry/db/schema.js +29 -4
- package/dist/registry/email/define.d.ts +10 -0
- package/dist/registry/email/define.js +13 -0
- package/dist/registry/email/providers/smtp.d.ts +10 -0
- package/dist/registry/email/providers/smtp.js +73 -0
- package/dist/registry/email/resolve.d.ts +28 -0
- package/dist/registry/email/resolve.js +74 -0
- package/dist/registry/email/types.d.ts +24 -0
- package/dist/registry/email/types.js +6 -0
- package/dist/registry/path.d.ts +2 -0
- package/dist/registry/path.js +11 -1
- package/dist/registry/publish/package.js +8 -0
- package/dist/registry/transfer.d.ts +4 -0
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +3 -1
- package/dist/transformers/preview/component/css.js +92 -51
- package/dist/transformers/preview/component/html.d.ts +3 -1
- package/dist/transformers/preview/component/html.js +6 -2
- package/dist/transformers/preview/component/javascript.d.ts +2 -1
- package/dist/transformers/preview/component/javascript.js +10 -4
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +24 -0
- package/dist/transformers/utils/build.js +100 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +135 -18
- package/dist/utils/path.d.ts +1 -5
- package/dist/utils/path.js +6 -23
- package/docs/api.md +6 -4
- package/docs/cli.md +275 -54
- package/drizzle/0007_add_ai_keys.sql +11 -0
- package/drizzle/0008_add_source_dir.sql +2 -0
- package/drizzle/meta/0007_snapshot.json +2013 -0
- package/drizzle/meta/0008_snapshot.json +2025 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +19 -8
- package/src/ai/README.md +42 -0
- package/src/ai/connections.ts +189 -0
- package/src/ai/define.ts +15 -0
- package/src/ai/types.ts +30 -0
- package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
- package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
- package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
- package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
- package/src/app/components/AiAssistant/dockState.ts +86 -0
- package/src/app/components/AiAssistant/toolLabel.ts +24 -0
- package/src/app/components/AiAssistant/useAiChat.ts +147 -0
- package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
- package/src/app/components/AiAssistant/usePageContext.ts +63 -0
- package/src/app/components/Auth/AccountLayout.tsx +58 -19
- package/src/app/components/Auth/AuthShell.tsx +27 -33
- package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
- package/src/app/components/Auth/installStatus.ts +29 -0
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +383 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Header.tsx +21 -15
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +2 -2
- package/src/app/components/Navigation/SideNav.tsx +1 -15
- package/src/app/components/context/NavProvider.tsx +7 -3
- package/src/app/components/ui/attachment.tsx +50 -0
- package/src/app/components/ui/bubble.tsx +57 -0
- package/src/app/components/ui/button.tsx +3 -1
- package/src/app/components/ui/confirm-dialog.tsx +87 -0
- package/src/app/components/ui/dialog.tsx +7 -2
- package/src/app/components/ui/marker.tsx +56 -0
- package/src/app/components/ui/sidebar.tsx +3 -1
- package/src/app/components/util/index.ts +3 -12
- package/src/app/css/index.css +64 -0
- package/src/app/lib/ai/auth.ts +33 -0
- package/src/app/lib/ai/context.ts +99 -0
- package/src/app/lib/ai/model.ts +79 -0
- package/src/app/lib/ai/prompt.ts +36 -0
- package/src/app/lib/ai/resolve.ts +97 -0
- package/src/app/lib/ai/tools.ts +120 -0
- package/src/app/lib/api/methods.ts +13 -0
- package/src/app/lib/asset-storage.ts +35 -36
- package/src/app/lib/auth/api.ts +4 -12
- package/src/app/lib/auth/email.ts +96 -23
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +47 -21
- package/src/app/lib/mcp/components.ts +523 -0
- package/src/app/lib/mcp/page-url.ts +24 -0
- package/src/app/lib/mcp/pages.ts +84 -0
- package/src/app/lib/mcp/result.ts +33 -0
- package/src/app/lib/mcp/server.ts +39 -318
- package/src/app/lib/mcp/tokens.ts +334 -0
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +119 -11
- package/src/app/lib/registry-api/errors.ts +2 -0
- package/src/app/lib/registry-api/handler.ts +7 -1
- package/src/app/lib/registry-api/transfer.ts +28 -6
- package/src/app/lib/server-module.ts +34 -0
- package/src/app/lib/utils.ts +20 -0
- package/src/app/next.config.mjs +26 -24
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/_app.tsx +6 -2
- package/src/app/pages/_document.tsx +6 -0
- package/src/app/pages/account/ai.tsx +183 -0
- package/src/app/pages/account/index.tsx +68 -68
- package/src/app/pages/account/tokens.tsx +37 -7
- package/src/app/pages/account/users.tsx +75 -15
- package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
- package/src/app/pages/api/account/ai/keys/index.ts +20 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
- package/src/app/pages/api/admin/users/index.ts +2 -2
- package/src/app/pages/api/ai/chat.ts +115 -0
- package/src/app/pages/api/ai/connections.ts +23 -0
- package/src/app/pages/api/auth/request-reset.ts +14 -16
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/install.ts +3 -3
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +10 -5
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/install.tsx +7 -2
- package/src/app/pages/login.tsx +32 -27
- package/src/app/pages/reset-password.tsx +21 -20
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/README.md +2 -1
- package/src/app-builder/build.ts +218 -73
- package/src/app-builder/client-config.ts +3 -2
- package/src/app-builder/paths.ts +3 -22
- package/src/app-builder/static-export.ts +1 -1
- package/src/app-builder/vercel-output.ts +5 -4
- package/src/cache/README.md +4 -1
- package/src/cache/build-cache.ts +128 -43
- package/src/cache/build-inputs.ts +28 -0
- package/src/cache/index.ts +4 -0
- package/src/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- package/src/cli/create.ts +1 -2
- package/src/commands/build/index.ts +1 -1
- package/src/config/README.md +10 -9
- package/src/config/defaults.ts +7 -4
- package/src/config/from-env.ts +33 -0
- package/src/config/helpers.ts +1 -3
- package/src/config/index.ts +0 -3
- package/src/config/loader.ts +27 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +10 -5
- package/src/handlebars.ts +8 -2
- package/src/index.ts +33 -29
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/pipeline/README.md +1 -1
- package/src/pipeline/figma.ts +98 -49
- package/src/pipeline/styles.ts +1 -1
- package/src/pipeline/validation.ts +3 -3
- package/src/providers.ts +18 -0
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +38 -37
- package/src/registry/asset-storage/types.ts +39 -17
- package/src/registry/auth/ai-keys.ts +62 -0
- package/src/registry/auth/crypto.ts +63 -1
- package/src/registry/auth/index.ts +11 -1
- package/src/registry/checkout/index.ts +60 -138
- package/src/registry/client.ts +60 -20
- package/src/registry/db/driver.ts +2 -2
- package/src/registry/db/schema.ts +49 -22
- package/src/registry/email/define.ts +15 -0
- package/src/registry/email/providers/smtp.ts +39 -0
- package/src/registry/email/resolve.ts +94 -0
- package/src/registry/email/types.ts +28 -0
- package/src/registry/path.ts +11 -0
- package/src/registry/publish/package.ts +9 -0
- package/src/registry/transfer.ts +4 -0
- package/src/store/registry.ts +7 -7
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +101 -53
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +93 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +148 -20
- package/src/utils/README.md +1 -1
- package/src/utils/path.ts +6 -29
- package/templates/default/handoff.config.js.tpl +10 -2
- package/templates/default/handoff.config.ts.tpl +10 -2
- package/templates/default/handoff.config.with-examples.js.tpl +10 -2
- package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
- package/templates/default/package.json.tpl +2 -2
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/config/validator.d.ts +0 -9
- package/dist/config/validator.js +0 -24
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
- package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/lib/mcp/shape.ts +0 -596
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/config/validator.ts +0 -22
- package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import type { ClientConfig } from '@handoff/types/config';
|
|
2
|
+
import type { GetStaticProps } from 'next';
|
|
3
|
+
import { getClientRuntimeConfig } from '../../components/util';
|
|
1
4
|
import { AccountLayout } from '../../components/Auth/AccountLayout';
|
|
2
5
|
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
6
|
import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
|
|
4
7
|
import { Badge } from '../../components/ui/badge';
|
|
5
8
|
import { Button } from '../../components/ui/button';
|
|
6
9
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
10
|
+
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
|
|
7
11
|
import { Input } from '../../components/ui/input';
|
|
8
12
|
import { Label } from '../../components/ui/label';
|
|
9
13
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
@@ -29,15 +33,17 @@ const formatDate = (value?: string | null) => {
|
|
|
29
33
|
return Number.isNaN(date.valueOf()) ? value : date.toLocaleDateString();
|
|
30
34
|
};
|
|
31
35
|
|
|
32
|
-
export
|
|
36
|
+
export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
|
|
37
|
+
|
|
38
|
+
export default function TokensPage({ config }: { config: ClientConfig }) {
|
|
33
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
34
|
-
return <AccountLayout title="Access tokens">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Access tokens">{null}</AccountLayout>;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
return <RegistryTokensPage />;
|
|
43
|
+
return <RegistryTokensPage config={config} />;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
function RegistryTokensPage() {
|
|
46
|
+
function RegistryTokensPage({ config }: { config: ClientConfig }) {
|
|
41
47
|
const { data: session } = useSession();
|
|
42
48
|
const role = (session?.user as { role?: string } | undefined)?.role;
|
|
43
49
|
const [tokens, setTokens] = useState<AccessToken[]>([]);
|
|
@@ -46,6 +52,7 @@ function RegistryTokensPage() {
|
|
|
46
52
|
const [copied, setCopied] = useState(false);
|
|
47
53
|
const [pending, setPending] = useState(false);
|
|
48
54
|
const [error, setError] = useState<string | null>(null);
|
|
55
|
+
const [tokenToRevoke, setTokenToRevoke] = useState<AccessToken | null>(null);
|
|
49
56
|
|
|
50
57
|
const load = useCallback(async () => {
|
|
51
58
|
const response = await fetch(authApiUrl('/api/account/tokens'), { credentials: 'include', cache: 'no-store' });
|
|
@@ -89,7 +96,6 @@ function RegistryTokensPage() {
|
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
const revoke = async (token: AccessToken) => {
|
|
92
|
-
if (!window.confirm(`Revoke “${token.name}”? Applications using it will immediately lose access.`)) return;
|
|
93
99
|
setPending(true);
|
|
94
100
|
setError(null);
|
|
95
101
|
try {
|
|
@@ -115,7 +121,11 @@ function RegistryTokensPage() {
|
|
|
115
121
|
};
|
|
116
122
|
|
|
117
123
|
return (
|
|
118
|
-
<AccountLayout
|
|
124
|
+
<AccountLayout
|
|
125
|
+
config={config}
|
|
126
|
+
title="Access tokens"
|
|
127
|
+
description="Create and revoke credentials for the CLI, CI jobs, and integrations."
|
|
128
|
+
>
|
|
119
129
|
<div className="space-y-6">
|
|
120
130
|
<Card>
|
|
121
131
|
<CardHeader>
|
|
@@ -209,7 +219,12 @@ function RegistryTokensPage() {
|
|
|
209
219
|
<TableCell>{formatDate(token.lastUsedAt)}</TableCell>
|
|
210
220
|
<TableCell>{formatDate(token.expiresAt)}</TableCell>
|
|
211
221
|
<TableCell className="text-right">
|
|
212
|
-
<Button
|
|
222
|
+
<Button
|
|
223
|
+
size="sm"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
disabled={pending || Boolean(token.revokedAt)}
|
|
226
|
+
onClick={() => setTokenToRevoke(token)}
|
|
227
|
+
>
|
|
213
228
|
<Trash2 /> Revoke
|
|
214
229
|
</Button>
|
|
215
230
|
</TableCell>
|
|
@@ -227,6 +242,21 @@ function RegistryTokensPage() {
|
|
|
227
242
|
</CardContent>
|
|
228
243
|
</Card>
|
|
229
244
|
</div>
|
|
245
|
+
|
|
246
|
+
{tokenToRevoke ? (
|
|
247
|
+
<ConfirmDialog
|
|
248
|
+
open
|
|
249
|
+
onOpenChange={(open) => {
|
|
250
|
+
if (!open) setTokenToRevoke(null);
|
|
251
|
+
}}
|
|
252
|
+
title={`Revoke “${tokenToRevoke.name}”?`}
|
|
253
|
+
description="Applications that use this token lose access immediately."
|
|
254
|
+
confirmLabel="Revoke"
|
|
255
|
+
pendingLabel="Revoking…"
|
|
256
|
+
variant="destructive"
|
|
257
|
+
onConfirm={() => revoke(tokenToRevoke)}
|
|
258
|
+
/>
|
|
259
|
+
) : null}
|
|
230
260
|
</AccountLayout>
|
|
231
261
|
);
|
|
232
262
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import type { ClientConfig } from '@handoff/types/config';
|
|
2
|
+
import type { GetStaticProps } from 'next';
|
|
3
|
+
import { getClientRuntimeConfig } from '../../components/util';
|
|
1
4
|
import { AccountLayout } from '../../components/Auth/AccountLayout';
|
|
2
5
|
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
6
|
import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
|
|
4
7
|
import { Badge } from '../../components/ui/badge';
|
|
5
8
|
import { Button } from '../../components/ui/button';
|
|
6
9
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
10
|
+
import { ConfirmDialog, type ConfirmDialogProps } from '../../components/ui/confirm-dialog';
|
|
7
11
|
import { Input } from '../../components/ui/input';
|
|
8
12
|
import { Label } from '../../components/ui/label';
|
|
9
13
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
10
14
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
|
|
11
|
-
import { Check, Copy, MailPlus, RefreshCw } from 'lucide-react';
|
|
15
|
+
import { Check, Copy, KeyRound, MailPlus, RefreshCw } from 'lucide-react';
|
|
12
16
|
import { useSession } from 'next-auth/react';
|
|
13
17
|
import { useRouter } from 'next/router';
|
|
14
18
|
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
|
@@ -29,15 +33,17 @@ interface MutationResult {
|
|
|
29
33
|
message?: string;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
export
|
|
36
|
+
export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
|
|
37
|
+
|
|
38
|
+
export default function UsersPage({ config }: { config: ClientConfig }) {
|
|
33
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
34
|
-
return <AccountLayout title="Users">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Users">{null}</AccountLayout>;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
return <RegistryUsersPage />;
|
|
43
|
+
return <RegistryUsersPage config={config} />;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
function RegistryUsersPage() {
|
|
46
|
+
function RegistryUsersPage({ config }: { config: ClientConfig }) {
|
|
41
47
|
const router = useRouter();
|
|
42
48
|
const { data: session, status: sessionStatus } = useSession();
|
|
43
49
|
const currentUser = session?.user as (typeof session.user & { id?: string; role?: string }) | undefined;
|
|
@@ -48,6 +54,8 @@ function RegistryUsersPage() {
|
|
|
48
54
|
const [success, setSuccess] = useState<string | null>(null);
|
|
49
55
|
const [manualLink, setManualLink] = useState<string | null>(null);
|
|
50
56
|
const [copied, setCopied] = useState(false);
|
|
57
|
+
const [emailConfigured, setEmailConfigured] = useState<boolean | null>(null);
|
|
58
|
+
const [confirmation, setConfirmation] = useState<Omit<ConfirmDialogProps, 'open' | 'onOpenChange'> | null>(null);
|
|
51
59
|
|
|
52
60
|
useEffect(() => {
|
|
53
61
|
if (sessionStatus === 'authenticated' && currentUser?.role !== 'admin') void router.replace('/account');
|
|
@@ -56,8 +64,9 @@ function RegistryUsersPage() {
|
|
|
56
64
|
const load = useCallback(async () => {
|
|
57
65
|
const response = await fetch(authApiUrl('/api/admin/users'), { credentials: 'include', cache: 'no-store' });
|
|
58
66
|
if (!response.ok) throw new Error(await readApiError(response, 'Could not load users.'));
|
|
59
|
-
const body = (await response.json()) as RegistryUser[] | { users: RegistryUser[] };
|
|
67
|
+
const body = (await response.json()) as RegistryUser[] | { users: RegistryUser[]; emailConfigured?: boolean };
|
|
60
68
|
const rows = Array.isArray(body) ? body : body.users;
|
|
69
|
+
if (!Array.isArray(body)) setEmailConfigured(body.emailConfigured ?? null);
|
|
61
70
|
setUsers(
|
|
62
71
|
rows.map((user) => ({
|
|
63
72
|
...user,
|
|
@@ -112,7 +121,12 @@ function RegistryUsersPage() {
|
|
|
112
121
|
}
|
|
113
122
|
};
|
|
114
123
|
|
|
115
|
-
const mutate = async (
|
|
124
|
+
const mutate = async (
|
|
125
|
+
user: RegistryUser,
|
|
126
|
+
action: 'resend' | 'reset' | 'role' | 'status',
|
|
127
|
+
body: Record<string, string>,
|
|
128
|
+
successMessage: string
|
|
129
|
+
) => {
|
|
116
130
|
setPending(true);
|
|
117
131
|
setError(null);
|
|
118
132
|
setSuccess(null);
|
|
@@ -146,13 +160,17 @@ function RegistryUsersPage() {
|
|
|
146
160
|
};
|
|
147
161
|
|
|
148
162
|
return (
|
|
149
|
-
<AccountLayout title="Users">
|
|
163
|
+
<AccountLayout config={config} title="Users" description="Invite people and manage their access to this registry.">
|
|
150
164
|
<div className="space-y-6">
|
|
151
165
|
<Card>
|
|
152
166
|
<CardHeader>
|
|
153
167
|
<CardTitle>Invite a user</CardTitle>
|
|
154
168
|
<CardDescription>
|
|
155
|
-
|
|
169
|
+
{emailConfigured === false
|
|
170
|
+
? 'Email delivery is off. Activation and reset links appear once here for you to deliver.'
|
|
171
|
+
: emailConfigured
|
|
172
|
+
? 'Invitations and reset links are sent by email.'
|
|
173
|
+
: null}
|
|
156
174
|
</CardDescription>
|
|
157
175
|
</CardHeader>
|
|
158
176
|
<form onSubmit={invite}>
|
|
@@ -170,12 +188,12 @@ function RegistryUsersPage() {
|
|
|
170
188
|
{manualLink ? (
|
|
171
189
|
<Alert variant="warning">
|
|
172
190
|
<MailPlus />
|
|
173
|
-
<AlertTitle>Deliver this
|
|
191
|
+
<AlertTitle>Deliver this link securely</AlertTitle>
|
|
174
192
|
<AlertDescription>
|
|
175
193
|
<p className="mb-3">It is shown only in this response.</p>
|
|
176
194
|
<div className="flex gap-2">
|
|
177
195
|
<Input value={manualLink} readOnly className="text-xs" />
|
|
178
|
-
<Button type="button" size="icon" variant="outline" onClick={() => void copyLink()} aria-label="Copy
|
|
196
|
+
<Button type="button" size="icon" variant="outline" onClick={() => void copyLink()} aria-label="Copy link">
|
|
179
197
|
{copied ? <Check /> : <Copy />}
|
|
180
198
|
</Button>
|
|
181
199
|
</div>
|
|
@@ -272,6 +290,24 @@ function RegistryUsersPage() {
|
|
|
272
290
|
<RefreshCw /> Resend
|
|
273
291
|
</Button>
|
|
274
292
|
) : null}
|
|
293
|
+
{user.status === 'active' ? (
|
|
294
|
+
<Button
|
|
295
|
+
size="sm"
|
|
296
|
+
variant="ghost"
|
|
297
|
+
disabled={pending}
|
|
298
|
+
onClick={() =>
|
|
299
|
+
setConfirmation({
|
|
300
|
+
title: `Create a password reset link for ${user.email}?`,
|
|
301
|
+
description: 'They can use the link to set a new password.',
|
|
302
|
+
confirmLabel: 'Create link',
|
|
303
|
+
pendingLabel: 'Creating…',
|
|
304
|
+
onConfirm: () => mutate(user, 'reset', {}, `Password reset link created for ${user.email}.`),
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
>
|
|
308
|
+
<KeyRound /> Reset password
|
|
309
|
+
</Button>
|
|
310
|
+
) : null}
|
|
275
311
|
{user.status !== 'invited' ? (
|
|
276
312
|
<Button
|
|
277
313
|
size="sm"
|
|
@@ -279,11 +315,25 @@ function RegistryUsersPage() {
|
|
|
279
315
|
disabled={pending || (user.id === currentUser?.id && user.status === 'active')}
|
|
280
316
|
onClick={() => {
|
|
281
317
|
const nextStatus = user.status === 'active' ? 'deactivated' : 'active';
|
|
282
|
-
const
|
|
318
|
+
const onConfirm = () => mutate(user, 'status', { status: nextStatus }, `Updated ${user.email}.`);
|
|
319
|
+
setConfirmation(
|
|
283
320
|
nextStatus === 'active'
|
|
284
|
-
?
|
|
285
|
-
|
|
286
|
-
|
|
321
|
+
? {
|
|
322
|
+
title: `Reactivate ${user.email}?`,
|
|
323
|
+
description: 'They can sign in again. Access tokens revoked at deactivation stay revoked.',
|
|
324
|
+
confirmLabel: 'Reactivate',
|
|
325
|
+
pendingLabel: 'Reactivating…',
|
|
326
|
+
onConfirm,
|
|
327
|
+
}
|
|
328
|
+
: {
|
|
329
|
+
title: `Deactivate ${user.email}?`,
|
|
330
|
+
description: 'They can no longer sign in, and all their access tokens are revoked.',
|
|
331
|
+
confirmLabel: 'Deactivate',
|
|
332
|
+
pendingLabel: 'Deactivating…',
|
|
333
|
+
variant: 'destructive',
|
|
334
|
+
onConfirm,
|
|
335
|
+
}
|
|
336
|
+
);
|
|
287
337
|
}}
|
|
288
338
|
>
|
|
289
339
|
{user.status === 'active' ? 'Deactivate' : 'Reactivate'}
|
|
@@ -305,6 +355,16 @@ function RegistryUsersPage() {
|
|
|
305
355
|
</CardContent>
|
|
306
356
|
</Card>
|
|
307
357
|
</div>
|
|
358
|
+
|
|
359
|
+
{confirmation ? (
|
|
360
|
+
<ConfirmDialog
|
|
361
|
+
{...confirmation}
|
|
362
|
+
open
|
|
363
|
+
onOpenChange={(open) => {
|
|
364
|
+
if (!open) setConfirmation(null);
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
) : null}
|
|
308
368
|
</AccountLayout>
|
|
309
369
|
);
|
|
310
370
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { MissingEncryptionSecretError, deleteRegistryAiKey, saveRegistryAiKey } from '@handoff/registry/auth';
|
|
3
|
+
import { aiCredentialKind } from '@handoff/ai/connections';
|
|
4
|
+
import { allowApiMethods, prepareRegistryApi } from '@/lib/auth/api';
|
|
5
|
+
import { findAiConnection } from '@/lib/ai/resolve';
|
|
6
|
+
import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `/api/account/ai/keys/[connectionId]`: save or remove this reader's key for one declared
|
|
10
|
+
* connection. A connection id the config does not declare with `credential: 'user'` is rejected, so
|
|
11
|
+
* a reader can never introduce an endpoint of their own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Longest key worth accepting. Provider keys are short; anything longer is a mistake or an attack. */
|
|
15
|
+
const MAX_KEY_LENGTH = 512;
|
|
16
|
+
|
|
17
|
+
export default async function accountAiKeyHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
18
|
+
const method = allowApiMethods(req, res, ['PUT', 'DELETE']);
|
|
19
|
+
if (!method) return;
|
|
20
|
+
if (!getServerRuntimeConfig().ai.enabled) {
|
|
21
|
+
res.status(404).end();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: true });
|
|
25
|
+
if (!context?.user) return;
|
|
26
|
+
|
|
27
|
+
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : '';
|
|
28
|
+
const connection = findAiConnection(connectionId);
|
|
29
|
+
if (!connection || aiCredentialKind(connection) !== 'user') {
|
|
30
|
+
res.status(404).json({ error: 'That AI connection does not take a personal API key.' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (method === 'DELETE') {
|
|
35
|
+
const removed = await deleteRegistryAiKey(context.db, { userId: context.user.id, connectionId });
|
|
36
|
+
if (!removed) {
|
|
37
|
+
res.status(404).json({ error: 'No API key is saved for that connection.' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
res.status(200).json({ ok: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = typeof (req.body as { key?: unknown })?.key === 'string' ? (req.body as { key: string }).key.trim() : '';
|
|
45
|
+
if (!key || key.length > MAX_KEY_LENGTH) {
|
|
46
|
+
res.status(400).json({ error: 'Enter the API key for this provider.' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await saveRegistryAiKey(context.db, { userId: context.user.id, connectionId, key });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof MissingEncryptionSecretError) {
|
|
54
|
+
res.status(503).json({ error: error.message, code: 'encryption_secret_missing' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
res.status(200).json({ ok: true });
|
|
60
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { allowApiMethods, prepareRegistryApi } from '@/lib/auth/api';
|
|
3
|
+
import { describeAiUserConnections } from '@/lib/ai/resolve';
|
|
4
|
+
import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/api/account/ai/keys`: the connections this reader may add a key for, each with whether they
|
|
8
|
+
* already have. The key value is write-only across this boundary and is never returned.
|
|
9
|
+
*/
|
|
10
|
+
export default async function accountAiKeysHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
11
|
+
if (!allowApiMethods(req, res, ['GET'])) return;
|
|
12
|
+
if (!getServerRuntimeConfig().ai.enabled) {
|
|
13
|
+
res.status(404).end();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user' });
|
|
17
|
+
if (!context?.user) return;
|
|
18
|
+
|
|
19
|
+
res.status(200).json({ connections: await describeAiUserConnections(context.user.id) });
|
|
20
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createPasswordReset,
|
|
4
|
+
getRegistryUserById,
|
|
5
|
+
resendUserInvitation,
|
|
6
|
+
setRegistryUserStatus,
|
|
7
|
+
updateRegistryUserRole,
|
|
8
|
+
type RegistryUser,
|
|
9
|
+
type RegistryUserRole,
|
|
10
|
+
} from '@handoff/registry/auth';
|
|
3
11
|
import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../../../lib/auth/api';
|
|
4
12
|
import { registryEmailIsConfigured, sendRegistryAuthEmail } from '../../../../../lib/auth/email';
|
|
5
13
|
|
|
@@ -10,6 +18,32 @@ const mutationError = (reason: string): { status: number; error: string } => {
|
|
|
10
18
|
return { status: 400, error: 'The requested user change is not valid.' };
|
|
11
19
|
};
|
|
12
20
|
|
|
21
|
+
/** Email the link when email is configured; otherwise return it once for the administrator to deliver. */
|
|
22
|
+
const deliverLink = async (
|
|
23
|
+
res: NextApiResponse,
|
|
24
|
+
user: RegistryUser,
|
|
25
|
+
link: { url: string; subject: string; heading: string; message: string; actionLabel: string },
|
|
26
|
+
messages: { sent: string; failed: string; manual: string }
|
|
27
|
+
) => {
|
|
28
|
+
if (!registryEmailIsConfigured()) {
|
|
29
|
+
res.status(200).json({ user, message: messages.manual, activationUrl: link.url });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const delivered = await sendRegistryAuthEmail({
|
|
33
|
+
to: user.email,
|
|
34
|
+
subject: link.subject,
|
|
35
|
+
heading: link.heading,
|
|
36
|
+
message: link.message,
|
|
37
|
+
actionLabel: link.actionLabel,
|
|
38
|
+
actionUrl: link.url,
|
|
39
|
+
});
|
|
40
|
+
if (!delivered) {
|
|
41
|
+
res.status(502).json({ error: messages.failed });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
res.status(200).json({ user, message: messages.sent });
|
|
45
|
+
};
|
|
46
|
+
|
|
13
47
|
export default async function userActionHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
14
48
|
const method = allowApiMethods(req, res, ['POST']);
|
|
15
49
|
if (!method) return;
|
|
@@ -65,23 +99,58 @@ export default async function userActionHandler(req: NextApiRequest, res: NextAp
|
|
|
65
99
|
res.status(500).json({ error: 'AUTH_URL is not configured.' });
|
|
66
100
|
return;
|
|
67
101
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
102
|
+
await deliverLink(
|
|
103
|
+
res,
|
|
104
|
+
result.user,
|
|
105
|
+
{
|
|
106
|
+
url: activationUrl,
|
|
71
107
|
subject: 'Your Handoff Registry invitation',
|
|
72
108
|
heading: 'You are invited',
|
|
73
109
|
message: 'Set a password to activate your Handoff Registry account.',
|
|
74
110
|
actionLabel: 'Accept invitation',
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
sent: 'Invitation resent.',
|
|
114
|
+
failed: 'The invitation was renewed, but email delivery failed.',
|
|
115
|
+
manual: 'Invitation renewed for manual delivery.',
|
|
80
116
|
}
|
|
81
|
-
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (action === 'reset') {
|
|
122
|
+
const user = await getRegistryUserById(context.db, userId);
|
|
123
|
+
if (!user) {
|
|
124
|
+
res.status(404).json({ error: 'User not found.' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const result = await createPasswordReset(context.db, user.email);
|
|
128
|
+
if (!result.token || !result.user) {
|
|
129
|
+
res.status(409).json({ error: 'Only active users can reset their password.' });
|
|
82
130
|
return;
|
|
83
131
|
}
|
|
84
|
-
|
|
132
|
+
const resetUrl = registryPageUrl('/reset-password', undefined, { token: result.token });
|
|
133
|
+
if (!resetUrl) {
|
|
134
|
+
res.status(500).json({ error: 'AUTH_URL is not configured.' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await deliverLink(
|
|
138
|
+
res,
|
|
139
|
+
result.user,
|
|
140
|
+
{
|
|
141
|
+
url: resetUrl,
|
|
142
|
+
subject: 'Reset your Handoff Registry password',
|
|
143
|
+
heading: 'Reset your password',
|
|
144
|
+
message:
|
|
145
|
+
'An administrator started a password reset for your account. Use this single-use link to choose a new password. It expires in one hour.',
|
|
146
|
+
actionLabel: 'Reset password',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
sent: 'Password reset link sent.',
|
|
150
|
+
failed: 'The reset link was created, but email delivery failed.',
|
|
151
|
+
manual: 'Password reset link created for manual delivery. It expires in one hour.',
|
|
152
|
+
}
|
|
153
|
+
);
|
|
85
154
|
return;
|
|
86
155
|
}
|
|
87
156
|
|
|
@@ -10,7 +10,7 @@ export default async function usersHandler(req: NextApiRequest, res: NextApiResp
|
|
|
10
10
|
if (!context?.user) return;
|
|
11
11
|
|
|
12
12
|
if (method === 'GET') {
|
|
13
|
-
res.status(200).json({ users: await listRegistryUsers(context.db) });
|
|
13
|
+
res.status(200).json({ users: await listRegistryUsers(context.db), emailConfigured: registryEmailIsConfigured() });
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -43,7 +43,7 @@ export default async function usersHandler(req: NextApiRequest, res: NextApiResp
|
|
|
43
43
|
actionUrl: activationUrl,
|
|
44
44
|
});
|
|
45
45
|
if (!delivered) {
|
|
46
|
-
res.status(502).json({ error: 'The invitation was created, but email delivery failed. Fix
|
|
46
|
+
res.status(502).json({ error: 'The invitation was created, but email delivery failed. Fix the email configuration and resend it.' });
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
res.status(201).json({ user: result.user, message: 'Invitation sent.' });
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
convertToModelMessages,
|
|
3
|
+
createUIMessageStream,
|
|
4
|
+
pipeUIMessageStreamToResponse,
|
|
5
|
+
stepCountIs,
|
|
6
|
+
streamText,
|
|
7
|
+
toUIMessageStream,
|
|
8
|
+
type UIMessage,
|
|
9
|
+
} from 'ai';
|
|
10
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
11
|
+
import { allowApiMethods } from '@/lib/api/methods';
|
|
12
|
+
import { authorizeAiRequest } from '@/lib/ai/auth';
|
|
13
|
+
import { withPageContext } from '@/lib/ai/context';
|
|
14
|
+
import { AiConnectionError, createAiLanguageModel } from '@/lib/ai/model';
|
|
15
|
+
import { DOCS_ASSISTANT_PROMPT } from '@/lib/ai/prompt';
|
|
16
|
+
import { describeAiConnections, findAiConnection, selectAiModel } from '@/lib/ai/resolve';
|
|
17
|
+
import { createAiToolSession, type AiSource } from '@/lib/ai/tools';
|
|
18
|
+
import { resolveDocsBackend } from '@/lib/docs-api';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `/api/ai/chat`: the docs assistant's agent loop, streamed to the browser.
|
|
22
|
+
*
|
|
23
|
+
* The agent reads through the MCP tools in process (see `lib/ai/tools`), so it answers from the same
|
|
24
|
+
* records `/api/mcp/` returns and a tool added there needs no work here.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How many model turns one question may take. A constant rather than config: a loop with no stop
|
|
29
|
+
* condition can call tools without end, so this is a liveness control, not a spend control. Spend
|
|
30
|
+
* belongs to the gateway, which enforces budgets and rate limits per key.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_AGENT_STEPS = 12;
|
|
33
|
+
|
|
34
|
+
const readMessages = (body: unknown): UIMessage[] => {
|
|
35
|
+
const messages = (body as { messages?: unknown })?.messages;
|
|
36
|
+
return Array.isArray(messages) ? (messages as UIMessage[]) : [];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default async function aiChatHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
40
|
+
if (!allowApiMethods(req, res, ['POST'])) return;
|
|
41
|
+
const reader = await authorizeAiRequest(req, res, true);
|
|
42
|
+
if (!reader) return;
|
|
43
|
+
|
|
44
|
+
const messages = readMessages(req.body);
|
|
45
|
+
if (messages.length === 0) {
|
|
46
|
+
res.status(400).json({ error: 'Ask a question to start.' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const requested = typeof (req.body as { model?: unknown })?.model === 'string' ? (req.body as { model: string }).model : undefined;
|
|
51
|
+
const selection = selectAiModel(await describeAiConnections(reader.userId), requested);
|
|
52
|
+
if (!selection) {
|
|
53
|
+
res.status(409).json({ error: 'No AI provider is available for this account.', code: 'no_usable_connection' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const connection = findAiConnection(selection.connection.id);
|
|
58
|
+
if (!connection) {
|
|
59
|
+
res.status(409).json({ error: 'No AI provider is available for this account.', code: 'no_usable_connection' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let model;
|
|
64
|
+
try {
|
|
65
|
+
model = await createAiLanguageModel(connection, selection.model, reader.userId);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof AiConnectionError) {
|
|
68
|
+
res.status(409).json({ error: error.message, code: 'connection_unavailable' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
console.error('AI model could not be built.', error);
|
|
72
|
+
res.status(500).json({ error: 'The AI provider could not be reached.' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stream = createUIMessageStream({
|
|
77
|
+
execute: async ({ writer }) => {
|
|
78
|
+
// Sources are written as the tools return them, so a link appears as soon as the agent has
|
|
79
|
+
// read the page it came from. Deduplicated because one question often reads a page twice.
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
const writeSources = (sources: AiSource[]) => {
|
|
82
|
+
for (const source of sources) {
|
|
83
|
+
if (seen.has(source.url)) continue;
|
|
84
|
+
seen.add(source.url);
|
|
85
|
+
writer.write({ type: 'source-url', sourceId: source.url, url: source.url, title: source.title });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const session = await createAiToolSession(writeSources);
|
|
90
|
+
// Torn down with the response, the way `/api/mcp/` does it: the merged stream outlives this
|
|
91
|
+
// function, and a reader who closes the modal mid-answer must not leave the session open.
|
|
92
|
+
res.on('close', () => void session.close());
|
|
93
|
+
|
|
94
|
+
// A question never fails for want of context.
|
|
95
|
+
const labeled = await resolveDocsBackend()
|
|
96
|
+
.then((backend) => withPageContext(messages, backend))
|
|
97
|
+
.catch(() => messages);
|
|
98
|
+
|
|
99
|
+
const result = streamText({
|
|
100
|
+
model,
|
|
101
|
+
system: DOCS_ASSISTANT_PROMPT,
|
|
102
|
+
messages: await convertToModelMessages(labeled),
|
|
103
|
+
tools: session.tools,
|
|
104
|
+
stopWhen: stepCountIs(MAX_AGENT_STEPS),
|
|
105
|
+
});
|
|
106
|
+
writer.merge(toUIMessageStream({ stream: result.fullStream, tools: session.tools }));
|
|
107
|
+
},
|
|
108
|
+
onError: (error) => {
|
|
109
|
+
console.error('AI chat request failed.', error);
|
|
110
|
+
return 'The AI provider could not complete this answer.';
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await pipeUIMessageStreamToResponse({ response: res, stream });
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { authorizeAiRequest } from '@/lib/ai/auth';
|
|
3
|
+
import { describeAiConnections } from '@/lib/ai/resolve';
|
|
4
|
+
import { getServerRuntimeConfig } from '@/lib/docs-api/runtime-config';
|
|
5
|
+
import { allowApiMethods } from '@/lib/api/methods';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/api/ai/connections`: the models this reader can actually run right now, for the assistant's
|
|
9
|
+
* model picker. Never carries a key, only whether one is in place.
|
|
10
|
+
*/
|
|
11
|
+
export default async function aiConnectionsHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
12
|
+
if (!allowApiMethods(req, res, ['GET'])) return;
|
|
13
|
+
const reader = await authorizeAiRequest(req, res);
|
|
14
|
+
if (!reader) return;
|
|
15
|
+
|
|
16
|
+
const connections = await describeAiConnections(reader.userId);
|
|
17
|
+
res.status(200).json({
|
|
18
|
+
connections,
|
|
19
|
+
defaultModel: getServerRuntimeConfig().ai.defaultModel ?? null,
|
|
20
|
+
/** Whether this deployment has a page where a reader can add their own key. */
|
|
21
|
+
canAddKeys: getServerRuntimeConfig().mode === 'registry' && connections.some((connection) => connection.credential === 'user'),
|
|
22
|
+
});
|
|
23
|
+
}
|