@pylonsync/client 0.3.267
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/package.json +32 -0
- package/src/components/AcceptInvite.tsx +160 -0
- package/src/components/ChatBot.tsx +228 -0
- package/src/components/ConnectAccount.tsx +119 -0
- package/src/components/EnsureGuest.tsx +49 -0
- package/src/components/EntityForm.tsx +308 -0
- package/src/components/EntityList.tsx +203 -0
- package/src/components/FileUpload.tsx +213 -0
- package/src/components/Gates.tsx +139 -0
- package/src/components/InviteMembers.tsx +562 -0
- package/src/components/OrganizationSwitcher.tsx +417 -0
- package/src/components/PasswordReset.tsx +302 -0
- package/src/components/SignIn.tsx +515 -0
- package/src/components/SignOutButton.tsx +42 -0
- package/src/components/UserButton.tsx +163 -0
- package/src/components/UserProfile.tsx +485 -0
- package/src/hooks/useAuth.ts +27 -0
- package/src/index.ts +130 -0
- package/src/lib/api.ts +368 -0
- package/src/lib/cn.ts +7 -0
- package/src/router/Router.tsx +282 -0
- package/src/router/context.ts +25 -0
- package/src/router/match.ts +106 -0
- package/src/router/useRouter.ts +40 -0
- package/src/theme.css +30 -0
- package/tsconfig.json +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @pylonsync/client
|
|
2
|
+
|
|
3
|
+
Drop-in React components for Pylon auth. Clerk-style API, wired to the
|
|
4
|
+
existing `/api/auth/*` surface — magic link, password, and OAuth all
|
|
5
|
+
work with zero config.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @pylonsync/client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { init } from "@pylonsync/react";
|
|
13
|
+
import {
|
|
14
|
+
SignIn,
|
|
15
|
+
SignedIn,
|
|
16
|
+
SignedOut,
|
|
17
|
+
UserButton,
|
|
18
|
+
} from "@pylonsync/client";
|
|
19
|
+
import "@pylonsync/client/theme.css"; // optional default theme
|
|
20
|
+
|
|
21
|
+
init(); // once at app boot
|
|
22
|
+
|
|
23
|
+
export function App() {
|
|
24
|
+
return (
|
|
25
|
+
<header>
|
|
26
|
+
<SignedIn>
|
|
27
|
+
<UserButton showName afterSignOutUrl="/" />
|
|
28
|
+
</SignedIn>
|
|
29
|
+
<SignedOut>
|
|
30
|
+
<SignIn afterSignInUrl="/dashboard" />
|
|
31
|
+
</SignedOut>
|
|
32
|
+
</header>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Components
|
|
38
|
+
|
|
39
|
+
**Auth surfaces**
|
|
40
|
+
- `<SignIn />` — magic link + password + OAuth, two-step code flow.
|
|
41
|
+
- `<SignUp />` — password registration with email validation.
|
|
42
|
+
- `<ForgotPassword />` — request a reset link.
|
|
43
|
+
- `<ResetPassword token={...} />` — landing page for the reset email.
|
|
44
|
+
- `<UserButton />` — avatar dropdown with sign-out.
|
|
45
|
+
- `<SignOutButton />` — bare sign-out trigger.
|
|
46
|
+
- `<UserProfile />` — account management: identity, password, sessions, API keys.
|
|
47
|
+
|
|
48
|
+
**Org surfaces**
|
|
49
|
+
- `<OrganizationSwitcher />` — list + switch + inline create.
|
|
50
|
+
- `<CreateOrganization />` — standalone create form.
|
|
51
|
+
- `<InviteMembers />` — invite by email, pending list, role mgmt.
|
|
52
|
+
- `<AcceptInvite token={...} />` — landing page for invite emails.
|
|
53
|
+
|
|
54
|
+
**Integrations**
|
|
55
|
+
- `<ConnectAccount name="slack" />` — start OAuth for a `defineConnection(...)`.
|
|
56
|
+
- `<ChatBot fn="chat" />` — streaming UI over a server-side `ctx.llm` handler.
|
|
57
|
+
|
|
58
|
+
**Files**
|
|
59
|
+
- `<FileUpload />` — drag-and-drop, multi-file, server-side via `/api/files/upload`.
|
|
60
|
+
|
|
61
|
+
**Data scaffolds**
|
|
62
|
+
- `<EntityList entity columns where orderBy />` — reactive table.
|
|
63
|
+
- `<EntityForm entity fields row onSubmitted />` — create + edit form.
|
|
64
|
+
|
|
65
|
+
**Control components**
|
|
66
|
+
- `<SignedIn>` / `<SignedOut>` — auth render-gates.
|
|
67
|
+
- `<InOrg>` / `<NoOrg>` — active-org render-gates.
|
|
68
|
+
- `<HasRole role="owner">` — role render-gate (single or array).
|
|
69
|
+
- `<Protect admin>` / `<Protect predicate={...}>` — predicate gate.
|
|
70
|
+
- `<RedirectToSignIn signInUrl="/sign-in" />` — client redirect.
|
|
71
|
+
|
|
72
|
+
**Routing**
|
|
73
|
+
- `<Router routes={...} />` — SPA router with nested layouts via `<Outlet />`.
|
|
74
|
+
- `<Link href />` — SPA link (intercepts left-click, lets cmd-click through).
|
|
75
|
+
- `useRouter()` / `useParams()` / `useSearchParams()` / `usePathname()`.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<Router routes={[
|
|
79
|
+
{ path: "/", component: Home },
|
|
80
|
+
{
|
|
81
|
+
path: "/app",
|
|
82
|
+
component: AppLayout, // contains an <Outlet />
|
|
83
|
+
requireAuth: true,
|
|
84
|
+
children: [
|
|
85
|
+
{ path: "/", component: Dashboard },
|
|
86
|
+
{ path: "/posts/:id", component: PostDetail },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
]} />
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Hooks**
|
|
93
|
+
- `useAuth()` — `{isSignedIn, userId, tenantId, isAdmin, session, signOut, ...}`.
|
|
94
|
+
|
|
95
|
+
## Orgs
|
|
96
|
+
|
|
97
|
+
Pylon ships org/membership endpoints (`/api/auth/orgs`) when your
|
|
98
|
+
manifest declares `Org` and `OrgMember` entities. `<OrganizationSwitcher />`
|
|
99
|
+
reads + writes through those endpoints — no app-side glue needed.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
<OrganizationSwitcher
|
|
103
|
+
onSwitched={(orgId) => router.push("/")}
|
|
104
|
+
onCreated={(org) => analytics.track("org_created", org)}
|
|
105
|
+
/>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Theming
|
|
109
|
+
|
|
110
|
+
All styling references CSS variables namespaced under `--pylon-*`. Import
|
|
111
|
+
`@pylonsync/client/theme.css` for sensible light/dark defaults, or define
|
|
112
|
+
the variables yourself.
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
:root {
|
|
116
|
+
--pylon-ink: #1d4ed8;
|
|
117
|
+
--pylon-paper: #f8fafc;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## OAuth providers
|
|
122
|
+
|
|
123
|
+
`<SignIn />` queries `/api/auth/providers` and renders a button per
|
|
124
|
+
enabled provider. Configure providers via env vars on the Pylon process
|
|
125
|
+
(e.g. `PYLON_OAUTH_GOOGLE_CLIENT_ID` / `PYLON_OAUTH_GOOGLE_CLIENT_SECRET`).
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pylonsync/client",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.3.267",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./theme.css": "./src/theme.css"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json --noEmit",
|
|
19
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@pylonsync/react": "0.3.267",
|
|
23
|
+
"@pylonsync/sync": "0.3.267",
|
|
24
|
+
"clsx": "^2.1.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^19.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
5
|
+
import { useAuth } from "../hooks/useAuth";
|
|
6
|
+
import { acceptInvite, ApiError } from "../lib/api";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
export interface AcceptInviteProps {
|
|
10
|
+
/**
|
|
11
|
+
* The invite token. Pulled from the email link's `?token=` param
|
|
12
|
+
* by the consuming page. Falls back to `window.location.search`
|
|
13
|
+
* when omitted.
|
|
14
|
+
*/
|
|
15
|
+
token?: string;
|
|
16
|
+
/** Where to send the user after a successful accept. */
|
|
17
|
+
afterAcceptUrl?: string;
|
|
18
|
+
/** Where to send a signed-out user so they can sign in / sign up first. */
|
|
19
|
+
signInUrl?: string;
|
|
20
|
+
/** Optional callback once accept lands. Receives `{org_id, role}`. */
|
|
21
|
+
onAccepted?: (result: { org_id: string; role: string }) => void;
|
|
22
|
+
/** Override the headline copy. */
|
|
23
|
+
title?: ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type State =
|
|
28
|
+
| { kind: "idle" }
|
|
29
|
+
| { kind: "accepting" }
|
|
30
|
+
| { kind: "accepted"; orgId: string; role: string }
|
|
31
|
+
| { kind: "error"; message: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drop-in landing surface for `/accept-invite?token=…`. Reads the token
|
|
35
|
+
* from props or the URL, calls `POST /api/auth/invites/:token/accept`,
|
|
36
|
+
* and lights up org membership when the request succeeds.
|
|
37
|
+
*
|
|
38
|
+
* Signed-out users see a "sign in to accept" prompt that bounces through
|
|
39
|
+
* `signInUrl` with the current href as `?next=` so they end up back on
|
|
40
|
+
* this page with the token preserved.
|
|
41
|
+
*/
|
|
42
|
+
export function AcceptInvite({
|
|
43
|
+
token: tokenProp,
|
|
44
|
+
afterAcceptUrl,
|
|
45
|
+
signInUrl = "/sign-in",
|
|
46
|
+
onAccepted,
|
|
47
|
+
title = "Accept invite",
|
|
48
|
+
className,
|
|
49
|
+
}: AcceptInviteProps) {
|
|
50
|
+
const { isSignedIn, refresh } = useAuth();
|
|
51
|
+
const [state, setState] = useState<State>({ kind: "idle" });
|
|
52
|
+
|
|
53
|
+
const token =
|
|
54
|
+
tokenProp ??
|
|
55
|
+
(typeof window !== "undefined"
|
|
56
|
+
? new URLSearchParams(window.location.search).get("token")
|
|
57
|
+
: null) ??
|
|
58
|
+
undefined;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!token || !isSignedIn) return;
|
|
62
|
+
// Auto-accept once we have both a token and a session. Saves the
|
|
63
|
+
// "click accept" step that doesn't add any safety — the user is
|
|
64
|
+
// already on the URL they were emailed.
|
|
65
|
+
setState({ kind: "accepting" });
|
|
66
|
+
(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const result = await acceptInvite(token);
|
|
69
|
+
setState({ kind: "accepted", orgId: result.org_id, role: result.role });
|
|
70
|
+
onAccepted?.(result);
|
|
71
|
+
// Refresh the cached session so the new tenantId / role is
|
|
72
|
+
// visible in useAuth() immediately. Without this the user
|
|
73
|
+
// would need to reload to see the org switcher pick up the
|
|
74
|
+
// membership.
|
|
75
|
+
void refresh();
|
|
76
|
+
if (afterAcceptUrl && typeof window !== "undefined") {
|
|
77
|
+
window.location.assign(afterAcceptUrl);
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setState({ kind: "error", message: messageFromError(err) });
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
}, [token, isSignedIn, afterAcceptUrl, onAccepted, refresh]);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"mx-auto w-full max-w-sm space-y-4 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-7 text-center shadow-sm",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<h2 className="text-lg font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
93
|
+
{title}
|
|
94
|
+
</h2>
|
|
95
|
+
{!token ? (
|
|
96
|
+
<p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
97
|
+
This page needs an invite token. Check the email link you
|
|
98
|
+
were sent.
|
|
99
|
+
</p>
|
|
100
|
+
) : !isSignedIn ? (
|
|
101
|
+
<SignInPrompt signInUrl={signInUrl} />
|
|
102
|
+
) : state.kind === "idle" || state.kind === "accepting" ? (
|
|
103
|
+
<p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
104
|
+
Joining…
|
|
105
|
+
</p>
|
|
106
|
+
) : state.kind === "accepted" ? (
|
|
107
|
+
<p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
108
|
+
You're in. Welcome to the team.
|
|
109
|
+
</p>
|
|
110
|
+
) : (
|
|
111
|
+
<p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
|
|
112
|
+
{state.message}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function SignInPrompt({ signInUrl }: { signInUrl: string }) {
|
|
120
|
+
const next =
|
|
121
|
+
typeof window !== "undefined" && window.location?.href
|
|
122
|
+
? `?next=${encodeURIComponent(window.location.href)}`
|
|
123
|
+
: "";
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
127
|
+
Sign in to accept your invite. We'll bring you right back.
|
|
128
|
+
</p>
|
|
129
|
+
<a
|
|
130
|
+
href={`${signInUrl}${next}`}
|
|
131
|
+
className="inline-flex items-center justify-center rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-4 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)]"
|
|
132
|
+
>
|
|
133
|
+
Sign in
|
|
134
|
+
</a>
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function messageFromError(err: unknown): string {
|
|
140
|
+
if (err instanceof ApiError) {
|
|
141
|
+
switch (err.code) {
|
|
142
|
+
case "INVITE_NOT_FOUND":
|
|
143
|
+
return "This invite link is no longer valid.";
|
|
144
|
+
case "INVITE_EXPIRED":
|
|
145
|
+
return "This invite has expired. Ask for a fresh one.";
|
|
146
|
+
case "ALREADY_ACCEPTED":
|
|
147
|
+
return "You've already accepted this invite.";
|
|
148
|
+
case "WRONG_EMAIL":
|
|
149
|
+
return "This invite was for a different email. Sign in with the account that received it.";
|
|
150
|
+
case "ALREADY_MEMBER":
|
|
151
|
+
return "You're already a member of this org.";
|
|
152
|
+
case "NO_EMAIL":
|
|
153
|
+
return "Your account has no email on file — add one and try again.";
|
|
154
|
+
default:
|
|
155
|
+
return err.message;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (err instanceof Error) return err.message;
|
|
159
|
+
return "Something went wrong accepting the invite.";
|
|
160
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type FormEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { db } from "@pylonsync/react";
|
|
13
|
+
import { cn } from "../lib/cn";
|
|
14
|
+
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
role: "user" | "assistant" | "system";
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChatBotProps {
|
|
21
|
+
/**
|
|
22
|
+
* Name of the server-side function that drives the model. It runs
|
|
23
|
+
* `ctx.llm.*` and emits its response as SSE chunks via
|
|
24
|
+
* `db.streamFn`. The handler receives `{ messages, ...extra }`.
|
|
25
|
+
*/
|
|
26
|
+
fn: string;
|
|
27
|
+
/** Extra args merged into every call (e.g., `{model: "haiku"}`). */
|
|
28
|
+
extraArgs?: Record<string, unknown>;
|
|
29
|
+
/** System prompt prepended to every turn. */
|
|
30
|
+
systemPrompt?: string;
|
|
31
|
+
/** Initial messages to display (e.g., a welcome from the assistant). */
|
|
32
|
+
initialMessages?: ChatMessage[];
|
|
33
|
+
/** Placeholder shown in the input. */
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
/** Header copy. */
|
|
36
|
+
title?: ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Drop-in chat UI for a server-side LLM handler. Wires the assistant
|
|
42
|
+
* turn to `db.streamFn(fn, { messages })`; each yielded chunk is parsed
|
|
43
|
+
* as a JSON event of the shape `{type: "text", delta: string}` (the
|
|
44
|
+
* default emitted by the framework's streaming helpers). Plain string
|
|
45
|
+
* chunks are also accepted so this works with apps that just yield
|
|
46
|
+
* `event.delta` directly.
|
|
47
|
+
*
|
|
48
|
+
* Server-side authoring (5 lines):
|
|
49
|
+
* ```ts
|
|
50
|
+
* export default action({
|
|
51
|
+
* args: { messages: v.array(v.object({ role: v.string(), content: v.string() })) },
|
|
52
|
+
* async handler(ctx, { messages }) {
|
|
53
|
+
* return ctx.llm.streamMessage({ model: "claude-haiku", messages });
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function ChatBot({
|
|
59
|
+
fn,
|
|
60
|
+
extraArgs,
|
|
61
|
+
systemPrompt,
|
|
62
|
+
initialMessages,
|
|
63
|
+
placeholder = "Ask anything…",
|
|
64
|
+
title = "Assistant",
|
|
65
|
+
className,
|
|
66
|
+
}: ChatBotProps) {
|
|
67
|
+
const [messages, setMessages] = useState<ChatMessage[]>(
|
|
68
|
+
initialMessages ?? [],
|
|
69
|
+
);
|
|
70
|
+
const [draft, setDraft] = useState("");
|
|
71
|
+
const [streaming, setStreaming] = useState(false);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
74
|
+
|
|
75
|
+
// Keep the latest message visible without yanking scroll if the user
|
|
76
|
+
// has manually scrolled up. Simple heuristic: only auto-scroll when
|
|
77
|
+
// the user is already at the bottom.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const el = scrollerRef.current;
|
|
80
|
+
if (!el) return;
|
|
81
|
+
const nearBottom =
|
|
82
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
83
|
+
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
84
|
+
}, [messages]);
|
|
85
|
+
|
|
86
|
+
const send = useCallback(
|
|
87
|
+
async (e?: FormEvent) => {
|
|
88
|
+
e?.preventDefault();
|
|
89
|
+
const text = draft.trim();
|
|
90
|
+
if (!text || streaming) return;
|
|
91
|
+
setError(null);
|
|
92
|
+
|
|
93
|
+
const userTurn: ChatMessage = { role: "user", content: text };
|
|
94
|
+
const turns: ChatMessage[] = systemPrompt
|
|
95
|
+
? [{ role: "system", content: systemPrompt }, ...messages, userTurn]
|
|
96
|
+
: [...messages, userTurn];
|
|
97
|
+
|
|
98
|
+
setMessages((prev) => [
|
|
99
|
+
...prev,
|
|
100
|
+
userTurn,
|
|
101
|
+
{ role: "assistant", content: "" },
|
|
102
|
+
]);
|
|
103
|
+
setDraft("");
|
|
104
|
+
setStreaming(true);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const args = { messages: turns, ...(extraArgs ?? {}) };
|
|
108
|
+
let acc = "";
|
|
109
|
+
for await (const chunk of db.streamFn(fn, args)) {
|
|
110
|
+
const delta = extractDelta(chunk);
|
|
111
|
+
if (!delta) continue;
|
|
112
|
+
acc += delta;
|
|
113
|
+
setMessages((prev) => {
|
|
114
|
+
const next = prev.slice();
|
|
115
|
+
const idx = next.length - 1;
|
|
116
|
+
if (idx >= 0 && next[idx]?.role === "assistant") {
|
|
117
|
+
next[idx] = { role: "assistant", content: acc };
|
|
118
|
+
}
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setError(err instanceof Error ? err.message : "Stream failed.");
|
|
124
|
+
setMessages((prev) => prev.slice(0, -1));
|
|
125
|
+
} finally {
|
|
126
|
+
setStreaming(false);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[draft, extraArgs, fn, messages, streaming, systemPrompt],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
133
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
void send();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
"mx-auto flex h-[480px] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] shadow-sm",
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<div className="border-b border-[var(--pylon-rule,#e5e7eb)] px-4 py-2.5">
|
|
147
|
+
<p className="text-sm font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
148
|
+
{title}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
<div
|
|
152
|
+
ref={scrollerRef}
|
|
153
|
+
className="flex-1 space-y-3 overflow-y-auto px-4 py-4"
|
|
154
|
+
>
|
|
155
|
+
{messages.length === 0 ? (
|
|
156
|
+
<p className="text-center text-xs text-[var(--pylon-ink-3,#71717a)]">
|
|
157
|
+
{placeholder}
|
|
158
|
+
</p>
|
|
159
|
+
) : null}
|
|
160
|
+
{messages.map((m, i) => (
|
|
161
|
+
<MessageBubble key={i} message={m} />
|
|
162
|
+
))}
|
|
163
|
+
{error ? (
|
|
164
|
+
<p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
|
|
165
|
+
{error}
|
|
166
|
+
</p>
|
|
167
|
+
) : null}
|
|
168
|
+
</div>
|
|
169
|
+
<form
|
|
170
|
+
onSubmit={send}
|
|
171
|
+
className="flex items-end gap-2 border-t border-[var(--pylon-rule,#e5e7eb)] p-3"
|
|
172
|
+
>
|
|
173
|
+
<textarea
|
|
174
|
+
value={draft}
|
|
175
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
176
|
+
onKeyDown={onKeyDown}
|
|
177
|
+
placeholder={placeholder}
|
|
178
|
+
rows={1}
|
|
179
|
+
className="flex-1 resize-none rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
|
|
180
|
+
/>
|
|
181
|
+
<button
|
|
182
|
+
type="submit"
|
|
183
|
+
disabled={streaming || !draft.trim()}
|
|
184
|
+
className="rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
185
|
+
>
|
|
186
|
+
{streaming ? "…" : "Send"}
|
|
187
|
+
</button>
|
|
188
|
+
</form>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function MessageBubble({ message }: { message: ChatMessage }) {
|
|
194
|
+
const isUser = message.role === "user";
|
|
195
|
+
return (
|
|
196
|
+
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
|
197
|
+
<div
|
|
198
|
+
className={cn(
|
|
199
|
+
"max-w-[80%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm",
|
|
200
|
+
isUser
|
|
201
|
+
? "bg-[var(--pylon-ink,#0a0a0a)] text-[var(--pylon-paper,#ffffff)]"
|
|
202
|
+
: "bg-[var(--pylon-paper-2,#f4f4f5)] text-[var(--pylon-ink,#0a0a0a)]",
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{message.content || (
|
|
206
|
+
<span className="text-[var(--pylon-ink-3,#71717a)]">…</span>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Normalise streamed chunks. The framework's streaming helpers emit
|
|
215
|
+
* `{type: "text", delta: string}` JSON; apps writing raw streams may
|
|
216
|
+
* just yield strings. Anything else is ignored — defensive parsing
|
|
217
|
+
* keeps the UI from blowing up on tool-call events the chatbot doesn't
|
|
218
|
+
* render yet.
|
|
219
|
+
*/
|
|
220
|
+
function extractDelta(chunk: unknown): string {
|
|
221
|
+
if (typeof chunk === "string") return chunk;
|
|
222
|
+
if (chunk && typeof chunk === "object") {
|
|
223
|
+
const obj = chunk as { type?: string; delta?: unknown; text?: unknown };
|
|
224
|
+
if (typeof obj.delta === "string") return obj.delta;
|
|
225
|
+
if (obj.type === "text" && typeof obj.text === "string") return obj.text;
|
|
226
|
+
}
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, useState } from "react";
|
|
5
|
+
import { useAuth } from "../hooks/useAuth";
|
|
6
|
+
import { ApiError, connectionAuthUrl } from "../lib/api";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
export interface ConnectAccountProps {
|
|
10
|
+
/**
|
|
11
|
+
* Name of the connection in the manifest (matches the `name` you
|
|
12
|
+
* passed to `defineConnection({...})`). NOT the OAuth provider id.
|
|
13
|
+
*/
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* URL the OAuth provider sends the user back to after consent.
|
|
17
|
+
* Defaults to the current page so the user lands on the same screen
|
|
18
|
+
* with the new connection already in place.
|
|
19
|
+
*/
|
|
20
|
+
postRedirect?: string;
|
|
21
|
+
/** Optional label override. Default: "Connect <name>". */
|
|
22
|
+
label?: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* If true, render an "outline" style button instead of the solid
|
|
25
|
+
* primary. Useful when the connect-button sits next to a primary
|
|
26
|
+
* CTA.
|
|
27
|
+
*/
|
|
28
|
+
variant?: "primary" | "outline";
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop-in button that starts an OAuth flow for a connection defined via
|
|
34
|
+
* `defineConnection(...)` in the manifest. Hits
|
|
35
|
+
* `POST /api/connections/<name>/auth-url` and navigates the browser to
|
|
36
|
+
* the returned URL; the provider redirects back to
|
|
37
|
+
* `/api/connections/<name>/callback`, which stores the encrypted token
|
|
38
|
+
* row and bounces to `postRedirect` (default: current page).
|
|
39
|
+
*
|
|
40
|
+
* Renders nothing for signed-out users — connections need an
|
|
41
|
+
* authenticated identity to bind the access token to.
|
|
42
|
+
*/
|
|
43
|
+
export function ConnectAccount({
|
|
44
|
+
name,
|
|
45
|
+
postRedirect,
|
|
46
|
+
label,
|
|
47
|
+
variant = "primary",
|
|
48
|
+
className,
|
|
49
|
+
}: ConnectAccountProps) {
|
|
50
|
+
const { isSignedIn } = useAuth();
|
|
51
|
+
const [pending, setPending] = useState(false);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
if (!isSignedIn) return null;
|
|
55
|
+
|
|
56
|
+
async function onClick() {
|
|
57
|
+
setError(null);
|
|
58
|
+
setPending(true);
|
|
59
|
+
try {
|
|
60
|
+
const redirect =
|
|
61
|
+
postRedirect ??
|
|
62
|
+
(typeof window !== "undefined" ? window.location.href : undefined);
|
|
63
|
+
const { url } = await connectionAuthUrl(name, redirect);
|
|
64
|
+
if (typeof window !== "undefined") {
|
|
65
|
+
window.location.assign(url);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(messageFromError(err));
|
|
69
|
+
setPending(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<span className={cn("inline-flex flex-col gap-1.5", className)}>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={onClick}
|
|
78
|
+
disabled={pending}
|
|
79
|
+
className={cn(
|
|
80
|
+
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors disabled:opacity-60",
|
|
81
|
+
variant === "primary"
|
|
82
|
+
? "bg-[var(--pylon-ink,#0a0a0a)] text-[var(--pylon-paper,#ffffff)] hover:opacity-90"
|
|
83
|
+
: "border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] text-[var(--pylon-ink,#0a0a0a)] hover:bg-[var(--pylon-paper-2,#f4f4f5)]",
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{pending ? "…" : (label ?? `Connect ${capitalize(name)}`)}
|
|
87
|
+
</button>
|
|
88
|
+
{error ? (
|
|
89
|
+
<span className="text-[11px] text-[var(--pylon-error-ink,#b91c1c)]">
|
|
90
|
+
{error}
|
|
91
|
+
</span>
|
|
92
|
+
) : null}
|
|
93
|
+
</span>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function capitalize(s: string): string {
|
|
98
|
+
if (!s) return "";
|
|
99
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function messageFromError(err: unknown): string {
|
|
103
|
+
if (err instanceof ApiError) {
|
|
104
|
+
switch (err.code) {
|
|
105
|
+
case "CONNECTION_UNKNOWN":
|
|
106
|
+
return "This connection isn't defined in the app manifest.";
|
|
107
|
+
case "PROVIDER_NOT_CONFIGURED":
|
|
108
|
+
return "Connection provider isn't configured on the server.";
|
|
109
|
+
case "ENCRYPTION_REQUIRED":
|
|
110
|
+
return "Connection storage needs PYLON_ENCRYPTION_KEY set.";
|
|
111
|
+
case "CONNECTIONS_NOT_CONFIGURED":
|
|
112
|
+
return "No connections are defined in this app yet.";
|
|
113
|
+
default:
|
|
114
|
+
return err.message;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (err instanceof Error) return err.message;
|
|
118
|
+
return "Couldn't start the connection flow.";
|
|
119
|
+
}
|