Restore Ultracite + fix sidebar (#1233)

This commit is contained in:
Hayden Bleasel 2025-09-21 11:02:31 -07:00 committed by GitHub
parent 8fbfc253fa
commit 947ed094a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
177 changed files with 6908 additions and 8306 deletions

333
.cursor/rules/ultracite.mdc Normal file
View file

@ -0,0 +1,333 @@
---
description: Ultracite Rules - AI-Ready Formatter and Linter
globs: "**/*.{ts,tsx,js,jsx}"
alwaysApply: true
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use __dirname and __filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
- Don't use the next/head module in pages/_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite fix` - Format and fix code automatically
- `npx ultracite check` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```

28
.vscode/settings.json vendored
View file

@ -1,17 +1,35 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.workingDirectories": [
{ "pattern": "app/*" },
{ "pattern": "packages/*" }
]
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}

View file

@ -1,84 +1,84 @@
'use server';
"use server";
import { z } from 'zod';
import { z } from "zod";
import { createUser, getUser } from '@/lib/db/queries';
import { createUser, getUser } from "@/lib/db/queries";
import { signIn } from './auth';
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export interface LoginActionState {
status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data';
}
export type LoginActionState = {
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
};
export const login = async (
_: LoginActionState,
formData: FormData,
formData: FormData
): Promise<LoginActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get('email'),
password: formData.get('password'),
email: formData.get("email"),
password: formData.get("password"),
});
await signIn('credentials', {
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: 'success' };
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: 'invalid_data' };
return { status: "invalid_data" };
}
return { status: 'failed' };
return { status: "failed" };
}
};
export interface RegisterActionState {
export type RegisterActionState = {
status:
| 'idle'
| 'in_progress'
| 'success'
| 'failed'
| 'user_exists'
| 'invalid_data';
}
| "idle"
| "in_progress"
| "success"
| "failed"
| "user_exists"
| "invalid_data";
};
export const register = async (
_: RegisterActionState,
formData: FormData,
formData: FormData
): Promise<RegisterActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get('email'),
password: formData.get('password'),
email: formData.get("email"),
password: formData.get("password"),
});
const [user] = await getUser(validatedData.email);
if (user) {
return { status: 'user_exists' } as RegisterActionState;
return { status: "user_exists" } as RegisterActionState;
}
await createUser(validatedData.email, validatedData.password);
await signIn('credentials', {
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: 'success' };
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: 'invalid_data' };
return { status: "invalid_data" };
}
return { status: 'failed' };
return { status: "failed" };
}
};

View file

@ -1 +1,2 @@
export { GET, POST } from '@/app/(auth)/auth';
// biome-ignore lint/performance/noBarrelFile: "Required"
export { GET, POST } from "@/app/(auth)/auth";

View file

@ -1,11 +1,11 @@
import { signIn } from '@/app/(auth)/auth';
import { isDevelopmentEnvironment } from '@/lib/constants';
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { signIn } from "@/app/(auth)/auth";
import { isDevelopmentEnvironment } from "@/lib/constants";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const redirectUrl = searchParams.get('redirectUrl') || '/';
const redirectUrl = searchParams.get("redirectUrl") || "/";
const token = await getToken({
req: request,
@ -14,8 +14,8 @@ export async function GET(request: Request) {
});
if (token) {
return NextResponse.redirect(new URL('/', request.url));
return NextResponse.redirect(new URL("/", request.url));
}
return signIn('guest', { redirect: true, redirectTo: redirectUrl });
return signIn("guest", { redirect: true, redirectTo: redirectUrl });
}

View file

@ -1,9 +1,9 @@
import type { NextAuthConfig } from 'next-auth';
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: {
signIn: '/login',
newUser: '/',
signIn: "/login",
newUser: "/",
},
providers: [
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js

View file

@ -1,21 +1,22 @@
import { compare } from 'bcrypt-ts';
import NextAuth, { type DefaultSession } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { createGuestUser, getUser } from '@/lib/db/queries';
import { authConfig } from './auth.config';
import { DUMMY_PASSWORD } from '@/lib/constants';
import type { DefaultJWT } from 'next-auth/jwt';
import { compare } from "bcrypt-ts";
import NextAuth, { type DefaultSession } from "next-auth";
import type { DefaultJWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import { DUMMY_PASSWORD } from "@/lib/constants";
import { createGuestUser, getUser } from "@/lib/db/queries";
import { authConfig } from "./auth.config";
export type UserType = 'guest' | 'regular';
export type UserType = "guest" | "regular";
declare module 'next-auth' {
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
type: UserType;
} & DefaultSession['user'];
} & DefaultSession["user"];
}
// biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required"
interface User {
id?: string;
email?: string | null;
@ -23,7 +24,7 @@ declare module 'next-auth' {
}
}
declare module 'next-auth/jwt' {
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
id: string;
type: UserType;
@ -57,22 +58,24 @@ export const {
const passwordsMatch = await compare(password, user.password);
if (!passwordsMatch) return null;
if (!passwordsMatch) {
return null;
}
return { ...user, type: 'regular' };
return { ...user, type: "regular" };
},
}),
Credentials({
id: 'guest',
id: "guest",
credentials: {},
async authorize() {
const [guestUser] = await createGuestUser();
return { ...guestUser, type: 'guest' };
return { ...guestUser, type: "guest" };
},
}),
],
callbacks: {
async jwt({ token, user }) {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
token.type = user.type;
@ -80,7 +83,7 @@ export const {
return token;
},
async session({ session, token }) {
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type;

View file

@ -1,52 +1,51 @@
'use client';
"use client";
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { toast } from '@/components/toast';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button';
import { login, type LoginActionState } from '../actions';
import { useSession } from 'next-auth/react';
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type LoginActionState, login } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState('');
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: 'idle',
},
status: "idle",
}
);
const { update: updateSession } = useSession();
useEffect(() => {
if (state.status === 'failed') {
if (state.status === "failed") {
toast({
type: 'error',
description: 'Invalid credentials!',
type: "error",
description: "Invalid credentials!",
});
} else if (state.status === 'invalid_data') {
} else if (state.status === "invalid_data") {
toast({
type: 'error',
description: 'Failed validating your submission!',
type: "error",
description: "Failed validating your submission!",
});
} else if (state.status === 'success') {
} else if (state.status === "success") {
setIsSuccessful(true);
updateSession();
router.refresh();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status]);
}, [state.status, router.refresh, updateSession]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get('email') as string);
setEmail(formData.get("email") as string);
formAction(formData);
};
@ -64,12 +63,12 @@ export default function Page() {
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{"Don't have an account? "}
<Link
href="/register"
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/register"
>
Sign up
</Link>
{' for free.'}
{" for free."}
</p>
</AuthForm>
</div>

View file

@ -1,53 +1,51 @@
'use client';
"use client";
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button';
import { register, type RegisterActionState } from '../actions';
import { toast } from '@/components/toast';
import { useSession } from 'next-auth/react';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type RegisterActionState, register } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState('');
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<RegisterActionState, FormData>(
register,
{
status: 'idle',
},
status: "idle",
}
);
const { update: updateSession } = useSession();
useEffect(() => {
if (state.status === 'user_exists') {
toast({ type: 'error', description: 'Account already exists!' });
} else if (state.status === 'failed') {
toast({ type: 'error', description: 'Failed to create account!' });
} else if (state.status === 'invalid_data') {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
} else if (state.status === "failed") {
toast({ type: "error", description: "Failed to create account!" });
} else if (state.status === "invalid_data") {
toast({
type: 'error',
description: 'Failed validating your submission!',
type: "error",
description: "Failed validating your submission!",
});
} else if (state.status === 'success') {
toast({ type: 'success', description: 'Account created successfully!' });
} else if (state.status === "success") {
toast({ type: "success", description: "Account created successfully!" });
setIsSuccessful(true);
updateSession();
router.refresh();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status]);
}, [state.status, router.refresh, updateSession]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get('email') as string);
setEmail(formData.get("email") as string);
formAction(formData);
};
@ -63,14 +61,14 @@ export default function Page() {
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{'Already have an account? '}
{"Already have an account? "}
<Link
href="/login"
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/login"
>
Sign in
</Link>
{' instead.'}
{" instead."}
</p>
</AuthForm>
</div>

View file

@ -1,18 +1,18 @@
'use server';
"use server";
import { generateText, type UIMessage } from 'ai';
import { cookies } from 'next/headers';
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { myProvider } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getMessageById,
updateChatVisiblityById,
} from '@/lib/db/queries';
import type { VisibilityType } from '@/components/visibility-selector';
import { myProvider } from '@/lib/ai/providers';
} from "@/lib/db/queries";
export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();
cookieStore.set('chat-model', model);
cookieStore.set("chat-model", model);
}
export async function generateTitleFromUserMessage({
@ -21,7 +21,7 @@ export async function generateTitleFromUserMessage({
message: UIMessage;
}) {
const { text: title } = await generateText({
model: myProvider.languageModel('title-model'),
model: myProvider.languageModel("title-model"),
system: `\n
- you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 80 characters long

View file

@ -1,19 +1,19 @@
import { auth } from '@/app/(auth)/auth';
import { createUIMessageStream, JsonToSseTransformStream } from "ai";
import { differenceInSeconds } from "date-fns";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getMessagesByChatId,
getStreamIdsByChatId,
} from '@/lib/db/queries';
import type { Chat } from '@/lib/db/schema';
import { ChatSDKError } from '@/lib/errors';
import type { ChatMessage } from '@/lib/types';
import { createUIMessageStream, JsonToSseTransformStream } from 'ai';
import { getStreamContext } from '../../route';
import { differenceInSeconds } from 'date-fns';
} from "@/lib/db/queries";
import type { Chat } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { getStreamContext } from "../../route";
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> },
{ params }: { params: Promise<{ id: string }> }
) {
const { id: chatId } = await params;
@ -25,13 +25,13 @@ export async function GET(
}
if (!chatId) {
return new ChatSDKError('bad_request:api').toResponse();
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:chat').toResponse();
return new ChatSDKError("unauthorized:chat").toResponse();
}
let chat: Chat | null;
@ -39,35 +39,36 @@ export async function GET(
try {
chat = await getChatById({ id: chatId });
} catch {
return new ChatSDKError('not_found:chat').toResponse();
return new ChatSDKError("not_found:chat").toResponse();
}
if (!chat) {
return new ChatSDKError('not_found:chat').toResponse();
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.visibility === 'private' && chat.userId !== session.user.id) {
return new ChatSDKError('forbidden:chat').toResponse();
if (chat.visibility === "private" && chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
const streamIds = await getStreamIdsByChatId({ chatId });
if (!streamIds.length) {
return new ChatSDKError('not_found:stream').toResponse();
return new ChatSDKError("not_found:stream").toResponse();
}
const recentStreamId = streamIds.at(-1);
if (!recentStreamId) {
return new ChatSDKError('not_found:stream').toResponse();
return new ChatSDKError("not_found:stream").toResponse();
}
const emptyDataStream = createUIMessageStream<ChatMessage>({
// biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist"
execute: () => {},
});
const stream = await streamContext.resumableStream(recentStreamId, () =>
emptyDataStream.pipeThrough(new JsonToSseTransformStream()),
emptyDataStream.pipeThrough(new JsonToSseTransformStream())
);
/*
@ -82,7 +83,7 @@ export async function GET(
return new Response(emptyDataStream, { status: 200 });
}
if (mostRecentMessage.role !== 'assistant') {
if (mostRecentMessage.role !== "assistant") {
return new Response(emptyDataStream, { status: 200 });
}
@ -95,7 +96,7 @@ export async function GET(
const restoredStream = createUIMessageStream<ChatMessage>({
execute: ({ writer }) => {
writer.write({
type: 'data-appendMessage',
type: "data-appendMessage",
data: JSON.stringify(mostRecentMessage),
transient: true,
});
@ -104,7 +105,7 @@ export async function GET(
return new Response(
restoredStream.pipeThrough(new JsonToSseTransformStream()),
{ status: 200 },
{ status: 200 }
);
}

View file

@ -1,3 +1,4 @@
import { geolocation } from "@vercel/functions";
import {
convertToModelMessages,
createUIMessageStream,
@ -5,9 +6,27 @@ import {
smoothStream,
stepCountIs,
streamText,
} from 'ai';
import { auth, type UserType } from '@/app/(auth)/auth';
import { type RequestHints, systemPrompt } from '@/lib/ai/prompts';
} from "ai";
import { unstable_cache as cache } from "next/cache";
import { after } from "next/server";
import {
createResumableStreamContext,
type ResumableStreamContext,
} from "resumable-stream";
import type { ModelCatalog } from "tokenlens/core";
import { fetchModels } from "tokenlens/fetch";
import { getUsage } from "tokenlens/helpers";
import { auth, type UserType } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/visibility-selector";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import type { ChatModel } from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
import { updateDocument } from "@/lib/ai/tools/update-document";
import { isProductionEnvironment } from "@/lib/constants";
import {
createStreamId,
deleteChatById,
@ -16,33 +35,14 @@ import {
getMessagesByChatId,
saveChat,
saveMessages,
} from '@/lib/db/queries';
import { updateChatLastContextById } from '@/lib/db/queries';
import { convertToUIMessages, generateUUID } from '@/lib/utils';
import { generateTitleFromUserMessage } from '../../actions';
import { createDocument } from '@/lib/ai/tools/create-document';
import { updateDocument } from '@/lib/ai/tools/update-document';
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
import { getWeather } from '@/lib/ai/tools/get-weather';
import { isProductionEnvironment } from '@/lib/constants';
import { myProvider } from '@/lib/ai/providers';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
import { postRequestBodySchema, type PostRequestBody } from './schema';
import { geolocation } from '@vercel/functions';
import {
createResumableStreamContext,
type ResumableStreamContext,
} from 'resumable-stream';
import { after } from 'next/server';
import { ChatSDKError } from '@/lib/errors';
import type { ChatMessage } from '@/lib/types';
import type { ChatModel } from '@/lib/ai/models';
import type { VisibilityType } from '@/components/visibility-selector';
import { unstable_cache as cache } from 'next/cache';
import { fetchModels } from 'tokenlens/fetch';
import { getUsage } from 'tokenlens/helpers';
import type { ModelCatalog } from 'tokenlens/core';
import type { AppUsage } from '@/lib/usage';
updateChatLastContextById,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
export const maxDuration = 60;
@ -54,14 +54,14 @@ const getTokenlensCatalog = cache(
return await fetchModels();
} catch (err) {
console.warn(
'TokenLens: catalog fetch failed, using default catalog',
err,
"TokenLens: catalog fetch failed, using default catalog",
err
);
return undefined; // tokenlens helpers will fall back to defaultCatalog
return; // tokenlens helpers will fall back to defaultCatalog
}
},
['tokenlens-catalog'],
{ revalidate: 24 * 60 * 60 }, // 24 hours
["tokenlens-catalog"],
{ revalidate: 24 * 60 * 60 } // 24 hours
);
export function getStreamContext() {
@ -71,9 +71,9 @@ export function getStreamContext() {
waitUntil: after,
});
} catch (error: any) {
if (error.message.includes('REDIS_URL')) {
if (error.message.includes("REDIS_URL")) {
console.log(
' > Resumable streams are disabled due to missing REDIS_URL',
" > Resumable streams are disabled due to missing REDIS_URL"
);
} else {
console.error(error);
@ -91,7 +91,7 @@ export async function POST(request: Request) {
const json = await request.json();
requestBody = postRequestBodySchema.parse(json);
} catch (_) {
return new ChatSDKError('bad_request:api').toResponse();
return new ChatSDKError("bad_request:api").toResponse();
}
try {
@ -103,14 +103,14 @@ export async function POST(request: Request) {
}: {
id: string;
message: ChatMessage;
selectedChatModel: ChatModel['id'];
selectedChatModel: ChatModel["id"];
selectedVisibilityType: VisibilityType;
} = requestBody;
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:chat').toResponse();
return new ChatSDKError("unauthorized:chat").toResponse();
}
const userType: UserType = session.user.type;
@ -121,12 +121,16 @@ export async function POST(request: Request) {
});
if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
return new ChatSDKError('rate_limit:chat').toResponse();
return new ChatSDKError("rate_limit:chat").toResponse();
}
const chat = await getChatById({ id });
if (!chat) {
if (chat) {
if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
} else {
const title = await generateTitleFromUserMessage({
message,
});
@ -137,10 +141,6 @@ export async function POST(request: Request) {
title,
visibility: selectedVisibilityType,
});
} else {
if (chat.userId !== session.user.id) {
return new ChatSDKError('forbidden:chat').toResponse();
}
}
const messagesFromDb = await getMessagesByChatId({ id });
@ -160,7 +160,7 @@ export async function POST(request: Request) {
{
chatId: id,
id: message.id,
role: 'user',
role: "user",
parts: message.parts,
attachments: [],
createdAt: new Date(),
@ -181,15 +181,15 @@ export async function POST(request: Request) {
messages: convertToModelMessages(uiMessages),
stopWhen: stepCountIs(5),
experimental_activeTools:
selectedChatModel === 'chat-model-reasoning'
selectedChatModel === "chat-model-reasoning"
? []
: [
'getWeather',
'createDocument',
'updateDocument',
'requestSuggestions',
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
],
experimental_transform: smoothStream({ chunking: 'word' }),
experimental_transform: smoothStream({ chunking: "word" }),
tools: {
getWeather,
createDocument: createDocument({ session, dataStream }),
@ -201,7 +201,7 @@ export async function POST(request: Request) {
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
functionId: 'stream-text',
functionId: "stream-text",
},
onFinish: async ({ usage }) => {
try {
@ -210,23 +210,29 @@ export async function POST(request: Request) {
myProvider.languageModel(selectedChatModel).modelId;
if (!modelId) {
finalMergedUsage = usage;
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
if (!providers) {
finalMergedUsage = usage;
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
const summary = getUsage({ modelId, usage, providers });
finalMergedUsage = { ...usage, ...summary, modelId } as AppUsage;
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
dataStream.write({ type: "data-usage", data: finalMergedUsage });
} catch (err) {
console.warn('TokenLens enrichment failed', err);
console.warn("TokenLens enrichment failed", err);
finalMergedUsage = usage;
dataStream.write({ type: 'data-usage', data: finalMergedUsage });
dataStream.write({ type: "data-usage", data: finalMergedUsage });
}
},
});
@ -236,16 +242,16 @@ export async function POST(request: Request) {
dataStream.merge(
result.toUIMessageStream({
sendReasoning: true,
}),
})
);
},
generateId: generateUUID,
onFinish: async ({ messages }) => {
await saveMessages({
messages: messages.map((message) => ({
id: message.id,
role: message.role,
parts: message.parts,
messages: messages.map((currentMessage) => ({
id: currentMessage.id,
role: currentMessage.role,
parts: currentMessage.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
@ -259,12 +265,12 @@ export async function POST(request: Request) {
context: finalMergedUsage,
});
} catch (err) {
console.warn('Unable to persist last usage for chat', id, err);
console.warn("Unable to persist last usage for chat", id, err);
}
}
},
onError: () => {
return 'Oops, an error occurred!';
return "Oops, an error occurred!";
},
});
@ -273,12 +279,12 @@ export async function POST(request: Request) {
if (streamContext) {
return new Response(
await streamContext.resumableStream(streamId, () =>
stream.pipeThrough(new JsonToSseTransformStream()),
),
stream.pipeThrough(new JsonToSseTransformStream())
)
);
} else {
return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
}
return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
@ -288,35 +294,35 @@ export async function POST(request: Request) {
if (
error instanceof Error &&
error.message?.includes(
'AI Gateway requires a valid credit card on file to service requests',
"AI Gateway requires a valid credit card on file to service requests"
)
) {
return new ChatSDKError('bad_request:activate_gateway').toResponse();
return new ChatSDKError("bad_request:activate_gateway").toResponse();
}
console.error('Unhandled error in chat API:', error);
return new ChatSDKError('offline:chat').toResponse();
console.error("Unhandled error in chat API:", error);
return new ChatSDKError("offline:chat").toResponse();
}
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError('bad_request:api').toResponse();
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:chat').toResponse();
return new ChatSDKError("unauthorized:chat").toResponse();
}
const chat = await getChatById({ id });
if (chat?.userId !== session.user.id) {
return new ChatSDKError('forbidden:chat').toResponse();
return new ChatSDKError("forbidden:chat").toResponse();
}
const deletedChat = await deleteChatById({ id });

View file

@ -1,13 +1,13 @@
import { z } from 'zod';
import { z } from "zod";
const textPartSchema = z.object({
type: z.enum(['text']),
type: z.enum(["text"]),
text: z.string().min(1).max(2000),
});
const filePartSchema = z.object({
type: z.enum(['file']),
mediaType: z.enum(['image/jpeg', 'image/png']),
type: z.enum(["file"]),
mediaType: z.enum(["image/jpeg", "image/png"]),
name: z.string().min(1).max(100),
url: z.string().url(),
});
@ -18,11 +18,11 @@ export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: z.object({
id: z.string().uuid(),
role: z.enum(['user']),
role: z.enum(["user"]),
parts: z.array(partSchema),
}),
selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']),
selectedVisibilityType: z.enum(['public', 'private']),
selectedChatModel: z.enum(["chat-model", "chat-model-reasoning"]),
selectedVisibilityType: z.enum(["public", "private"]),
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View file

@ -1,27 +1,27 @@
import { auth } from '@/app/(auth)/auth';
import type { ArtifactKind } from '@/components/artifact';
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/artifact";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
} from '@/lib/db/queries';
import { ChatSDKError } from '@/lib/errors';
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
'bad_request:api',
'Parameter id is missing',
"bad_request:api",
"Parameter id is missing"
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:document').toResponse();
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
@ -29,11 +29,11 @@ export async function GET(request: Request) {
const [document] = documents;
if (!document) {
return new ChatSDKError('not_found:document').toResponse();
return new ChatSDKError("not_found:document").toResponse();
}
if (document.userId !== session.user.id) {
return new ChatSDKError('forbidden:document').toResponse();
return new ChatSDKError("forbidden:document").toResponse();
}
return Response.json(documents, { status: 200 });
@ -41,19 +41,19 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
'bad_request:api',
'Parameter id is required.',
"bad_request:api",
"Parameter id is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('not_found:document').toResponse();
return new ChatSDKError("not_found:document").toResponse();
}
const {
@ -66,10 +66,10 @@ export async function POST(request: Request) {
const documents = await getDocumentsById({ id });
if (documents.length > 0) {
const [document] = documents;
const [doc] = documents;
if (document.userId !== session.user.id) {
return new ChatSDKError('forbidden:document').toResponse();
if (doc.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
}
@ -86,27 +86,27 @@ export async function POST(request: Request) {
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const timestamp = searchParams.get('timestamp');
const id = searchParams.get("id");
const timestamp = searchParams.get("timestamp");
if (!id) {
return new ChatSDKError(
'bad_request:api',
'Parameter id is required.',
"bad_request:api",
"Parameter id is required."
).toResponse();
}
if (!timestamp) {
return new ChatSDKError(
'bad_request:api',
'Parameter timestamp is required.',
"bad_request:api",
"Parameter timestamp is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:document').toResponse();
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
@ -114,7 +114,7 @@ export async function DELETE(request: Request) {
const [document] = documents;
if (document.userId !== session.user.id) {
return new ChatSDKError('forbidden:document').toResponse();
return new ChatSDKError("forbidden:document").toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({

View file

@ -1,19 +1,19 @@
import { put } from '@vercel/blob';
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from '@/app/(auth)/auth';
import { auth } from "@/app/(auth)/auth";
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: 'File size should be less than 5MB',
message: "File size should be less than 5MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ['image/jpeg', 'image/png'].includes(file.type), {
message: 'File type should be JPEG or PNG',
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
message: "File type should be JPEG or PNG",
}),
});
@ -21,19 +21,19 @@ export async function POST(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
return new Response('Request body is empty', { status: 400 });
return new Response("Request body is empty", { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get('file') as Blob;
const file = formData.get("file") as Blob;
if (!file) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const validatedFile = FileSchema.safeParse({ file });
@ -41,28 +41,28 @@ export async function POST(request: Request) {
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
.join(', ');
.join(", ");
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get('file') as File).name;
const filename = (formData.get("file") as File).name;
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${filename}`, fileBuffer, {
access: 'public',
access: "public",
});
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
} catch (_error) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
} catch (error) {
} catch (_error) {
return NextResponse.json(
{ error: 'Failed to process request' },
{ status: 500 },
{ error: "Failed to process request" },
{ status: 500 }
);
}
}

View file

@ -1,26 +1,26 @@
import { auth } from '@/app/(auth)/auth';
import type { NextRequest } from 'next/server';
import { getChatsByUserId } from '@/lib/db/queries';
import { ChatSDKError } from '@/lib/errors';
import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getChatsByUserId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = Number.parseInt(searchParams.get('limit') || '10');
const startingAfter = searchParams.get('starting_after');
const endingBefore = searchParams.get('ending_before');
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
const startingAfter = searchParams.get("starting_after");
const endingBefore = searchParams.get("ending_before");
if (startingAfter && endingBefore) {
return new ChatSDKError(
'bad_request:api',
'Only one of starting_after or ending_before can be provided.',
"bad_request:api",
"Only one of starting_after or ending_before can be provided."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:chat').toResponse();
return new ChatSDKError("unauthorized:chat").toResponse();
}
const chats = await getChatsByUserId({

View file

@ -1,22 +1,22 @@
import { auth } from '@/app/(auth)/auth';
import { getSuggestionsByDocumentId } from '@/lib/db/queries';
import { ChatSDKError } from '@/lib/errors';
import { auth } from "@/app/(auth)/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const documentId = searchParams.get('documentId');
const documentId = searchParams.get("documentId");
if (!documentId) {
return new ChatSDKError(
'bad_request:api',
'Parameter documentId is required.',
"bad_request:api",
"Parameter documentId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:suggestions').toResponse();
return new ChatSDKError("unauthorized:suggestions").toResponse();
}
const suggestions = await getSuggestionsByDocumentId({
@ -30,7 +30,7 @@ export async function GET(request: Request) {
}
if (suggestion.userId !== session.user.id) {
return new ChatSDKError('forbidden:api').toResponse();
return new ChatSDKError("forbidden:api").toResponse();
}
return Response.json(suggestions, { status: 200 });

View file

@ -1,32 +1,32 @@
import { auth } from '@/app/(auth)/auth';
import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries';
import { ChatSDKError } from '@/lib/errors';
import { auth } from "@/app/(auth)/auth";
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get('chatId');
const chatId = searchParams.get("chatId");
if (!chatId) {
return new ChatSDKError(
'bad_request:api',
'Parameter chatId is required.',
"bad_request:api",
"Parameter chatId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:vote').toResponse();
return new ChatSDKError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatSDKError('not_found:chat').toResponse();
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatSDKError('forbidden:vote').toResponse();
return new ChatSDKError("forbidden:vote").toResponse();
}
const votes = await getVotesByChatId({ id: chatId });
@ -39,37 +39,37 @@ export async function PATCH(request: Request) {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: 'up' | 'down' } =
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
if (!chatId || !messageId || !type) {
return new ChatSDKError(
'bad_request:api',
'Parameters chatId, messageId, and type are required.',
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError('unauthorized:vote').toResponse();
return new ChatSDKError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatSDKError('not_found:vote').toResponse();
return new ChatSDKError("not_found:vote").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatSDKError('forbidden:vote').toResponse();
return new ChatSDKError("forbidden:vote").toResponse();
}
await voteMessage({
chatId,
messageId,
type: type,
type,
});
return new Response('Message voted', { status: 200 });
return new Response("Message voted", { status: 200 });
}

View file

@ -1,12 +1,12 @@
import { cookies } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { auth } from '@/app/(auth)/auth';
import { Chat } from '@/components/chat';
import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
import { DataStreamHandler } from '@/components/data-stream-handler';
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
import { convertToUIMessages } from '@/lib/utils';
import { auth } from "@/app/(auth)/auth";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
@ -20,10 +20,10 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) {
redirect('/api/auth/guest');
redirect("/api/auth/guest");
}
if (chat.visibility === 'private') {
if (chat.visibility === "private") {
if (!session.user) {
return notFound();
}
@ -40,20 +40,19 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
const uiMessages = convertToUIMessages(messagesFromDb);
const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get('chat-model');
const chatModelFromCookie = cookieStore.get("chat-model");
if (!chatModelFromCookie) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialMessages={uiMessages}
initialChatModel={DEFAULT_CHAT_MODEL}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
session={session}
autoResume={true}
initialLastContext={chat.lastContext ?? undefined}
/>
<DataStreamHandler />
</>
@ -63,14 +62,13 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialMessages={uiMessages}
initialChatModel={chatModelFromCookie.value}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
session={session}
autoResume={true}
initialLastContext={chat.lastContext ?? undefined}
/>
<DataStreamHandler />
</>

View file

@ -1,10 +1,9 @@
import { cookies } from 'next/headers';
import { AppSidebar } from '@/components/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { auth } from '../(auth)/auth';
import Script from 'next/script';
import { DataStreamProvider } from '@/components/data-stream-provider';
import { cookies } from "next/headers";
import Script from "next/script";
import { AppSidebar } from "@/components/app-sidebar";
import { DataStreamProvider } from "@/components/data-stream-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { auth } from "../(auth)/auth";
export const experimental_ppr = true;
@ -14,7 +13,7 @@ export default async function Layout({
children: React.ReactNode;
}) {
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true';
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
return (
<>

View file

@ -1,36 +1,34 @@
import { cookies } from 'next/headers';
import { Chat } from '@/components/chat';
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
import { generateUUID } from '@/lib/utils';
import { DataStreamHandler } from '@/components/data-stream-handler';
import { auth } from '../(auth)/auth';
import { redirect } from 'next/navigation';
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { generateUUID } from "@/lib/utils";
import { auth } from "../(auth)/auth";
export default async function Page() {
const session = await auth();
if (!session) {
redirect('/api/auth/guest');
redirect("/api/auth/guest");
}
const id = generateUUID();
const cookieStore = await cookies();
const modelIdFromCookie = cookieStore.get('chat-model');
const modelIdFromCookie = cookieStore.get("chat-model");
if (!modelIdFromCookie) {
return (
<>
<Chat
key={id}
autoResume={false}
id={id}
initialMessages={[]}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
session={session}
autoResume={false}
key={id}
/>
<DataStreamHandler />
</>
@ -40,14 +38,13 @@ export default async function Page() {
return (
<>
<Chat
key={id}
autoResume={false}
id={id}
initialMessages={[]}
initialChatModel={modelIdFromCookie.value}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
session={session}
autoResume={false}
key={id}
/>
<DataStreamHandler />
</>

View file

@ -1,5 +1,4 @@
/* set base path to root directory to include utility classes from app, components, pages, etc. */
@import "tailwindcss" source("..");
@import "tailwindcss";
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
@ -47,6 +46,7 @@
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* border radius unit */
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
}
/* define design tokens (dark mode) */
@ -83,6 +83,7 @@
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
/* define theme */
@ -294,3 +295,23 @@
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@theme inline {
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,15 +1,15 @@
import { Toaster } from 'sonner';
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider';
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import './globals.css';
import { SessionProvider } from 'next-auth/react';
import "./globals.css";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL('https://chat.vercel.ai'),
title: 'Next.js Chatbot Template',
description: 'Next.js chatbot template using the AI SDK.',
metadataBase: new URL("https://chat.vercel.ai"),
title: "Next.js Chatbot Template",
description: "Next.js chatbot template using the AI SDK.",
};
export const viewport = {
@ -17,19 +17,19 @@ export const viewport = {
};
const geist = Geist({
subsets: ['latin'],
display: 'swap',
variable: '--font-geist',
subsets: ["latin"],
display: "swap",
variable: "--font-geist",
});
const geistMono = Geist_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-geist-mono',
subsets: ["latin"],
display: "swap",
variable: "--font-geist-mono",
});
const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)';
const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)';
const LIGHT_THEME_COLOR = "hsl(0 0% 100%)";
const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)";
const THEME_COLOR_SCRIPT = `\
(function() {
var html = document.documentElement;
@ -48,23 +48,24 @@ const THEME_COLOR_SCRIPT = `\
updateThemeColor();
})();`;
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geist.variable} ${geistMono.variable}`}
// `next-themes` injects an extra classname to the body element to avoid
// visual flicker before hydration. Hence the `suppressHydrationWarning`
// prop is necessary to avoid the React hydration mismatch warning.
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
lang="en"
suppressHydrationWarning
className={`${geist.variable} ${geistMono.variable}`}
>
<head>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
dangerouslySetInnerHTML={{
__html: THEME_COLOR_SCRIPT,
}}
@ -74,8 +75,8 @@ export default async function RootLayout({
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
enableSystem
>
<Toaster position="top-center" />
<SessionProvider>{children}</SessionProvider>

View file

@ -1,6 +1,6 @@
'use server';
"use server";
import { getSuggestionsByDocumentId } from '@/lib/db/queries';
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
export async function getSuggestions({ documentId }: { documentId: string }) {
const suggestions = await getSuggestionsByDocumentId({ documentId });

View file

@ -1,5 +1,11 @@
import { Artifact } from '@/components/create-artifact';
import { CodeEditor } from '@/components/code-editor';
import { toast } from "sonner";
import { CodeEditor } from "@/components/code-editor";
import {
Console,
type ConsoleOutput,
type ConsoleOutputContent,
} from "@/components/console";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LogsIcon,
@ -7,14 +13,8 @@ import {
PlayIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons';
import { toast } from 'sonner';
import { generateUUID } from '@/lib/utils';
import {
Console,
type ConsoleOutput,
type ConsoleOutputContent,
} from '@/components/console';
} from "@/components/icons";
import { generateUUID } from "@/lib/utils";
const OUTPUT_HANDLERS = {
matplotlib: `
@ -53,40 +53,40 @@ const OUTPUT_HANDLERS = {
};
function detectRequiredHandlers(code: string): string[] {
const handlers: string[] = ['basic'];
const handlers: string[] = ["basic"];
if (code.includes('matplotlib') || code.includes('plt.')) {
handlers.push('matplotlib');
if (code.includes("matplotlib") || code.includes("plt.")) {
handlers.push("matplotlib");
}
return handlers;
}
interface Metadata {
outputs: Array<ConsoleOutput>;
}
type Metadata = {
outputs: ConsoleOutput[];
};
export const codeArtifact = new Artifact<'code', Metadata>({
kind: 'code',
export const codeArtifact = new Artifact<"code", Metadata>({
kind: "code",
description:
'Useful for code generation; Code execution is only available for python code.',
initialize: async ({ setMetadata }) => {
"Useful for code generation; Code execution is only available for python code.",
initialize: ({ setMetadata }) => {
setMetadata({
outputs: [],
});
},
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === 'data-codeDelta') {
if (streamPart.type === "data-codeDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible:
draftArtifact.status === 'streaming' &&
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 300 &&
draftArtifact.content.length < 310
? true
: draftArtifact.isVisible,
status: 'streaming',
status: "streaming",
}));
}
},
@ -114,11 +114,11 @@ export const codeArtifact = new Artifact<'code', Metadata>({
actions: [
{
icon: <PlayIcon size={18} />,
label: 'Run',
description: 'Execute code',
label: "Run",
description: "Execute code",
onClick: async ({ content, setMetadata }) => {
const runId = generateUUID();
const outputContent: Array<ConsoleOutputContent> = [];
const outputContent: ConsoleOutputContent[] = [];
setMetadata((metadata) => ({
...metadata,
@ -127,7 +127,7 @@ export const codeArtifact = new Artifact<'code', Metadata>({
{
id: runId,
contents: [],
status: 'in_progress',
status: "in_progress",
},
],
}));
@ -135,15 +135,15 @@ export const codeArtifact = new Artifact<'code', Metadata>({
try {
// @ts-expect-error - loadPyodide is not defined
const currentPyodideInstance = await globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/',
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.23.4/full/",
});
currentPyodideInstance.setStdout({
batched: (output: string) => {
outputContent.push({
type: output.startsWith('data:image/png;base64')
? 'image'
: 'text',
type: output.startsWith("data:image/png;base64")
? "image"
: "text",
value: output,
});
},
@ -157,8 +157,8 @@ export const codeArtifact = new Artifact<'code', Metadata>({
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: 'text', value: message }],
status: 'loading_packages',
contents: [{ type: "text", value: message }],
status: "loading_packages",
},
],
}));
@ -169,12 +169,12 @@ export const codeArtifact = new Artifact<'code', Metadata>({
for (const handler of requiredHandlers) {
if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
await currentPyodideInstance.runPythonAsync(
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS],
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]
);
if (handler === 'matplotlib') {
if (handler === "matplotlib") {
await currentPyodideInstance.runPythonAsync(
'setup_matplotlib_output()',
"setup_matplotlib_output()"
);
}
}
@ -189,7 +189,7 @@ export const codeArtifact = new Artifact<'code', Metadata>({
{
id: runId,
contents: outputContent,
status: 'completed',
status: "completed",
},
],
}));
@ -200,8 +200,8 @@ export const codeArtifact = new Artifact<'code', Metadata>({
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: 'text', value: error.message }],
status: 'failed',
contents: [{ type: "text", value: error.message }],
status: "failed",
},
],
}));
@ -210,9 +210,9 @@ export const codeArtifact = new Artifact<'code', Metadata>({
},
{
icon: <UndoIcon size={18} />,
description: 'View Previous version',
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('prev');
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
@ -224,9 +224,9 @@ export const codeArtifact = new Artifact<'code', Metadata>({
},
{
icon: <RedoIcon size={18} />,
description: 'View Next version',
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('next');
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
@ -238,24 +238,24 @@ export const codeArtifact = new Artifact<'code', Metadata>({
},
{
icon: <CopyIcon size={18} />,
description: 'Copy code to clipboard',
description: "Copy code to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success('Copied to clipboard!');
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <MessageIcon />,
description: 'Add comments',
description: "Add comments",
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
text: 'Add comments to the code snippet for understanding',
type: "text",
text: "Add comments to the code snippet for understanding",
},
],
});
@ -263,14 +263,14 @@ export const codeArtifact = new Artifact<'code', Metadata>({
},
{
icon: <LogsIcon />,
description: 'Add logs',
description: "Add logs",
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
text: 'Add logs to the code snippet for debugging',
type: "text",
text: "Add logs to the code snippet for debugging",
},
],
});

View file

@ -1,16 +1,16 @@
import { z } from 'zod';
import { streamObject } from 'ai';
import { myProvider } from '@/lib/ai/providers';
import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { streamObject } from "ai";
import { z } from "zod";
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const codeDocumentHandler = createDocumentHandler<'code'>({
kind: 'code',
export const codeDocumentHandler = createDocumentHandler<"code">({
kind: "code",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamObject({
model: myProvider.languageModel('artifact-model'),
model: myProvider.languageModel("artifact-model"),
system: codePrompt,
prompt: title,
schema: z.object({
@ -21,14 +21,14 @@ export const codeDocumentHandler = createDocumentHandler<'code'>({
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'object') {
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: 'data-codeDelta',
data: code ?? '',
type: "data-codeDelta",
data: code ?? "",
transient: true,
});
@ -40,11 +40,11 @@ export const codeDocumentHandler = createDocumentHandler<'code'>({
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamObject({
model: myProvider.languageModel('artifact-model'),
system: updateDocumentPrompt(document.content, 'code'),
model: myProvider.languageModel("artifact-model"),
system: updateDocumentPrompt(document.content, "code"),
prompt: description,
schema: z.object({
code: z.string(),
@ -54,14 +54,14 @@ export const codeDocumentHandler = createDocumentHandler<'code'>({
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'object') {
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: 'data-codeDelta',
data: code ?? '',
type: "data-codeDelta",
data: code ?? "",
transient: true,
});

View file

@ -1,18 +1,18 @@
import { Artifact } from '@/components/create-artifact';
import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons';
import { ImageEditor } from '@/components/image-editor';
import { toast } from 'sonner';
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
import { ImageEditor } from "@/components/image-editor";
export const imageArtifact = new Artifact({
kind: 'image',
description: 'Useful for image generation',
kind: "image",
description: "Useful for image generation",
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === 'data-imageDelta') {
if (streamPart.type === "data-imageDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: 'streaming',
status: "streaming",
}));
}
},
@ -20,9 +20,9 @@ export const imageArtifact = new Artifact({
actions: [
{
icon: <UndoIcon size={18} />,
description: 'View Previous version',
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('prev');
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
@ -34,9 +34,9 @@ export const imageArtifact = new Artifact({
},
{
icon: <RedoIcon size={18} />,
description: 'View Next version',
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('next');
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
@ -48,27 +48,27 @@ export const imageArtifact = new Artifact({
},
{
icon: <CopyIcon size={18} />,
description: 'Copy image to clipboard',
description: "Copy image to clipboard",
onClick: ({ content }) => {
const img = new Image();
img.src = `data:image/png;base64,${content}`;
img.onload = () => {
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
new ClipboardItem({ "image/png": blob }),
]);
}
}, 'image/png');
}, "image/png");
};
toast.success('Copied image to clipboard!');
toast.success("Copied image to clipboard!");
},
},
],

View file

@ -1,43 +1,37 @@
import { Artifact } from '@/components/create-artifact';
import { parse, unparse } from "papaparse";
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LineChartIcon,
RedoIcon,
SparklesIcon,
UndoIcon,
} from '@/components/icons';
import { SpreadsheetEditor } from '@/components/sheet-editor';
import { parse, unparse } from 'papaparse';
import { toast } from 'sonner';
} from "@/components/icons";
import { SpreadsheetEditor } from "@/components/sheet-editor";
type Metadata = any;
export const sheetArtifact = new Artifact<'sheet', Metadata>({
kind: 'sheet',
description: 'Useful for working with spreadsheets',
initialize: async () => {},
export const sheetArtifact = new Artifact<"sheet", Metadata>({
kind: "sheet",
description: "Useful for working with spreadsheets",
initialize: () => null,
onStreamPart: ({ setArtifact, streamPart }) => {
if (streamPart.type === 'data-sheetDelta') {
if (streamPart.type === "data-sheetDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: 'streaming',
status: "streaming",
}));
}
},
content: ({
content,
currentVersionIndex,
isCurrentVersion,
onSaveContent,
status,
}) => {
content: ({ content, currentVersionIndex, onSaveContent, status }) => {
return (
<SpreadsheetEditor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
isCurrentVersion={true}
saveContent={onSaveContent}
status={status}
/>
@ -46,9 +40,9 @@ export const sheetArtifact = new Artifact<'sheet', Metadata>({
actions: [
{
icon: <UndoIcon size={18} />,
description: 'View Previous version',
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('prev');
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
@ -60,9 +54,9 @@ export const sheetArtifact = new Artifact<'sheet', Metadata>({
},
{
icon: <RedoIcon size={18} />,
description: 'View Next version',
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('next');
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
@ -74,44 +68,44 @@ export const sheetArtifact = new Artifact<'sheet', Metadata>({
},
{
icon: <CopyIcon />,
description: 'Copy as .csv',
description: "Copy as .csv",
onClick: ({ content }) => {
const parsed = parse<string[]>(content, { skipEmptyLines: true });
const nonEmptyRows = parsed.data.filter((row) =>
row.some((cell) => cell.trim() !== ''),
row.some((cell) => cell.trim() !== "")
);
const cleanedCsv = unparse(nonEmptyRows);
navigator.clipboard.writeText(cleanedCsv);
toast.success('Copied csv to clipboard!');
toast.success("Copied csv to clipboard!");
},
},
],
toolbar: [
{
description: 'Format and clean data',
description: "Format and clean data",
icon: <SparklesIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{ type: 'text', text: 'Can you please format and clean the data?' },
{ type: "text", text: "Can you please format and clean the data?" },
],
});
},
},
{
description: 'Analyze and visualize data',
description: "Analyze and visualize data",
icon: <LineChartIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
text: 'Can you please analyze and visualize the data by creating a new code artifact in python?',
type: "text",
text: "Can you please analyze and visualize the data by creating a new code artifact in python?",
},
],
});

View file

@ -1,33 +1,33 @@
import { myProvider } from '@/lib/ai/providers';
import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { streamObject } from 'ai';
import { z } from 'zod';
import { streamObject } from "ai";
import { z } from "zod";
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
kind: 'sheet',
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
kind: "sheet",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamObject({
model: myProvider.languageModel('artifact-model'),
model: myProvider.languageModel("artifact-model"),
system: sheetPrompt,
prompt: title,
schema: z.object({
csv: z.string().describe('CSV data'),
csv: z.string().describe("CSV data"),
}),
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'object') {
if (type === "object") {
const { object } = delta;
const { csv } = object;
if (csv) {
dataStream.write({
type: 'data-sheetDelta',
type: "data-sheetDelta",
data: csv,
transient: true,
});
@ -38,7 +38,7 @@ export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
}
dataStream.write({
type: 'data-sheetDelta',
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
@ -46,11 +46,11 @@ export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamObject({
model: myProvider.languageModel('artifact-model'),
system: updateDocumentPrompt(document.content, 'sheet'),
model: myProvider.languageModel("artifact-model"),
system: updateDocumentPrompt(document.content, "sheet"),
prompt: description,
schema: z.object({
csv: z.string(),
@ -60,13 +60,13 @@ export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'object') {
if (type === "object") {
const { object } = delta;
const { csv } = object;
if (csv) {
dataStream.write({
type: 'data-sheetDelta',
type: "data-sheetDelta",
data: csv,
transient: true,
});

View file

@ -1,7 +1,7 @@
import { Artifact } from '@/components/create-artifact';
import { DiffView } from '@/components/diffview';
import { DocumentSkeleton } from '@/components/document-skeleton';
import { Editor } from '@/components/text-editor';
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { DiffView } from "@/components/diffview";
import { DocumentSkeleton } from "@/components/document-skeleton";
import {
ClockRewind,
CopyIcon,
@ -9,18 +9,18 @@ import {
PenIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons';
import type { Suggestion } from '@/lib/db/schema';
import { toast } from 'sonner';
import { getSuggestions } from '../actions';
} from "@/components/icons";
import { Editor } from "@/components/text-editor";
import type { Suggestion } from "@/lib/db/schema";
import { getSuggestions } from "../actions";
interface TextArtifactMetadata {
suggestions: Array<Suggestion>;
}
type TextArtifactMetadata = {
suggestions: Suggestion[];
};
export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
kind: 'text',
description: 'Useful for text content, like drafting essays and emails.',
export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
kind: "text",
description: "Useful for text content, like drafting essays and emails.",
initialize: async ({ documentId, setMetadata }) => {
const suggestions = await getSuggestions({ documentId });
@ -29,7 +29,7 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
});
},
onStreamPart: ({ streamPart, setMetadata, setArtifact }) => {
if (streamPart.type === 'data-suggestion') {
if (streamPart.type === "data-suggestion") {
setMetadata((metadata) => {
return {
suggestions: [...metadata.suggestions, streamPart.data],
@ -37,18 +37,18 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
});
}
if (streamPart.type === 'data-textDelta') {
if (streamPart.type === "data-textDelta") {
setArtifact((draftArtifact) => {
return {
...draftArtifact,
content: draftArtifact.content + streamPart.data,
isVisible:
draftArtifact.status === 'streaming' &&
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 400 &&
draftArtifact.content.length < 450
? true
: draftArtifact.isVisible,
status: 'streaming',
status: "streaming",
};
});
}
@ -68,40 +68,38 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
return <DocumentSkeleton artifactKind="text" />;
}
if (mode === 'diff') {
if (mode === "diff") {
const oldContent = getDocumentContentById(currentVersionIndex - 1);
const newContent = getDocumentContentById(currentVersionIndex);
return <DiffView oldContent={oldContent} newContent={newContent} />;
return <DiffView newContent={newContent} oldContent={oldContent} />;
}
return (
<>
<div className="flex flex-row px-4 py-8 md:p-20">
<Editor
content={content}
suggestions={metadata ? metadata.suggestions : []}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex}
status={status}
onSaveContent={onSaveContent}
/>
<div className="flex flex-row px-4 py-8 md:p-20">
<Editor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
onSaveContent={onSaveContent}
status={status}
suggestions={metadata ? metadata.suggestions : []}
/>
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
<div className="h-dvh w-12 shrink-0 md:hidden" />
) : null}
</div>
</>
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
<div className="h-dvh w-12 shrink-0 md:hidden" />
) : null}
</div>
);
},
actions: [
{
icon: <ClockRewind size={18} />,
description: 'View changes',
description: "View changes",
onClick: ({ handleVersionChange }) => {
handleVersionChange('toggle');
handleVersionChange("toggle");
},
isDisabled: ({ currentVersionIndex, setMetadata }) => {
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
@ -111,9 +109,9 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
},
{
icon: <UndoIcon size={18} />,
description: 'View Previous version',
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('prev');
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
@ -125,9 +123,9 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
},
{
icon: <RedoIcon size={18} />,
description: 'View Next version',
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange('next');
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
@ -139,24 +137,24 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
},
{
icon: <CopyIcon size={18} />,
description: 'Copy to clipboard',
description: "Copy to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success('Copied to clipboard!');
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <PenIcon />,
description: 'Add final polish',
description: "Add final polish",
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
text: 'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.',
type: "text",
text: "Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.",
},
],
});
@ -164,14 +162,14 @@ export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
},
{
icon: <MessageIcon />,
description: 'Request suggestions',
description: "Request suggestions",
onClick: ({ sendMessage }) => {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
text: 'Please add suggestions you have that could improve the writing.',
type: "text",
text: "Please add suggestions you have that could improve the writing.",
},
],
});

View file

@ -1,31 +1,31 @@
import { smoothStream, streamText } from 'ai';
import { myProvider } from '@/lib/ai/providers';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { updateDocumentPrompt } from '@/lib/ai/prompts';
import { smoothStream, streamText } from "ai";
import { updateDocumentPrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const textDocumentHandler = createDocumentHandler<'text'>({
kind: 'text',
export const textDocumentHandler = createDocumentHandler<"text">({
kind: "text",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamText({
model: myProvider.languageModel('artifact-model'),
model: myProvider.languageModel("artifact-model"),
system:
'Write about the given topic. Markdown is supported. Use headings wherever appropriate.',
experimental_transform: smoothStream({ chunking: 'word' }),
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
experimental_transform: smoothStream({ chunking: "word" }),
prompt: title,
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'text-delta') {
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: 'data-textDelta',
type: "data-textDelta",
data: text,
transient: true,
});
@ -35,17 +35,17 @@ export const textDocumentHandler = createDocumentHandler<'text'>({
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = '';
let draftContent = "";
const { fullStream } = streamText({
model: myProvider.languageModel('artifact-model'),
system: updateDocumentPrompt(document.content, 'text'),
experimental_transform: smoothStream({ chunking: 'word' }),
model: myProvider.languageModel("artifact-model"),
system: updateDocumentPrompt(document.content, "text"),
experimental_transform: smoothStream({ chunking: "word" }),
prompt: description,
providerOptions: {
openai: {
prediction: {
type: 'content',
type: "content",
content: document.content,
},
},
@ -55,13 +55,13 @@ export const textDocumentHandler = createDocumentHandler<'text'>({
for await (const delta of fullStream) {
const { type } = delta;
if (type === 'text-delta') {
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: 'data-textDelta',
type: "data-textDelta",
data: text,
transient: true,
});

View file

@ -1,136 +1,51 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite"],
"files": {
"ignoreUnknown": false,
"ignore": [
"**/pnpm-lock.yaml",
"lib/db/migrations",
"lib/editor/react-renderer.tsx",
"node_modules",
".next",
"public",
".vercel"
"includes": [
"**/*",
"!components/ui",
"!lib/utils.ts",
"!hooks/use-mobile.ts"
]
},
"vcs": {
"enabled": true,
"clientKind": "git",
"defaultBranch": "main",
"useIgnoreFile": true
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
"noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
"useValidAriaRole": {
"level": "warn",
"options": {
"ignoreNonDom": false,
"allowInvalidRoles": ["none", "text"]
}
},
"useSemanticElements": "off", // Rule is buggy, revisit later
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
"useMediaCaption": "off", // We would need a cultural change to turn this on
"noAutofocus": "off", // We're highly intentional about when we use autofocus
"noBlankTarget": "off", // Covered by Conformance
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
},
"complexity": {
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
"noForEach": "off", // forEach is too familiar to ban
"noUselessSwitchCase": "off", // Turned off due to developer preferences
"noUselessThisAlias": "off", // Turned off due to developer preferences
"noBannedTypes": "off"
},
"correctness": {
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
"useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
"noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
"useJsxKeyInIterable": "off", // Rule is buggy, revisit later
"useExhaustiveDependencies": "warn", // Errors by default, switching to warn instead
"noUnnecessaryContinue": "off" // Turned off due to developer preferences
},
"security": {
"noDangerouslySetInnerHtml": "off" // Covered by Conformance
"suspicious": {
/* Needs more work to fix */
"noExplicitAny": "off",
/* Allow for Tailwind @ rules */
"noUnknownAtRules": "off",
/* Allowing console for debugging */
"noConsole": "off",
/* Needed for generateUUID() */
"noBitwiseOperators": "off"
},
"style": {
"useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
"noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
"useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
"useExponentiationOperator": "off", // Obscure and arguably not easily readable
"noUnusedTemplateLiteral": "off", // Stylistic opinion
"noUselessElse": "off" // Stylistic opinion
},
"suspicious": {
"noExplicitAny": "off" // We trust Vercelians to use any only when necessary
/* Allowing magic numbers */
"noMagicNumbers": "off",
/* Needs more work to fix */
"noNestedTernary": "off"
},
"nursery": {
"noDocumentImportInPage": "warn",
"noDuplicateElseIf": "warn",
"noHeadImportInDocument": "warn",
"noIrregularWhitespace": "warn",
"noStaticElementInteractions": "warn",
"useSortedClasses": "error",
"useValidAutocomplete": "warn"
/* Too many false positives */
"noUnnecessaryConditions": "off"
},
"complexity": {
/* Needs more work to fix */
"noExcessiveCognitiveComplexity": "off",
/* This one has false positives. It's a bit... iffy 😉 */
"useSimplifiedLogicExpression": "off"
},
"a11y": {
/* Needs more work to fix */
"noSvgWithoutTitle": "off"
}
}
},
"javascript": {
"jsxRuntime": "reactClassic",
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto"
}
},
"json": {
"formatter": {
"enabled": true,
"trailingCommas": "none"
},
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
},
"css": {
"formatter": { "enabled": false },
"linter": { "enabled": false }
},
"organizeImports": { "enabled": false },
"overrides": [
// Playwright requires an object destructure, even if empty
// https://github.com/microsoft/playwright/issues/30007
{
"include": ["playwright/**"],
"linter": {
"rules": {
"correctness": {
"noEmptyPattern": "off"
}
}
}
}
]
}
}

View file

@ -1,12 +1,12 @@
'use client';
"use client";
import type { User } from 'next-auth';
import { useRouter } from 'next/navigation';
import { PlusIcon } from '@/components/icons';
import { SidebarHistory } from '@/components/sidebar-history';
import { SidebarUserNav } from '@/components/sidebar-user-nav';
import { Button } from '@/components/ui/button';
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { PlusIcon } from "@/components/icons";
import { SidebarHistory } from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
@ -14,9 +14,8 @@ import {
SidebarHeader,
SidebarMenu,
useSidebar,
} from '@/components/ui/sidebar';
import Link from 'next/link';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
} from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
@ -28,11 +27,11 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<SidebarMenu>
<div className="flex flex-row items-center justify-between">
<Link
className="flex flex-row items-center gap-3"
href="/"
onClick={() => {
setOpenMobile(false);
}}
className="flex flex-row items-center gap-3"
>
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
Chatbot
@ -41,14 +40,14 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
type="button"
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => {
setOpenMobile(false);
router.push('/');
router.push("/");
router.refresh();
}}
type="button"
variant="ghost"
>
<PlusIcon />
</Button>

View file

@ -1,20 +1,20 @@
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { artifactDefinitions, type UIArtifact } from './artifact';
import { type Dispatch, memo, type SetStateAction, useState } from 'react';
import type { ArtifactActionContext } from './create-artifact';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { type Dispatch, memo, type SetStateAction, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface ArtifactActionsProps {
type ArtifactActionsProps = {
artifact: UIArtifact;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: 'edit' | 'diff';
mode: "edit" | "diff";
metadata: any;
setMetadata: Dispatch<SetStateAction<any>>;
}
};
function PureArtifactActions({
artifact,
@ -28,11 +28,11 @@ function PureArtifactActions({
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind,
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error('Artifact definition not found!');
throw new Error("Artifact definition not found!");
}
const actionContext: ArtifactActionContext = {
@ -51,29 +51,29 @@ function PureArtifactActions({
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<Button
variant="outline"
className={cn('h-fit dark:hover:bg-zinc-700', {
'p-2': !action.label,
'px-2 py-1.5': action.label,
className={cn("h-fit dark:hover:bg-zinc-700", {
"p-2": !action.label,
"px-2 py-1.5": action.label,
})}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (error) {
toast.error('Failed to execute action');
} finally {
setIsLoading(false);
}
}}
disabled={
isLoading || artifact.status === 'streaming'
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} finally {
setIsLoading(false);
}
}}
variant="outline"
>
{action.icon}
{action.label}
@ -89,12 +89,19 @@ function PureArtifactActions({
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) return false;
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
if (prevProps.artifact.status !== nextProps.artifact.status) {
return false;
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
if (prevProps.artifact.content !== nextProps.artifact.content) return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
return true;
},
}
);

View file

@ -1,26 +1,26 @@
import { memo } from 'react';
import { CrossIcon } from './icons';
import { Button } from './ui/button';
import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
import { memo } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { CrossIcon } from "./icons";
import { Button } from "./ui/button";
function PureArtifactCloseButton() {
const { setArtifact } = useArtifact();
return (
<Button
data-testid="artifact-close-button"
variant="outline"
className="h-fit p-2 dark:hover:bg-zinc-700"
data-testid="artifact-close-button"
onClick={() => {
setArtifact((currentArtifact) =>
currentArtifact.status === 'streaming'
currentArtifact.status === "streaming"
? {
...currentArtifact,
isVisible: false,
}
: { ...initialArtifactData, status: 'idle' },
: { ...initialArtifactData, status: "idle" }
);
}}
variant="outline"
>
<CrossIcon size={18} />
</Button>

View file

@ -1,23 +1,23 @@
import { PreviewMessage, ThinkingMessage } from './message';
import type { Vote } from '@/lib/db/schema';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import type { UIArtifact } from './artifact';
import type { UseChatHelpers } from '@ai-sdk/react';
import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types';
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import type { UIArtifact } from "./artifact";
import { PreviewMessage, ThinkingMessage } from "./message";
interface ArtifactMessagesProps {
type ArtifactMessagesProps = {
chatId: string;
status: UseChatHelpers<ChatMessage>['status'];
votes: Array<Vote> | undefined;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
artifactStatus: UIArtifact['status'];
}
artifactStatus: UIArtifact["status"];
};
function PureArtifactMessages({
chatId,
@ -35,45 +35,43 @@ function PureArtifactMessages({
onViewportLeave,
hasSentMessage,
} = useMessages({
chatId,
status,
});
return (
<div
ref={messagesContainerRef}
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
ref={messagesContainerRef}
>
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
isLoading={status === "streaming" && index === messages.length - 1}
isReadonly={isReadonly}
key={message.id}
message={message}
isLoading={status === 'streaming' && index === messages.length - 1}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
isArtifactVisible={true}
/>
))}
{status === 'submitted' &&
{status === "submitted" &&
messages.length > 0 &&
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
messages.at(-1)?.role === "user" && <ThinkingMessage />}
<motion.div
ref={messagesEndRef}
className="min-h-[24px] min-w-[24px] shrink-0"
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
onViewportLeave={onViewportLeave}
ref={messagesEndRef}
/>
</div>
);
@ -81,18 +79,27 @@ function PureArtifactMessages({
function areEqual(
prevProps: ArtifactMessagesProps,
nextProps: ArtifactMessagesProps,
nextProps: ArtifactMessagesProps
) {
if (
prevProps.artifactStatus === 'streaming' &&
nextProps.artifactStatus === 'streaming'
)
prevProps.artifactStatus === "streaming" &&
nextProps.artifactStatus === "streaming"
) {
return true;
}
if (prevProps.status !== nextProps.status) return false;
if (prevProps.status && nextProps.status) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.status && nextProps.status) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return true;
}

View file

@ -1,5 +1,7 @@
import { formatDistance } from 'date-fns';
import { AnimatePresence, motion } from 'framer-motion';
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import {
type Dispatch,
memo,
@ -7,27 +9,25 @@ import {
useCallback,
useEffect,
useState,
} from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { useDebounceCallback, useWindowSize } from 'usehooks-ts';
import type { Document, Vote } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils';
import { MultimodalInput } from './multimodal-input';
import { Toolbar } from './toolbar';
import { VersionFooter } from './version-footer';
import { ArtifactActions } from './artifact-actions';
import { ArtifactCloseButton } from './artifact-close-button';
import { ArtifactMessages } from './artifact-messages';
import { useSidebar } from './ui/sidebar';
import { useArtifact } from '@/hooks/use-artifact';
import { imageArtifact } from '@/artifacts/image/client';
import { codeArtifact } from '@/artifacts/code/client';
import { sheetArtifact } from '@/artifacts/sheet/client';
import { textArtifact } from '@/artifacts/text/client';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { Attachment, ChatMessage } from '@/lib/types';
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { ArtifactMessages } from "./artifact-messages";
import { MultimodalInput } from "./multimodal-input";
import { Toolbar } from "./toolbar";
import { useSidebar } from "./ui/sidebar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
export const artifactDefinitions = [
textArtifact,
@ -35,22 +35,22 @@ export const artifactDefinitions = [
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]['kind'];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export interface UIArtifact {
export type UIArtifact = {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: 'streaming' | 'idle';
status: "streaming" | "idle";
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
}
};
function PureArtifact({
chatId,
@ -72,15 +72,15 @@ function PureArtifact({
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>['status'];
stop: UseChatHelpers<ChatMessage>['stop'];
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
votes: Array<Vote> | undefined;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
@ -91,14 +91,14 @@ function PureArtifact({
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Array<Document>>(
artifact.documentId !== 'init' && artifact.status !== 'streaming'
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
? `/api/document?id=${artifact.documentId}`
: null,
fetcher,
fetcher
);
const [mode, setMode] = useState<'edit' | 'diff'>('edit');
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
@ -113,7 +113,7 @@ function PureArtifact({
setCurrentVersionIndex(documents.length - 1);
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? '',
content: mostRecentDocument.content ?? "",
}));
}
}
@ -121,19 +121,23 @@ function PureArtifact({
useEffect(() => {
mutateDocuments();
}, [artifact.status, mutateDocuments]);
}, [mutateDocuments]);
const { mutate } = useSWRConfig();
const [isContentDirty, setIsContentDirty] = useState(false);
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) return;
if (!artifact) {
return;
}
mutate<Array<Document>>(
mutate<Document[]>(
`/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) return undefined;
if (!currentDocuments) {
return [];
}
const currentDocument = currentDocuments.at(-1);
@ -144,7 +148,7 @@ function PureArtifact({
if (currentDocument.content !== updatedContent) {
await fetch(`/api/document?id=${artifact.documentId}`, {
method: 'POST',
method: "POST",
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
@ -164,15 +168,15 @@ function PureArtifact({
}
return currentDocuments;
},
{ revalidate: false },
{ revalidate: false }
);
},
[artifact, mutate],
[artifact, mutate]
);
const debouncedHandleContentChange = useDebounceCallback(
handleContentChange,
2000,
2000
);
const saveContent = useCallback(
@ -187,35 +191,39 @@ function PureArtifact({
}
}
},
[document, debouncedHandleContentChange, handleContentChange],
[document, debouncedHandleContentChange, handleContentChange]
);
function getDocumentContentById(index: number) {
if (!documents) return '';
if (!documents[index]) return '';
return documents[index].content ?? '';
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
}
const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => {
if (!documents) return;
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
if (type === 'latest') {
if (type === "latest") {
setCurrentVersionIndex(documents.length - 1);
setMode('edit');
setMode("edit");
}
if (type === 'toggle') {
setMode((mode) => (mode === 'edit' ? 'diff' : 'edit'));
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
}
if (type === 'prev') {
if (type === "prev") {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === 'next') {
if (currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
};
@ -236,21 +244,19 @@ function PureArtifact({
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind,
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error('Artifact definition not found!');
throw new Error("Artifact definition not found!");
}
useEffect(() => {
if (artifact.documentId !== 'init') {
if (artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
@ -258,21 +264,21 @@ function PureArtifact({
<AnimatePresence>
{artifact.isVisible && (
<motion.div
data-testid="artifact"
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
data-testid="artifact"
exit={{ opacity: 0, transition: { delay: 0.4 } }}
initial={{ opacity: 1 }}
>
{!isMobile && (
<motion.div
animate={{ width: windowWidth, right: 0 }}
className="fixed h-dvh bg-background"
initial={{
exit={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
animate={{ width: windowWidth, right: 0 }}
exit={{
initial={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
@ -281,64 +287,64 @@ function PureArtifact({
{!isMobile && (
<motion.div
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
initial={{ opacity: 0, x: 10, scale: 1 }}
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.1,
type: 'spring',
type: "spring",
stiffness: 300,
damping: 30,
},
}}
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
initial={{ opacity: 0, x: 10, scale: 1 }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages
artifactStatus={artifact.status}
chatId={chatId}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
setMessages={setMessages}
status={status}
votes={votes}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
artifactStatus={artifact.status}
/>
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
<MultimodalInput
attachments={attachments}
chatId={chatId}
className="bg-background dark:bg-muted"
input={input}
messages={messages}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
sendMessage={sendMessage}
className="bg-background dark:bg-muted"
setMessages={setMessages}
selectedVisibilityType={selectedVisibilityType}
selectedModelId={selectedModelId}
/>
</div>
</div>
@ -346,7 +352,52 @@ function PureArtifact({
)}
<motion.div
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : "calc(100dvw)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: "calc(100dvw-400px)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: "spring",
stiffness: 600,
damping: 30,
},
}}
initial={
isMobile
? {
@ -366,51 +417,6 @@ function PureArtifact({
borderRadius: 50,
}
}
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : 'calc(100dvw)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: 'calc(100dvw-400px)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: 'spring',
stiffness: 600,
damping: 30,
},
}}
>
<div className="flex flex-row items-start justify-between p-2">
<div className="flex flex-row items-start gap-4">
@ -430,7 +436,7 @@ function PureArtifact({
new Date(),
{
addSuffix: true,
},
}
)}`}
</div>
) : (
@ -444,43 +450,43 @@ function PureArtifact({
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
mode={mode}
metadata={metadata}
mode={mode}
setMetadata={setMetadata}
/>
</div>
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
<artifactDefinition.content
title={artifact.title}
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
mode={mode}
status={artifact.status}
currentVersionIndex={currentVersionIndex}
suggestions={[]}
onSaveContent={saveContent}
isInline={false}
isCurrentVersion={isCurrentVersion}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
isInline={false}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactKind={artifact.kind}
isToolbarVisible={isToolbarVisible}
setIsToolbarVisible={setIsToolbarVisible}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
status={status}
stop={stop}
setMessages={setMessages}
artifactKind={artifact.kind}
/>
)}
</AnimatePresence>
@ -503,12 +509,21 @@ function PureArtifact({
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
if (prevProps.input !== nextProps.input) return false;
if (!equal(prevProps.messages, nextProps.messages.length)) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages.length)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -1,12 +1,12 @@
import Form from 'next/form';
import Form from "next/form";
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export function AuthForm({
action,
children,
defaultEmail = '',
defaultEmail = "",
}: {
action: NonNullable<
string | ((formData: FormData) => void | Promise<void>) | undefined
@ -18,39 +18,39 @@ export function AuthForm({
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
<div className="flex flex-col gap-2">
<Label
htmlFor="email"
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="email"
>
Email Address
</Label>
<Input
autoComplete="email"
autoFocus
className="bg-muted text-md md:text-sm"
defaultValue={defaultEmail}
id="email"
name="email"
className="bg-muted text-md md:text-sm"
type="email"
placeholder="user@acme.com"
autoComplete="email"
required
autoFocus
defaultValue={defaultEmail}
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<Label
htmlFor="password"
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="password"
>
Password
</Label>
<Input
className="bg-muted text-md md:text-sm"
id="password"
name="password"
className="bg-muted text-md md:text-sm"
type="password"
required
type="password"
/>
</div>

View file

@ -1,27 +1,23 @@
'use client';
"use client";
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useWindowSize } from 'usehooks-ts';
import { SidebarToggle } from '@/components/sidebar-toggle';
import { Button } from '@/components/ui/button';
import { PlusIcon, VercelIcon } from './icons';
import { useSidebar } from './ui/sidebar';
import { memo } from 'react';
import { type VisibilityType, VisibilitySelector } from './visibility-selector';
import type { Session } from 'next-auth';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo } from "react";
import { useWindowSize } from "usehooks-ts";
import { SidebarToggle } from "@/components/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { PlusIcon, VercelIcon } from "./icons";
import { useSidebar } from "./ui/sidebar";
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
session,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
}) {
const router = useRouter();
const { open } = useSidebar();
@ -34,12 +30,12 @@ function PureChatHeader({
{(!open || windowWidth < 768) && (
<Button
variant="outline"
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
router.push('/');
router.push("/");
router.refresh();
}}
variant="outline"
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
@ -49,19 +45,19 @@ function PureChatHeader({
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
className="order-1 md:order-2"
selectedVisibilityType={selectedVisibilityType}
/>
)}
<Button
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
asChild
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Link
href={`https://vercel.com/templates/next.js/nextjs-ai-chatbot`}
target="_noblank"
href={"https://vercel.com/templates/next.js/nextjs-ai-chatbot"}
rel="noreferrer"
target="_noblank"
>
<VercelIcon size={16} />
Deploy with Vercel

View file

@ -1,28 +1,12 @@
'use client';
"use client";
import { DefaultChatTransport } from 'ai';
import { useChat } from '@ai-sdk/react';
import { useEffect, useState, useRef } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { ChatHeader } from '@/components/chat-header';
import type { Vote } from '@/lib/db/schema';
import { fetcher, fetchWithErrorHandlers, generateUUID } from '@/lib/utils';
import { Artifact } from './artifact';
import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages';
import type { VisibilityType } from './visibility-selector';
import { useArtifactSelector } from '@/hooks/use-artifact';
import { unstable_serialize } from 'swr/infinite';
import { getChatHistoryPaginationKey } from './sidebar-history';
import { toast } from './toast';
import type { Session } from 'next-auth';
import { useSearchParams } from 'next/navigation';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
import { useAutoResume } from '@/hooks/use-auto-resume';
import { ChatSDKError } from '@/lib/errors';
import type { Attachment, ChatMessage } from '@/lib/types';
import type { AppUsage } from '@/lib/usage';
import { useDataStream } from './data-stream-provider';
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { ChatHeader } from "@/components/chat-header";
import {
AlertDialog,
AlertDialogAction,
@ -32,7 +16,22 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
import { getChatHistoryPaginationKey } from "./sidebar-history";
import { toast } from "./toast";
import type { VisibilityType } from "./visibility-selector";
export function Chat({
id,
@ -40,7 +39,6 @@ export function Chat({
initialChatModel,
initialVisibilityType,
isReadonly,
session,
autoResume,
initialLastContext,
}: {
@ -49,7 +47,6 @@ export function Chat({
initialChatModel: string;
initialVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
autoResume: boolean;
initialLastContext?: AppUsage;
}) {
@ -61,12 +58,12 @@ export function Chat({
const { mutate } = useSWRConfig();
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>('');
const [input, setInput] = useState<string>("");
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId);
useEffect(() => {
currentModelIdRef.current = currentModelId;
}, [currentModelId]);
@ -85,23 +82,25 @@ export function Chat({
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: '/api/chat',
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest({ messages, id, body }) {
prepareSendMessagesRequest(request) {
return {
body: {
id,
message: messages.at(-1),
id: request.id,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibilityType,
...body,
...request.body,
},
};
},
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
if (dataPart.type === 'data-usage') setUsage(dataPart.data);
if (dataPart.type === "data-usage") {
setUsage(dataPart.data);
}
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
@ -110,12 +109,12 @@ export function Chat({
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
error.message?.includes('AI Gateway requires a valid credit card')
error.message?.includes("AI Gateway requires a valid credit card")
) {
setShowCreditCardAlert(true);
} else {
toast({
type: 'error',
type: "error",
description: error.message,
});
}
@ -124,28 +123,28 @@ export function Chat({
});
const searchParams = useSearchParams();
const query = searchParams.get('query');
const query = searchParams.get("query");
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
useEffect(() => {
if (query && !hasAppendedQuery) {
sendMessage({
role: 'user' as const,
parts: [{ type: 'text', text: query }],
role: "user" as const,
parts: [{ type: "text", text: query }],
});
setHasAppendedQuery(true);
window.history.replaceState({}, '', `/chat/${id}`);
window.history.replaceState({}, "", `/chat/${id}`);
}
}, [query, sendMessage, hasAppendedQuery, id]);
const { data: votes } = useSWR<Array<Vote>>(
const { data: votes } = useSWR<Vote[]>(
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
fetcher,
fetcher
);
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
useAutoResume({
@ -160,39 +159,38 @@ export function Chat({
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
selectedVisibilityType={initialVisibilityType}
isReadonly={isReadonly}
session={session}
selectedVisibilityType={initialVisibilityType}
/>
<Messages
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={initialChatModel}
setMessages={setMessages}
status={status}
votes={votes}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
isArtifactVisible={isArtifactVisible}
selectedModelId={initialChatModel}
/>
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={messages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
setMessages={setMessages}
sendMessage={sendMessage}
selectedVisibilityType={visibilityType}
selectedModelId={currentModelId}
onModelChange={setCurrentModelId}
usage={usage}
/>
)}
@ -200,33 +198,33 @@ export function Chat({
</div>
<Artifact
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
sendMessage={sendMessage}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
votes={votes}
isReadonly={isReadonly}
selectedVisibilityType={visibilityType}
selectedModelId={currentModelId}
/>
<AlertDialog
open={showCreditCardAlert}
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
This application requires{' '}
{process.env.NODE_ENV === 'production' ? 'the owner' : 'you'} to
This application requires{" "}
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
activate Vercel AI Gateway.
</AlertDialogDescription>
</AlertDialogHeader>
@ -235,10 +233,10 @@ export function Chat({
<AlertDialogAction
onClick={() => {
window.open(
'https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card',
'_blank',
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = '/';
window.location.href = "/";
}}
>
Activate

View file

@ -1,20 +1,20 @@
'use client';
"use client";
import { EditorView } from '@codemirror/view';
import { EditorState, Transaction } from '@codemirror/state';
import { python } from '@codemirror/lang-python';
import { oneDark } from '@codemirror/theme-one-dark';
import { basicSetup } from 'codemirror';
import React, { memo, useEffect, useRef } from 'react';
import type { Suggestion } from '@/lib/db/schema';
import { python } from "@codemirror/lang-python";
import { EditorState, Transaction } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: 'streaming' | 'idle';
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Array<Suggestion>;
suggestions: Suggestion[];
};
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
@ -42,14 +42,14 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, []);
}, [content]);
useEffect(() => {
if (editorRef.current) {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const transaction = update.transactions.find(
(tr) => !tr.annotation(Transaction.remote),
(tr) => !tr.annotation(Transaction.remote)
);
if (transaction) {
@ -75,7 +75,7 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
if (editorRef.current && content) {
const currentContent = editorRef.current.state.doc.toString();
if (status === 'streaming' || currentContent !== content) {
if (status === "streaming" || currentContent !== content) {
const transaction = editorRef.current.state.update({
changes: {
from: 0,
@ -99,13 +99,21 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
if (prevProps.suggestions !== nextProps.suggestions) return false;
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
if (prevProps.suggestions !== nextProps.suggestions) {
return false;
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
if (prevProps.status === 'streaming' && nextProps.status === 'streaming')
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
if (prevProps.content !== nextProps.content) return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
return true;
}

View file

@ -1,6 +1,3 @@
import { TerminalWindowIcon, CrossSmallIcon } from './icons';
import { Loader } from './elements/loader';
import { Button } from './ui/button';
import {
type Dispatch,
type SetStateAction,
@ -8,25 +5,28 @@ import {
useEffect,
useRef,
useState,
} from 'react';
import { cn } from '@/lib/utils';
import { useArtifactSelector } from '@/hooks/use-artifact';
} from "react";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { cn } from "@/lib/utils";
import { Loader } from "./elements/loader";
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
import { Button } from "./ui/button";
export interface ConsoleOutputContent {
type: 'text' | 'image';
export type ConsoleOutputContent = {
type: "text" | "image";
value: string;
}
};
export interface ConsoleOutput {
export type ConsoleOutput = {
id: string;
status: 'in_progress' | 'loading_packages' | 'completed' | 'failed';
contents: Array<ConsoleOutputContent>;
}
status: "in_progress" | "loading_packages" | "completed" | "failed";
contents: ConsoleOutputContent[];
};
interface ConsoleProps {
consoleOutputs: Array<ConsoleOutput>;
setConsoleOutputs: Dispatch<SetStateAction<Array<ConsoleOutput>>>;
}
type ConsoleProps = {
consoleOutputs: ConsoleOutput[];
setConsoleOutputs: Dispatch<SetStateAction<ConsoleOutput[]>>;
};
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
const [height, setHeight] = useState<number>(300);
@ -55,21 +55,21 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
}
}
},
[isResizing],
[isResizing]
);
useEffect(() => {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [consoleOutputs]);
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
if (!isArtifactVisible) {
@ -80,19 +80,31 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
return consoleOutputs.length > 0 ? (
<>
<div
aria-label="Resize console"
aria-orientation="horizontal"
aria-valuemax={maxHeight}
aria-valuemin={minHeight}
aria-valuenow={height}
className="fixed z-50 h-2 w-full cursor-ns-resize"
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
setHeight((prev) => Math.min(prev + 10, maxHeight));
} else if (e.key === "ArrowDown") {
setHeight((prev) => Math.max(prev - 10, minHeight));
}
}}
onMouseDown={startResizing}
style={{ bottom: height - 4 }}
role="slider"
aria-valuenow={minHeight}
style={{ bottom: height - 4 }}
tabIndex={0}
/>
<div
className={cn(
'fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900',
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
{
'select-none': isResizing,
},
"select-none": isResizing,
}
)}
style={{ height }}
>
@ -104,10 +116,10 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div>Console</div>
</div>
<Button
variant="ghost"
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
size="icon"
onClick={() => setConsoleOutputs([])}
size="icon"
variant="ghost"
>
<CrossSmallIcon />
</Button>
@ -116,57 +128,58 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div
key={consoleOutput.id}
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
key={consoleOutput.id}
>
<div
className={cn('w-12 shrink-0', {
'text-muted-foreground': [
'in_progress',
'loading_packages',
className={cn("w-12 shrink-0", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
].includes(consoleOutput.status),
'text-emerald-500': consoleOutput.status === 'completed',
'text-red-400': consoleOutput.status === 'failed',
"text-emerald-500": consoleOutput.status === "completed",
"text-red-400": consoleOutput.status === "failed",
})}
>
[{index + 1}]
</div>
{['in_progress', 'loading_packages'].includes(
consoleOutput.status,
{["in_progress", "loading_packages"].includes(
consoleOutput.status
) ? (
<div className="flex flex-row gap-2">
<div className="mt-0.5 mb-auto size-fit self-center">
<Loader size={16} />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === 'in_progress'
? 'Initializing...'
: consoleOutput.status === 'loading_packages'
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
? consoleOutput.contents.map((content) =>
content.type === 'text' ? content.value : null,
content.type === "text" ? content.value : null
)
: null}
</div>
</div>
) : (
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
{consoleOutput.contents.map((content, index) =>
content.type === 'image' ? (
<picture key={`${consoleOutput.id}-${index}`}>
{consoleOutput.contents.map((content, contentIndex) =>
content.type === "image" ? (
<picture key={`${consoleOutput.id}-${contentIndex}`}>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
src={content.value}
alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
src={content.value}
/>
</picture>
) : (
<div
key={`${consoleOutput.id}-${index}`}
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-${contentIndex}`}
>
{content.value}
</div>
),
)
)}
</div>
)}

View file

@ -1,16 +1,16 @@
import type { Suggestion } from '@/lib/db/schema';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react';
import type { UIArtifact } from './artifact';
import type { ChatMessage, CustomUIDataTypes } from '@/lib/types';
import type { DataUIPart } from 'ai';
import type { UseChatHelpers } from "@ai-sdk/react";
import type { DataUIPart } from "ai";
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
import type { Suggestion } from "@/lib/db/schema";
import type { ChatMessage, CustomUIDataTypes } from "@/lib/types";
import type { UIArtifact } from "./artifact";
export type ArtifactActionContext<M = any> = {
content: string;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: 'edit' | 'diff';
mode: "edit" | "diff";
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
@ -24,7 +24,7 @@ type ArtifactAction<M = any> = {
};
export type ArtifactToolbarContext = {
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
};
export type ArtifactToolbarItem = {
@ -33,32 +33,32 @@ export type ArtifactToolbarItem = {
onClick: (context: ArtifactToolbarContext) => void;
};
interface ArtifactContent<M = any> {
type ArtifactContent<M = any> = {
title: string;
content: string;
mode: 'edit' | 'diff';
mode: "edit" | "diff";
isCurrentVersion: boolean;
currentVersionIndex: number;
status: 'streaming' | 'idle';
suggestions: Array<Suggestion>;
status: "streaming" | "idle";
suggestions: Suggestion[];
onSaveContent: (updatedContent: string, debounce: boolean) => void;
isInline: boolean;
getDocumentContentById: (index: number) => string;
isLoading: boolean;
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
}
};
interface InitializeParameters<M = any> {
type InitializeParameters<M = any> = {
documentId: string;
setMetadata: Dispatch<SetStateAction<M>>;
}
};
type ArtifactConfig<T extends string, M = any> = {
kind: T;
description: string;
content: ComponentType<ArtifactContent<M>>;
actions: Array<ArtifactAction<M>>;
actions: ArtifactAction<M>[];
toolbar: ArtifactToolbarItem[];
initialize?: (parameters: InitializeParameters<M>) => void;
onStreamPart: (args: {
@ -72,7 +72,7 @@ export class Artifact<T extends string, M = any> {
readonly kind: T;
readonly description: string;
readonly content: ComponentType<ArtifactContent<M>>;
readonly actions: Array<ArtifactAction<M>>;
readonly actions: ArtifactAction<M>[];
readonly toolbar: ArtifactToolbarItem[];
readonly initialize?: (parameters: InitializeParameters) => void;
readonly onStreamPart: (args: {

View file

@ -1,9 +1,9 @@
'use client';
"use client";
import { useEffect, useRef } from 'react';
import { artifactDefinitions } from './artifact';
import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
import { useDataStream } from './data-stream-provider';
import { useEffect, useRef } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
export function DataStreamHandler() {
const { dataStream } = useDataStream();
@ -12,14 +12,17 @@ export function DataStreamHandler() {
const lastProcessedIndex = useRef(-1);
useEffect(() => {
if (!dataStream?.length) return;
if (!dataStream?.length) {
return;
}
const newDeltas = dataStream.slice(lastProcessedIndex.current + 1);
lastProcessedIndex.current = dataStream.length - 1;
newDeltas.forEach((delta) => {
for (const delta of newDeltas) {
const artifactDefinition = artifactDefinitions.find(
(artifactDefinition) => artifactDefinition.kind === artifact.kind,
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
);
if (artifactDefinition?.onStreamPart) {
@ -32,49 +35,49 @@ export function DataStreamHandler() {
setArtifact((draftArtifact) => {
if (!draftArtifact) {
return { ...initialArtifactData, status: 'streaming' };
return { ...initialArtifactData, status: "streaming" };
}
switch (delta.type) {
case 'data-id':
case "data-id":
return {
...draftArtifact,
documentId: delta.data,
status: 'streaming',
status: "streaming",
};
case 'data-title':
case "data-title":
return {
...draftArtifact,
title: delta.data,
status: 'streaming',
status: "streaming",
};
case 'data-kind':
case "data-kind":
return {
...draftArtifact,
kind: delta.data,
status: 'streaming',
status: "streaming",
};
case 'data-clear':
case "data-clear":
return {
...draftArtifact,
content: '',
status: 'streaming',
content: "",
status: "streaming",
};
case 'data-finish':
case "data-finish":
return {
...draftArtifact,
status: 'idle',
status: "idle",
};
default:
return draftArtifact;
}
});
});
}
}, [dataStream, setArtifact, setMetadata, artifact]);
return null;

View file

@ -1,15 +1,16 @@
'use client';
"use client";
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DataUIPart } from 'ai';
import type { CustomUIDataTypes } from '@/lib/types';
import type { DataUIPart } from "ai";
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { CustomUIDataTypes } from "@/lib/types";
interface DataStreamContextValue {
type DataStreamContextValue = {
dataStream: DataUIPart<CustomUIDataTypes>[];
setDataStream: React.Dispatch<
React.SetStateAction<DataUIPart<CustomUIDataTypes>[]>
>;
}
};
const DataStreamContext = createContext<DataStreamContextValue | null>(null);
@ -19,7 +20,7 @@ export function DataStreamProvider({
children: React.ReactNode;
}) {
const [dataStream, setDataStream] = useState<DataUIPart<CustomUIDataTypes>[]>(
[],
[]
);
const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
@ -34,7 +35,7 @@ export function DataStreamProvider({
export function useDataStream() {
const context = useContext(DataStreamContext);
if (!context) {
throw new Error('useDataStream must be used within a DataStreamProvider');
throw new Error("useDataStream must be used within a DataStreamProvider");
}
return context;
}

View file

@ -1,42 +1,42 @@
import OrderedMap from 'orderedmap';
import OrderedMap from "orderedmap";
import {
Schema,
type Node as ProsemirrorNode,
type MarkSpec,
DOMParser,
} from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import React, { useEffect, useRef } from 'react';
import { renderToString } from 'react-dom/server';
import { Streamdown } from 'streamdown';
type MarkSpec,
type Node as ProsemirrorNode,
Schema,
} from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Streamdown } from "streamdown";
import { diffEditor, DiffType } from '@/lib/editor/diff';
import { DiffType, diffEditor } from "@/lib/editor/diff";
const diffSchema = new Schema({
nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: OrderedMap.from({
...schema.spec.marks.toObject(),
diffMark: {
attrs: { type: { default: '' } },
attrs: { type: { default: "" } },
toDOM(mark) {
let className = '';
let className = "";
switch (mark.attrs.type) {
case DiffType.Inserted:
className =
'bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300';
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
break;
case DiffType.Deleted:
className =
'bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300';
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
break;
default:
className = '';
className = "";
}
return ['span', { class: className }, 0];
return ["span", { class: className }, 0];
},
} as MarkSpec,
}),
@ -60,16 +60,16 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<Streamdown>{oldContent}</Streamdown>,
<Streamdown>{oldContent}</Streamdown>
);
const newHtmlContent = renderToString(
<Streamdown>{newContent}</Streamdown>,
<Streamdown>{newContent}</Streamdown>
);
const oldContainer = document.createElement('div');
const oldContainer = document.createElement("div");
oldContainer.innerHTML = oldHtmlContent;
const newContainer = document.createElement('div');
const newContainer = document.createElement("div");
newContainer.innerHTML = newHtmlContent;
const oldDoc = parser.parse(oldContainer);

View file

@ -1,32 +1,32 @@
'use client';
"use client";
import equal from "fast-deep-equal";
import {
memo,
type MouseEvent,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import type { ArtifactKind, UIArtifact } from './artifact';
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from './icons';
import { cn, fetcher } from '@/lib/utils';
import type { Document } from '@/lib/db/schema';
import { InlineDocumentSkeleton } from './document-skeleton';
import useSWR from 'swr';
import { Editor } from './text-editor';
import { DocumentToolCall, DocumentToolResult } from './document';
import { CodeEditor } from './code-editor';
import { useArtifact } from '@/hooks/use-artifact';
import equal from 'fast-deep-equal';
import { SpreadsheetEditor } from './sheet-editor';
import { ImageEditor } from './image-editor';
} from "react";
import useSWR from "swr";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { cn, fetcher } from "@/lib/utils";
import type { ArtifactKind, UIArtifact } from "./artifact";
import { CodeEditor } from "./code-editor";
import { DocumentToolCall, DocumentToolResult } from "./document";
import { InlineDocumentSkeleton } from "./document-skeleton";
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
import { ImageEditor } from "./image-editor";
import { SpreadsheetEditor } from "./sheet-editor";
import { Editor } from "./text-editor";
interface DocumentPreviewProps {
type DocumentPreviewProps = {
isReadonly: boolean;
result?: any;
args?: any;
}
};
export function DocumentPreview({
isReadonly,
@ -36,7 +36,7 @@ export function DocumentPreview({
const { artifact, setArtifact } = useArtifact();
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Array<Document>
Document[]
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
@ -46,8 +46,8 @@ export function DocumentPreview({
const boundingBox = hitboxRef.current?.getBoundingClientRect();
if (artifact.documentId && boundingBox) {
setArtifact((artifact) => ({
...artifact,
setArtifact((currentArtifact) => ({
...currentArtifact,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
@ -62,9 +62,9 @@ export function DocumentPreview({
if (result) {
return (
<DocumentToolResult
type="create"
result={{ id: result.id, title: result.title, kind: result.kind }}
isReadonly={isReadonly}
result={{ id: result.id, title: result.title, kind: result.kind }}
type="create"
/>
);
}
@ -72,9 +72,9 @@ export function DocumentPreview({
if (args) {
return (
<DocumentToolCall
type="create"
args={{ title: args.title, kind: args.kind }}
isReadonly={isReadonly}
type="create"
/>
);
}
@ -86,18 +86,20 @@ export function DocumentPreview({
const document: Document | null = previewDocument
? previewDocument
: artifact.status === 'streaming'
: artifact.status === "streaming"
? {
title: artifact.title,
kind: artifact.kind,
content: artifact.content,
id: artifact.documentId,
createdAt: new Date(),
userId: 'noop',
userId: "noop",
}
: null;
if (!document) return <LoadingSkeleton artifactKind={artifact.kind} />;
if (!document) {
return <LoadingSkeleton artifactKind={artifact.kind} />;
}
return (
<div className="relative w-full cursor-pointer">
@ -107,9 +109,9 @@ export function DocumentPreview({
setArtifact={setArtifact}
/>
<DocumentHeader
title={document.title}
isStreaming={artifact.status === "streaming"}
kind={document.kind}
isStreaming={artifact.status === 'streaming'}
title={document.title}
/>
<DocumentContent document={document} />
</div>
@ -129,7 +131,7 @@ const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<FullscreenIcon />
</div>
</div>
{artifactKind === 'image' ? (
{artifactKind === "image" ? (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
</div>
@ -149,7 +151,7 @@ const PureHitboxLayer = ({
hitboxRef: React.RefObject<HTMLDivElement>;
result: any;
setArtifact: (
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact),
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
) => void;
}) => {
const handleClick = useCallback(
@ -157,7 +159,7 @@ const PureHitboxLayer = ({
const boundingBox = event.currentTarget.getBoundingClientRect();
setArtifact((artifact) =>
artifact.status === 'streaming'
artifact.status === "streaming"
? { ...artifact, isVisible: true }
: {
...artifact,
@ -171,19 +173,19 @@ const PureHitboxLayer = ({
width: boundingBox.width,
height: boundingBox.height,
},
},
}
);
},
[setArtifact, result],
[setArtifact, result]
);
return (
<div
className="absolute top-0 left-0 z-10 size-full rounded-xl"
ref={hitboxRef}
onClick={handleClick}
role="presentation"
aria-hidden="true"
className="absolute top-0 left-0 z-10 size-full rounded-xl"
onClick={handleClick}
ref={hitboxRef}
role="presentation"
>
<div className="flex w-full items-center justify-end p-4">
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
@ -195,7 +197,9 @@ const PureHitboxLayer = ({
};
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
if (!equal(prevProps.result, nextProps.result)) return false;
if (!equal(prevProps.result, nextProps.result)) {
return false;
}
return true;
});
@ -215,7 +219,7 @@ const PureDocumentHeader = ({
<div className="animate-spin">
<LoaderIcon />
</div>
) : kind === 'image' ? (
) : kind === "image" ? (
<ImageIcon />
) : (
<FileIcon />
@ -228,8 +232,12 @@ const PureDocumentHeader = ({
);
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) return false;
if (prevProps.isStreaming !== nextProps.isStreaming) return false;
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
return true;
});
@ -238,46 +246,48 @@ const DocumentContent = ({ document }: { document: Document }) => {
const { artifact } = useArtifact();
const containerClassName = cn(
'h-[257px] overflow-y-scroll border rounded-b-2xl dark:bg-muted border-t-0 dark:border-zinc-700',
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
{
'p-4 sm:px-14 sm:py-16': document.kind === 'text',
'p-0': document.kind === 'code',
},
"p-4 sm:px-14 sm:py-16": document.kind === "text",
"p-0": document.kind === "code",
}
);
const commonProps = {
content: document.content ?? '',
content: document.content ?? "",
isCurrentVersion: true,
currentVersionIndex: 0,
status: artifact.status,
saveContent: () => {},
saveContent: () => null,
suggestions: [],
};
const handleSaveContent = () => null;
return (
<div className={containerClassName}>
{document.kind === 'text' ? (
<Editor {...commonProps} onSaveContent={() => {}} />
) : document.kind === 'code' ? (
{document.kind === "text" ? (
<Editor {...commonProps} onSaveContent={handleSaveContent} />
) : document.kind === "code" ? (
<div className="relative flex w-full flex-1">
<div className="absolute inset-0">
<CodeEditor {...commonProps} onSaveContent={() => {}} />
<CodeEditor {...commonProps} onSaveContent={handleSaveContent} />
</div>
</div>
) : document.kind === 'sheet' ? (
) : document.kind === "sheet" ? (
<div className="relative flex size-full flex-1 p-4">
<div className="absolute inset-0">
<SpreadsheetEditor {...commonProps} />
</div>
</div>
) : document.kind === 'image' ? (
) : document.kind === "image" ? (
<ImageEditor
title={document.title}
content={document.content ?? ''}
isCurrentVersion={true}
content={document.content ?? ""}
currentVersionIndex={0}
status={artifact.status}
isCurrentVersion={true}
isInline={true}
status={artifact.status}
title={document.title}
/>
) : null}
</div>

View file

@ -1,13 +1,13 @@
'use client';
"use client";
import type { ArtifactKind } from './artifact';
import type { ArtifactKind } from "./artifact";
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === 'image' ? (
return artifactKind === "image" ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>

View file

@ -1,33 +1,32 @@
import { memo } from 'react';
import type { ArtifactKind } from './artifact';
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
import { toast } from 'sonner';
import { useArtifact } from '@/hooks/use-artifact';
import { memo } from "react";
import { toast } from "sonner";
import { useArtifact } from "@/hooks/use-artifact";
import type { ArtifactKind } from "./artifact";
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from "./icons";
const getActionText = (
type: 'create' | 'update' | 'request-suggestions',
tense: 'present' | 'past',
type: "create" | "update" | "request-suggestions",
tense: "present" | "past"
) => {
switch (type) {
case 'create':
return tense === 'present' ? 'Creating' : 'Created';
case 'update':
return tense === 'present' ? 'Updating' : 'Updated';
case 'request-suggestions':
return tense === 'present'
? 'Adding suggestions'
: 'Added suggestions to';
case "create":
return tense === "present" ? "Creating" : "Created";
case "update":
return tense === "present" ? "Updating" : "Updated";
case "request-suggestions":
return tense === "present"
? "Adding suggestions"
: "Added suggestions to";
default:
return null;
}
};
interface DocumentToolResultProps {
type: 'create' | 'update' | 'request-suggestions';
type DocumentToolResultProps = {
type: "create" | "update" | "request-suggestions";
result: { id: string; title: string; kind: ArtifactKind };
isReadonly: boolean;
}
};
function PureDocumentToolResult({
type,
@ -38,12 +37,11 @@ function PureDocumentToolResult({
return (
<button
type="button"
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
'Viewing files in shared chats is currently not supported.',
"Viewing files in shared chats is currently not supported."
);
return;
}
@ -60,25 +58,26 @@ function PureDocumentToolResult({
setArtifact({
documentId: result.id,
kind: result.kind,
content: '',
content: "",
title: result.title,
isVisible: true,
status: 'idle',
status: "idle",
boundingBox,
});
}}
type="button"
>
<div className="mt-1 text-muted-foreground">
{type === 'create' ? (
{type === "create" ? (
<FileIcon />
) : type === 'update' ? (
) : type === "update" ? (
<PencilEditIcon />
) : type === 'request-suggestions' ? (
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, 'past')} "${result.title}"`}
{`${getActionText(type, "past")} "${result.title}"`}
</div>
</button>
);
@ -86,14 +85,14 @@ function PureDocumentToolResult({
export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
interface DocumentToolCallProps {
type: 'create' | 'update' | 'request-suggestions';
type DocumentToolCallProps = {
type: "create" | "update" | "request-suggestions";
args:
| { title: string; kind: ArtifactKind } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
isReadonly: boolean;
}
};
function PureDocumentToolCall({
type,
@ -104,12 +103,11 @@ function PureDocumentToolCall({
return (
<button
type="button"
className="cursor pointer flex w-fit flex-row items-start justify-between gap-3 rounded-xl border px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
'Viewing files in shared chats is currently not supported.',
"Viewing files in shared chats is currently not supported."
);
return;
}
@ -129,27 +127,28 @@ function PureDocumentToolCall({
boundingBox,
}));
}}
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-zinc-500">
{type === 'create' ? (
{type === "create" ? (
<FileIcon />
) : type === 'update' ? (
) : type === "update" ? (
<PencilEditIcon />
) : type === 'request-suggestions' ? (
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, 'present')} ${
type === 'create' && 'title' in args && args.title
{`${getActionText(type, "present")} ${
type === "create" && "title" in args && args.title
? `"${args.title}"`
: type === 'update' && 'description' in args
: type === "update" && "description" in args
? `"${args.description}"`
: type === 'request-suggestions'
? 'for document'
: ''
: type === "request-suggestions"
? "for document"
: ""
}`}
</div>
</div>

View file

@ -1,19 +1,19 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type ActionsProps = ComponentProps<'div'>;
export type ActionsProps = ComponentProps<"div">;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
@ -28,15 +28,15 @@ export const Action = ({
children,
label,
className,
variant = 'ghost',
size = 'sm',
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'relative size-9 p-1.5 text-muted-foreground hover:text-foreground',
className,
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"

View file

@ -1,11 +1,11 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState, useMemo } from 'react';
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type BranchContextType = {
currentBranch: number;
@ -22,7 +22,7 @@ const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error('Branch components must be used within Branch');
throw new Error("Branch components must be used within Branch");
}
return context;
@ -71,7 +71,7 @@ export const Branch = ({
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</BranchContext.Provider>
@ -84,7 +84,7 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children],
[children]
);
// Use useEffect to update branches when they change
@ -97,8 +97,8 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden',
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
@ -109,7 +109,7 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
from: UIMessage["role"];
};
export const BranchSelector = ({
@ -127,9 +127,9 @@ export const BranchSelector = ({
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className,
"flex items-center gap-2 self-end px-10",
from === "assistant" ? "justify-start" : "justify-end",
className
)}
{...props}
/>
@ -149,10 +149,10 @@ export const BranchPrevious = ({
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
@ -179,10 +179,10 @@ export const BranchNext = ({
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
@ -204,8 +204,8 @@ export const BranchPage = ({ className, ...props }: BranchPageProps) => {
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className,
"font-medium text-muted-foreground text-xs tabular-nums",
className
)}
{...props}
>

View file

@ -1,22 +1,22 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
code: "",
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
@ -37,8 +37,8 @@ export const CodeBlock = ({
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className,
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
@ -46,23 +46,23 @@ export const CodeBlock = ({
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
overflowX: 'auto',
overflowWrap: 'break-word',
wordBreak: 'break-all',
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneLight}
@ -72,23 +72,23 @@ export const CodeBlock = ({
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
overflowX: 'auto',
overflowWrap: 'break-word',
wordBreak: 'break-all',
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneDark}
@ -123,8 +123,8 @@ export const CodeBlockCopyButton = ({
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
@ -142,7 +142,7 @@ export const CodeBlockCopyButton = ({
return (
<Button
className={cn('shrink-0', className)}
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"

View file

@ -1,24 +1,24 @@
'use client';
"use client";
import type { ComponentProps } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
import { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress';
import type { AppUsage } from '@/lib/usage';
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
export type ContextProps = ComponentProps<'button'> & {
export type ContextProps = ComponentProps<"button"> & {
/** Optional full usage payload to enable breakdown view */
usage?: AppUsage;
};
const THOUSAND = 1000;
const MILLION = 1_000_000;
const BILLION = 1_000_000_000;
const _THOUSAND = 1000;
const _MILLION = 1_000_000;
const _BILLION = 1_000_000_000;
const PERCENT_MAX = 100;
// Lucide CircleIcon geometry
@ -41,7 +41,7 @@ export const ContextIcon = ({ percent }: ContextIconProps) => {
aria-label={`${percent.toFixed(2)}% of model context used`}
height="28"
role="img"
style={{ color: 'currentcolor' }}
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="28"
>
@ -71,7 +71,6 @@ export const ContextIcon = ({ percent }: ContextIconProps) => {
);
};
function InfoRow({
label,
tokens,
@ -85,14 +84,16 @@ function InfoRow({
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 font-mono">
<span className="text-right min-w-[4ch]">
{tokens === undefined ? '—' : tokens.toLocaleString()}
<span className="min-w-[4ch] text-right">
{tokens === undefined ? "—" : tokens.toLocaleString()}
</span>
{costText !== undefined && costText !== null && !isNaN(parseFloat(costText)) && (
<span className="text-muted-foreground">
${parseFloat(costText).toFixed(6)}
</span>
)}
{costText !== undefined &&
costText !== null &&
!Number.isNaN(Number.parseFloat(costText)) && (
<span className="text-muted-foreground">
${Number.parseFloat(costText).toFixed(6)}
</span>
)}
</div>
</div>
);
@ -104,16 +105,16 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
usage?.context?.totalMax ??
usage?.context?.combinedMax ??
usage?.context?.inputMax;
const hasMax = typeof max === 'number' && Number.isFinite(max) && max > 0;
const hasMax = typeof max === "number" && Number.isFinite(max) && max > 0;
const usedPercent = hasMax ? Math.min(100, (used / max) * 100) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 select-none rounded-md text-sm',
'cursor-pointer bg-background text-foreground',
className,
"inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground",
className
)}
type="button"
{...props}
@ -124,7 +125,7 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
<ContextIcon percent={usedPercent} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-fit p-3">
<DropdownMenuContent align="end" className="w-fit p-3" side="top">
<div className="min-w-[240px] space-y-2">
<div className="flex items-start justify-between text-sm">
<span>{usedPercent.toFixed(1)}%</span>
@ -138,42 +139,43 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
<div className="mt-1 space-y-1">
{usage?.cachedInputTokens && usage.cachedInputTokens > 0 && (
<InfoRow
costText={usage?.costUSD?.cacheReadUSD?.toString()}
label="Cache Hits"
tokens={usage?.cachedInputTokens}
costText={usage?.costUSD?.cacheReadUSD?.toString()}
/>
)}
<InfoRow
costText={usage?.costUSD?.inputUSD?.toString()}
label="Input"
tokens={usage?.inputTokens}
costText={usage?.costUSD?.inputUSD?.toString()}
/>
<InfoRow
costText={usage?.costUSD?.outputUSD?.toString()}
label="Output"
tokens={usage?.outputTokens}
costText={usage?.costUSD?.outputUSD?.toString()}
/>
<InfoRow
costText={usage?.costUSD?.reasoningUSD?.toString()}
label="Reasoning"
tokens={
usage?.reasoningTokens && usage.reasoningTokens > 0
? usage.reasoningTokens
: undefined
}
costText={usage?.costUSD?.reasoningUSD?.toString()}
/>
{usage?.costUSD?.totalUSD !== undefined && (
<>
<Separator className="mt-1" />
<div className="flex justify-between items-center pt-1 text-xs">
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-muted-foreground">Total cost</span>
<div className="flex items-center gap-2 font-mono">
<span className="text-right min-w-[4ch]"></span>
<span className="min-w-[4ch] text-right" />
<span>
{!isNaN(parseFloat(usage.costUSD.totalUSD.toString()))
? `$${parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`
: '—'
}
{Number.isNaN(
Number.parseFloat(usage.costUSD.totalUSD.toString())
)
? "—"
: `$${Number.parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`}
</span>
</div>
</div>

View file

@ -1,19 +1,19 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn(
'relative flex-1 touch-pan-y overflow-y-auto will-change-scroll',
className,
"relative flex-1 touch-pan-y overflow-y-auto will-change-scroll",
className
)}
initial="smooth"
resize="smooth"
@ -30,7 +30,7 @@ export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
<StickToBottom.Content className={cn("p-4", className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
@ -49,8 +49,8 @@ export const ConversationScrollButton = ({
!isAtBottom && (
<Button
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg',
className,
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg",
className
)}
onClick={handleScrollToBottom}
size="icon"

View file

@ -1,5 +1,5 @@
import { cn } from '@/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
import type { Experimental_GeneratedImage } from "ai";
import { cn } from "@/lib/utils";
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
@ -12,13 +12,14 @@ export const Image = ({
mediaType,
...props
}: ImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size"
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className,
"h-auto max-w-full overflow-hidden rounded-md",
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>

View file

@ -1,19 +1,6 @@
'use client';
"use client";
import { Badge } from '@/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
@ -21,28 +8,41 @@ import {
useContext,
useEffect,
useState,
} from 'react';
} from "react";
import { Badge } from "@/components/ui/badge";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
export type InlineCitationProps = ComponentProps<'span'>;
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn('group inline items-center gap-1', className)}
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<'span'>;
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn('transition-colors group-hover:bg-accent', className)}
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
@ -64,29 +64,29 @@ export const InlineCitationCardTrigger = ({
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{' '}
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
@ -107,32 +107,32 @@ export const InlineCitationCarousel = ({
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps,
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn('w-full space-y-2 p-4 pl-8', className)}
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
@ -140,14 +140,14 @@ export const InlineCitationCarouselHeader = ({
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className,
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
export const InlineCitationCarouselIndex = ({
children,
@ -166,7 +166,7 @@ export const InlineCitationCarouselIndex = ({
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
@ -174,8 +174,8 @@ export const InlineCitationCarouselIndex = ({
return (
<div
className={cn(
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className,
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
@ -184,7 +184,7 @@ export const InlineCitationCarouselIndex = ({
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
@ -201,7 +201,7 @@ export const InlineCitationCarouselPrev = ({
return (
<button
aria-label="Previous"
className={cn('shrink-0', className)}
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
@ -211,7 +211,7 @@ export const InlineCitationCarouselPrev = ({
);
};
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
@ -228,7 +228,7 @@ export const InlineCitationCarouselNext = ({
return (
<button
aria-label="Next"
className={cn('shrink-0', className)}
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
@ -238,7 +238,7 @@ export const InlineCitationCarouselNext = ({
);
};
export type InlineCitationSourceProps = ComponentProps<'div'> & {
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
@ -252,7 +252,7 @@ export const InlineCitationSource = ({
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
@ -268,7 +268,7 @@ export const InlineCitationSource = ({
</div>
);
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
@ -277,8 +277,8 @@ export const InlineCitationQuote = ({
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className,
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>

View file

@ -1,5 +1,5 @@
import { cn } from '@/lib/utils';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type LoaderIconProps = {
size?: number;
@ -9,7 +9,7 @@ const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: 'currentcolor' }}
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
@ -86,8 +86,8 @@ export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className,
"inline-flex animate-spin items-center justify-center",
className
)}
{...props}
>

View file

@ -1,19 +1,19 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import type { ComponentProps, HTMLAttributes } from 'react';
import type { UIMessage } from "ai";
import type { ComponentProps, HTMLAttributes } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className,
"group flex w-full items-end justify-end gap-2 py-4",
from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end",
"[&>div]:max-w-[80%]",
className
)}
{...props}
/>
@ -28,11 +28,11 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
'is-user:dark',
className,
"flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm",
"group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
"group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
"is-user:dark",
className
)}
{...props}
>
@ -51,8 +51,8 @@ export const MessageAvatar = ({
className,
...props
}: MessageAvatarProps) => (
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
<Avatar className={cn("size-8 ring-1 ring-border", className)} {...props}>
<AvatarImage alt="" className="my-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
<AvatarFallback>{name?.slice(0, 2) || "ME"}</AvatarFallback>
</Avatar>
);

View file

@ -1,31 +1,31 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import type { ChatStatus } from "ai";
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from "lucide-react";
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from "react";
import { Children } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { ChatStatus } from 'ai';
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { Children } from 'react';
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
'w-full overflow-hidden rounded-xl border bg-background shadow-xs',
className,
"w-full overflow-hidden rounded-xl border bg-background shadow-xs",
className
)}
{...props}
/>
@ -41,7 +41,7 @@ export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
placeholder = "What would you like to know?",
minHeight = 48,
maxHeight = 164,
disableAutoResize = false,
@ -49,7 +49,7 @@ export const PromptInputTextarea = ({
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
@ -72,15 +72,15 @@ export const PromptInputTextarea = ({
return (
<Textarea
className={cn(
'w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0',
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
disableAutoResize
? 'field-sizing-fixed'
? "field-sizing-fixed"
: resizeOnNewLinesOnly
? 'field-sizing-fixed'
: 'field-sizing-content max-h-[6lh]',
'bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className,
? "field-sizing-fixed"
: "field-sizing-content max-h-[6lh]",
"bg-transparent dark:bg-transparent",
"focus-visible:ring-0",
className
)}
name="message"
onChange={(e) => {
@ -100,7 +100,7 @@ export const PromptInputToolbar = ({
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
className={cn("flex items-center justify-between p-1", className)}
{...props}
/>
);
@ -113,9 +113,9 @@ export const PromptInputTools = ({
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className,
"flex items-center gap-1",
"[&_button:first-child]:rounded-bl-xl",
className
)}
{...props}
/>
@ -124,21 +124,21 @@ export const PromptInputTools = ({
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
variant = "ghost",
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
(size ?? Children.count(props.children) > 1) ? "default" : "icon";
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className,
"shrink-0 gap-1.5 rounded-lg",
variant === "ghost" && "text-muted-foreground",
newSize === "default" && "px-3",
className
)}
size={newSize}
type="button"
@ -154,25 +154,25 @@ export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
variant = "default",
size = "icon",
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
className={cn("gap-1.5 rounded-lg", className)}
size={size}
type="submit"
variant={variant}
@ -199,10 +199,10 @@ export const PromptInputModelSelectTrigger = ({
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
'h-auto px-2 py-1.5',
className,
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
"h-auto px-2 py-1.5",
className
)}
{...props}
/>

View file

@ -1,16 +1,16 @@
'use client';
"use client";
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { Response } from "./response";
type ReasoningContextValue = {
isStreaming: boolean;
@ -24,7 +24,7 @@ const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error('Reasoning components must be used within Reasoning');
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
@ -98,7 +98,7 @@ export const Reasoning = memo(
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose', className)}
className={cn("not-prose", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
@ -107,7 +107,7 @@ export const Reasoning = memo(
</Collapsible>
</ReasoningContext.Provider>
);
},
}
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
@ -119,8 +119,8 @@ export const ReasoningTrigger = memo(
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground',
className,
"flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground",
className
)}
{...props}
>
@ -134,15 +134,15 @@ export const ReasoningTrigger = memo(
)}
<ChevronDownIcon
className={cn(
'size-3 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0',
"size-3 text-muted-foreground transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
},
}
);
export type ReasoningContentProps = ComponentProps<
@ -155,17 +155,17 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-2 text-muted-foreground text-xs',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"mt-2 text-muted-foreground text-xs",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
),
)
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View file

@ -1,8 +1,8 @@
'use client';
"use client";
import { cn } from '@/lib/utils';
import { type ComponentProps, memo } from 'react';
import { Streamdown } from 'streamdown';
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown>;
@ -10,13 +10,13 @@ export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto',
className,
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = 'Response';
Response.displayName = "Response";

View file

@ -1,19 +1,19 @@
'use client';
"use client";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BookIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type SourcesProps = ComponentProps<'div'>;
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn('not-prose mb-4 text-primary text-xs', className)}
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
@ -46,15 +46,15 @@ export const SourcesContent = ({
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<'a'>;
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a

View file

@ -1,9 +1,9 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
@ -13,14 +13,14 @@ export const Suggestions = ({
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
@ -29,8 +29,8 @@ export const Suggestion = ({
suggestion,
onClick,
className,
variant = 'outline',
size = 'sm',
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
@ -40,7 +40,7 @@ export const Suggestion = ({
return (
<Button
className={cn('cursor-pointer rounded-full px-4', className)}
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"

View file

@ -1,15 +1,15 @@
'use client';
"use client";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type TaskItemFileProps = ComponentProps<'div'>;
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
@ -18,8 +18,8 @@ export const TaskItemFile = ({
}: TaskItemFileProps) => (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
className,
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
@ -27,10 +27,10 @@ export const TaskItemFile = ({
</div>
);
export type TaskItemProps = ComponentProps<'div'>;
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
@ -44,8 +44,8 @@ export const Task = ({
}: TaskProps) => (
<Collapsible
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
defaultOpen={defaultOpen}
{...props}
@ -62,7 +62,7 @@ export const TaskTrigger = ({
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
@ -82,8 +82,8 @@ export const TaskContent = ({
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>

View file

@ -1,13 +1,6 @@
'use client';
"use client";
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
@ -15,38 +8,45 @@ import {
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart['type'];
state: ToolUIPart['state'];
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
};
const getStatusBadge = (status: ToolUIPart['state']) => {
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
"input-streaming": "Pending",
"input-available": "Running",
"output-available": "Completed",
"output-error": "Error",
} as const;
const icons = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
@ -68,8 +68,8 @@ export const ToolHeader = ({
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'flex w-full min-w-0 items-center justify-between gap-2 p-3',
className,
"flex w-full min-w-0 items-center justify-between gap-2 p-3",
className
)}
{...props}
>
@ -89,19 +89,19 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
@ -111,9 +111,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
</div>
);
export type ToolOutputProps = ComponentProps<'div'> & {
export type ToolOutputProps = ComponentProps<"div"> & {
output: ReactNode;
errorText: ToolUIPart['errorText'];
errorText: ToolUIPart["errorText"];
};
export const ToolOutput = ({
@ -127,16 +127,16 @@ export const ToolOutput = ({
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? 'Error' : 'Result'}
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground',
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}

View file

@ -1,22 +1,22 @@
'use client';
"use client";
import { Button } from '@/components/ui/button';
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type WebPreviewContextValue = {
url: string;
@ -30,12 +30,12 @@ const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error('WebPreview components must be used within a WebPreview');
throw new Error("WebPreview components must be used within a WebPreview");
}
return context;
};
export type WebPreviewProps = ComponentProps<'div'> & {
export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
@ -43,7 +43,7 @@ export type WebPreviewProps = ComponentProps<'div'> & {
export const WebPreview = ({
className,
children,
defaultUrl = '',
defaultUrl = "",
onUrlChange,
...props
}: WebPreviewProps) => {
@ -66,8 +66,8 @@ export const WebPreview = ({
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
'flex size-full flex-col rounded-lg border bg-card',
className,
"flex size-full flex-col rounded-lg border bg-card",
className
)}
{...props}
>
@ -77,7 +77,7 @@ export const WebPreview = ({
);
};
export type WebPreviewNavigationProps = ComponentProps<'div'>;
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
@ -85,7 +85,7 @@ export const WebPreviewNavigation = ({
...props
}: WebPreviewNavigationProps) => (
<div
className={cn('flex items-center gap-1 border-b p-2', className)}
className={cn("flex items-center gap-1 border-b p-2", className)}
{...props}
>
{children}
@ -135,7 +135,7 @@ export const WebPreviewUrl = ({
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
@ -154,7 +154,7 @@ export const WebPreviewUrl = ({
);
};
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode;
};
@ -169,7 +169,7 @@ export const WebPreviewBody = ({
return (
<div className="flex-1">
<iframe
className={cn('size-full', className)}
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
@ -180,9 +180,9 @@ export const WebPreviewBody = ({
);
};
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: Array<{
level: 'log' | 'warn' | 'error';
level: "log" | "warn" | "error";
message: string;
timestamp: Date;
}>;
@ -198,7 +198,7 @@ export const WebPreviewConsole = ({
return (
<Collapsible
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
@ -211,16 +211,16 @@ export const WebPreviewConsole = ({
Console
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180',
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
@ -230,16 +230,16 @@ export const WebPreviewConsole = ({
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground',
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
</span>{" "}
{log.message}
</div>
))

View file

@ -1,26 +1,26 @@
import { motion } from 'framer-motion';
import { motion } from "framer-motion";
export const Greeting = () => {
return (
<div
key="overview"
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
key="overview"
>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }}
className="font-semibold text-xl md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }}
>
Hello there!
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
className="text-xl text-zinc-500 md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
>
How can I help you today?
</motion.div>

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
import { LoaderIcon } from './icons';
import cn from 'classnames';
import cn from "classnames";
import { LoaderIcon } from "./icons";
interface ImageEditorProps {
type ImageEditorProps = {
title: string;
content: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
status: string;
isInline: boolean;
}
};
export function ImageEditor({
title,
@ -18,12 +18,12 @@ export function ImageEditor({
}: ImageEditorProps) {
return (
<div
className={cn('flex w-full flex-row items-center justify-center', {
'h-[calc(100dvh-60px)]': !isInline,
'h-[200px]': isInline,
className={cn("flex w-full flex-row items-center justify-center", {
"h-[calc(100dvh-60px)]": !isInline,
"h-[200px]": isInline,
})}
>
{status === 'streaming' ? (
{status === "streaming" ? (
<div className="flex flex-row items-center gap-4">
{!isInline && (
<div className="animate-spin">
@ -34,12 +34,13 @@ export function ImageEditor({
</div>
) : (
<picture>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
className={cn('h-fit w-full max-w-[800px]', {
'p-0 md:p-20': !isInline,
alt={title}
className={cn("h-fit w-full max-w-[800px]", {
"p-0 md:p-20": !isInline,
})}
src={`data:image/png;base64,${content}`}
alt={title}
/>
</picture>
)}

View file

@ -1,14 +1,12 @@
import { useSWRConfig } from 'swr';
import { useCopyToClipboard } from 'usehooks-ts';
import type { Vote } from '@/lib/db/schema';
import { CopyIcon, ThumbDownIcon, ThumbUpIcon, PencilEditIcon } from './icons';
import { Actions, Action } from './elements/actions';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { toast } from 'sonner';
import type { ChatMessage } from '@/lib/types';
import equal from "fast-deep-equal";
import { memo } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { useCopyToClipboard } from "usehooks-ts";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { Action, Actions } from "./elements/actions";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
chatId,
@ -21,17 +19,19 @@ export function PureMessageActions({
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMode?: (mode: 'view' | 'edit') => void;
setMode?: (mode: "view" | "edit") => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) return null;
if (isLoading) {
return null;
}
const textFromParts = message.parts
?.filter((part) => part.type === 'text')
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join('\n')
.join("\n")
.trim();
const handleCopy = async () => {
@ -41,24 +41,24 @@ export function PureMessageActions({
}
await copyToClipboard(textFromParts);
toast.success('Copied to clipboard!');
toast.success("Copied to clipboard!");
};
// User messages get edit (on hover) and copy actions
if (message.role === 'user') {
if (message.role === "user") {
return (
<Actions className="-mr-0.5 justify-end">
<div className="relative">
{setMode && (
<Action
tooltip="Edit"
onClick={() => setMode('edit')}
className="-left-10 absolute top-0 opacity-0 transition-opacity group-hover/message:opacity-100"
onClick={() => setMode("edit")}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action tooltip="Copy" onClick={handleCopy}>
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
</div>
@ -68,34 +68,35 @@ export function PureMessageActions({
return (
<Actions className="-ml-0.5">
<Action tooltip="Copy" onClick={handleCopy}>
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
<Action
tooltip="Upvote Response"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={async () => {
const upvote = fetch('/api/vote', {
method: 'PATCH',
onClick={() => {
const upvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: 'up',
type: "up",
}),
});
toast.promise(upvote, {
loading: 'Upvoting Response...',
loading: "Upvoting Response...",
success: () => {
mutate<Array<Vote>>(
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id,
(currentVote) => currentVote.messageId !== message.id
);
return [
@ -107,42 +108,44 @@ export function PureMessageActions({
},
];
},
{ revalidate: false },
{ revalidate: false }
);
return 'Upvoted Response!';
return "Upvoted Response!";
},
error: 'Failed to upvote response.',
error: "Failed to upvote response.",
});
}}
tooltip="Upvote Response"
>
<ThumbUpIcon />
</Action>
<Action
tooltip="Downvote Response"
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={async () => {
const downvote = fetch('/api/vote', {
method: 'PATCH',
onClick={() => {
const downvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: 'down',
type: "down",
}),
});
toast.promise(downvote, {
loading: 'Downvoting Response...',
loading: "Downvoting Response...",
success: () => {
mutate<Array<Vote>>(
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id,
(currentVote) => currentVote.messageId !== message.id
);
return [
@ -154,14 +157,15 @@ export function PureMessageActions({
},
];
},
{ revalidate: false },
{ revalidate: false }
);
return 'Downvoted Response!';
return "Downvoted Response!";
},
error: 'Failed to downvote response.',
error: "Failed to downvote response.",
});
}}
tooltip="Downvote Response"
>
<ThumbDownIcon />
</Action>
@ -172,9 +176,13 @@ export function PureMessageActions({
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) return false;
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
return true;
},
}
);

View file

@ -1,24 +1,25 @@
'use client';
"use client";
import { Button } from './ui/button';
import type { UseChatHelpers } from "@ai-sdk/react";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Textarea } from './ui/textarea';
import { deleteTrailingMessages } from '@/app/(chat)/actions';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
import { getTextFromMessage } from '@/lib/utils';
} from "react";
import { deleteTrailingMessages } from "@/app/(chat)/actions";
import type { ChatMessage } from "@/lib/types";
import { getTextFromMessage } from "@/lib/utils";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
export type MessageEditorProps = {
message: ChatMessage;
setMode: Dispatch<SetStateAction<'view' | 'edit'>>;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
setMode: Dispatch<SetStateAction<"view" | "edit">>;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
};
export function MessageEditor({
@ -30,22 +31,22 @@ export function MessageEditor({
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [draftContent, setDraftContent] = useState<string>(
getTextFromMessage(message),
getTextFromMessage(message)
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, []);
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
};
}, [adjustHeight]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftContent(event.target.value);
@ -55,27 +56,26 @@ export function MessageEditor({
return (
<div className="flex w-full flex-col gap-2">
<Textarea
data-testid="message-editor"
ref={textareaRef}
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
value={draftContent}
data-testid="message-editor"
onChange={handleInput}
ref={textareaRef}
value={draftContent}
/>
<div className="flex flex-row justify-end gap-2">
<Button
variant="outline"
className="h-fit px-3 py-2"
onClick={() => {
setMode('view');
setMode("view");
}}
variant="outline"
>
Cancel
</Button>
<Button
data-testid="message-editor-send-button"
variant="default"
className="h-fit px-3 py-2"
data-testid="message-editor-send-button"
disabled={isSubmitting}
onClick={async () => {
setIsSubmitting(true);
@ -90,7 +90,7 @@ export function MessageEditor({
if (index !== -1) {
const updatedMessage: ChatMessage = {
...message,
parts: [{ type: 'text', text: draftContent }],
parts: [{ type: "text", text: draftContent }],
};
return [...messages.slice(0, index), updatedMessage];
@ -99,11 +99,12 @@ export function MessageEditor({
return messages;
});
setMode('view');
setMode("view");
regenerate();
}}
variant="default"
>
{isSubmitting ? 'Sending...' : 'Send'}
{isSubmitting ? "Sending..." : "Send"}
</Button>
</div>
</div>

View file

@ -1,16 +1,16 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { useEffect, useState } from "react";
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from './elements/reasoning';
ReasoningTrigger,
} from "./elements/reasoning";
interface MessageReasoningProps {
type MessageReasoningProps = {
isLoading: boolean;
reasoning: string;
}
};
export function MessageReasoning({
isLoading,
@ -23,12 +23,12 @@ export function MessageReasoning({
setHasBeenStreaming(true);
}
}, [isLoading]);
return (
<Reasoning
isStreaming={isLoading}
defaultOpen={hasBeenStreaming}
data-testid="message-reasoning"
defaultOpen={hasBeenStreaming}
isStreaming={isLoading}
>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>

View file

@ -1,29 +1,29 @@
'use client';
import { motion } from 'framer-motion';
import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import { DocumentToolResult } from './document';
import { SparklesIcon } from './icons';
import { Response } from './elements/response';
import { MessageContent } from './elements/message';
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
import { MessageContent } from "./elements/message";
import { Response } from "./elements/response";
import {
Tool,
ToolHeader,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from './elements/tool';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
import equal from 'fast-deep-equal';
import { cn, sanitizeText } from '@/lib/utils';
import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
import { MessageReasoning } from './message-reasoning';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
} from "./elements/tool";
import { SparklesIcon } from "./icons";
import { MessageActions } from "./message-actions";
import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
const PurePreviewMessage = ({
chatId,
@ -34,75 +34,73 @@ const PurePreviewMessage = ({
regenerate,
isReadonly,
requiresScrollPadding,
isArtifactVisible,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
requiresScrollPadding: boolean;
isArtifactVisible: boolean;
}) => {
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [mode, setMode] = useState<"view" | "edit">("view");
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === 'file',
(part) => part.type === "file"
);
useDataStream();
return (
<motion.div
data-testid={`message-${message.role}`}
className="group/message w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="group/message w-full"
data-role={message.role}
data-testid={`message-${message.role}`}
initial={{ opacity: 0 }}
>
<div
className={cn('flex w-full items-start gap-2 md:gap-3', {
'justify-end': message.role === 'user' && mode !== 'edit',
'justify-start': message.role === 'assistant',
className={cn("flex w-full items-start gap-2 md:gap-3", {
"justify-end": message.role === "user" && mode !== "edit",
"justify-start": message.role === "assistant",
})}
>
{message.role === 'assistant' && (
{message.role === "assistant" && (
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
</div>
)}
<div
className={cn('flex flex-col', {
'gap-2 md:gap-4': message.parts?.some(
(p) => p.type === 'text' && p.text?.trim(),
className={cn("flex flex-col", {
"gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
),
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
'w-full':
(message.role === 'assistant' &&
"min-h-96": message.role === "assistant" && requiresScrollPadding,
"w-full":
(message.role === "assistant" &&
message.parts?.some(
(p) => p.type === 'text' && p.text?.trim(),
(p) => p.type === "text" && p.text?.trim()
)) ||
mode === 'edit',
'max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]':
message.role === 'user' && mode !== 'edit',
mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit",
})}
>
{attachmentsFromMessage.length > 0 && (
<div
data-testid={`message-attachments`}
className="flex flex-row justify-end gap-2"
data-testid={"message-attachments"}
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={{
name: attachment.filename ?? 'file',
name: attachment.filename ?? "file",
contentType: attachment.mediaType,
url: attachment.url,
}}
key={attachment.url}
/>
))}
</div>
@ -112,31 +110,31 @@ const PurePreviewMessage = ({
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === 'reasoning' && part.text?.trim().length > 0) {
if (type === "reasoning" && part.text?.trim().length > 0) {
return (
<MessageReasoning
key={key}
isLoading={isLoading}
key={key}
reasoning={part.text}
/>
);
}
if (type === 'text') {
if (mode === 'view') {
if (type === "text") {
if (mode === "view") {
return (
<div key={key}>
<MessageContent
data-testid="message-content"
className={cn({
'w-fit break-words rounded-2xl px-3 py-2 text-right text-white':
message.role === 'user',
'bg-transparent px-0 py-0 text-left':
message.role === 'assistant',
"w-fit break-words rounded-2xl px-3 py-2 text-right text-white":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
style={
message.role === 'user'
? { backgroundColor: '#006cff' }
message.role === "user"
? { backgroundColor: "#006cff" }
: undefined
}
>
@ -146,20 +144,20 @@ const PurePreviewMessage = ({
);
}
if (mode === 'edit') {
if (mode === "edit") {
return (
<div
key={key}
className="flex w-full flex-row items-start gap-3"
key={key}
>
<div className="size-8" />
<div className="min-w-0 flex-1">
<MessageEditor
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
regenerate={regenerate}
setMessages={setMessages}
setMode={setMode}
/>
</div>
</div>
@ -167,20 +165,20 @@ const PurePreviewMessage = ({
}
}
if (type === 'tool-getWeather') {
if (type === "tool-getWeather") {
const { toolCallId, state } = part;
return (
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-getWeather" state={state} />
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
{state === 'input-available' && (
{state === "input-available" && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
{state === "output-available" && (
<ToolOutput
output={<Weather weatherAtLocation={part.output} />}
errorText={undefined}
output={<Weather weatherAtLocation={part.output} />}
/>
)}
</ToolContent>
@ -188,14 +186,14 @@ const PurePreviewMessage = ({
);
}
if (type === 'tool-createDocument') {
if (type === "tool-createDocument") {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
if (part.output && "error" in part.output) {
return (
<div
key={toolCallId}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error creating document: {String(part.output.error)}
</div>
@ -204,21 +202,21 @@ const PurePreviewMessage = ({
return (
<DocumentPreview
key={toolCallId}
isReadonly={isReadonly}
key={toolCallId}
result={part.output}
/>
);
}
if (type === 'tool-updateDocument') {
if (type === "tool-updateDocument") {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
if (part.output && "error" in part.output) {
return (
<div
key={toolCallId}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error updating document: {String(part.output.error)}
</div>
@ -226,58 +224,60 @@ const PurePreviewMessage = ({
}
return (
<div key={toolCallId} className="relative">
<div className="relative" key={toolCallId}>
<DocumentPreview
args={{ ...part.output, isUpdate: true }}
isReadonly={isReadonly}
result={part.output}
args={{ ...part.output, isUpdate: true }}
/>
</div>
);
}
if (type === 'tool-requestSuggestions') {
if (type === "tool-requestSuggestions") {
const { toolCallId, state } = part;
return (
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-requestSuggestions" state={state} />
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-requestSuggestions" />
<ToolContent>
{state === 'input-available' && (
{state === "input-available" && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
{state === "output-available" && (
<ToolOutput
errorText={undefined}
output={
'error' in part.output ? (
"error" in part.output ? (
<div className="rounded border p-2 text-red-500">
Error: {String(part.output.error)}
</div>
) : (
<DocumentToolResult
type="request-suggestions"
result={part.output}
isReadonly={isReadonly}
result={part.output}
type="request-suggestions"
/>
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
key={`action-${message.id}`}
message={message}
setMode={setMode}
vote={vote}
/>
)}
</div>
@ -289,27 +289,36 @@ const PurePreviewMessage = ({
export const PreviewMessage = memo(
PurePreviewMessage,
(prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.message.id !== nextProps.message.id) return false;
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding)
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
if (!equal(prevProps.vote, nextProps.vote)) return false;
}
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding) {
return false;
}
if (!equal(prevProps.message.parts, nextProps.message.parts)) {
return false;
}
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
return false;
},
}
);
export const ThinkingMessage = () => {
const role = 'assistant';
const role = "assistant";
return (
<motion.div
data-testid="message-assistant-loading"
className="group/message w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="group/message w-full"
data-role={role}
data-testid="message-assistant-loading"
initial={{ opacity: 0 }}
>
<div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
@ -329,20 +338,20 @@ export const ThinkingMessage = () => {
const LoadingText = ({ children }: { children: React.ReactNode }) => {
return (
<motion.div
animate={{ backgroundPosition: ['100% 50%', '-100% 50%'] }}
animate={{ backgroundPosition: ["100% 50%", "-100% 50%"] }}
className="flex items-center text-transparent"
style={{
background:
"linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 35%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 65%, hsl(var(--muted-foreground)) 100%)",
backgroundSize: "200% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
ease: "linear",
}}
style={{
background:
'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 35%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 65%, hsl(var(--muted-foreground)) 100%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
}}
className="flex items-center text-transparent"
>
{children}
</motion.div>

View file

@ -1,26 +1,26 @@
import { PreviewMessage, ThinkingMessage } from './message';
import { Greeting } from './greeting';
import { memo, useEffect } from 'react';
import type { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
import { Conversation, ConversationContent } from './elements/conversation';
import { ArrowDownIcon } from 'lucide-react';
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { Conversation, ConversationContent } from "./elements/conversation";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
interface MessagesProps {
type MessagesProps = {
chatId: string;
status: UseChatHelpers<ChatMessage>['status'];
votes: Array<Vote> | undefined;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
}
};
function PureMessages({
chatId,
@ -30,7 +30,6 @@ function PureMessages({
setMessages,
regenerate,
isReadonly,
isArtifactVisible,
selectedModelId,
}: MessagesProps) {
const {
@ -40,20 +39,19 @@ function PureMessages({
scrollToBottom,
hasSentMessage,
} = useMessages({
chatId,
status,
});
useDataStream();
useEffect(() => {
if (status === 'submitted') {
if (status === "submitted") {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
behavior: "smooth",
});
}
});
@ -62,9 +60,9 @@ function PureMessages({
return (
<div
ref={messagesContainerRef}
className="overscroll-behavior-contain -webkit-overflow-scrolling-touch flex-1 touch-pan-y overflow-y-scroll"
style={{ overflowAnchor: 'none' }}
ref={messagesContainerRef}
style={{ overflowAnchor: "none" }}
>
<Conversation className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 md:gap-6">
<ConversationContent className="flex flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
@ -72,45 +70,44 @@ function PureMessages({
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
chatId={chatId}
message={message}
isLoading={
status === 'streaming' && messages.length - 1 === index
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
isArtifactVisible={isArtifactVisible}
/>
))}
{status === 'submitted' &&
{status === "submitted" &&
messages.length > 0 &&
messages[messages.length - 1].role === 'user' &&
selectedModelId !== 'chat-model-reasoning' && <ThinkingMessage />}
messages.at(-1)?.role === "user" &&
selectedModelId !== "chat-model-reasoning" && <ThinkingMessage />}
<div
ref={messagesEndRef}
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</ConversationContent>
</Conversation>
{!isAtBottom && (
<button
className="-translate-x-1/2 absolute bottom-40 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-colors hover:bg-muted"
onClick={() => scrollToBottom('smooth')}
type="button"
aria-label="Scroll to bottom"
className="-translate-x-1/2 absolute bottom-40 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-colors hover:bg-muted"
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
@ -120,13 +117,25 @@ function PureMessages({
}
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true;
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.status !== nextProps.status) return false;
if (prevProps.selectedModelId !== nextProps.selectedModelId) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.messages, nextProps.messages)) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages)) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return false;
});

View file

@ -1,21 +1,19 @@
'use client';
"use client";
import { startTransition, useMemo, useOptimistic, useState } from 'react';
import { saveChatModelAsCookie } from '@/app/(chat)/actions';
import { Button } from '@/components/ui/button';
import type { Session } from "next-auth";
import { startTransition, useMemo, useOptimistic, useState } from "react";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { chatModels } from '@/lib/ai/models';
import { cn } from '@/lib/utils';
import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
import type { Session } from 'next-auth';
} from "@/components/ui/dropdown-menu";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { chatModels } from "@/lib/ai/models";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, ChevronDownIcon } from "./icons";
export function ModelSelector({
session,
@ -33,30 +31,30 @@ export function ModelSelector({
const { availableChatModelIds } = entitlementsByUserType[userType];
const availableChatModels = chatModels.filter((chatModel) =>
availableChatModelIds.includes(chatModel.id),
availableChatModelIds.includes(chatModel.id)
);
const selectedChatModel = useMemo(
() =>
availableChatModels.find(
(chatModel) => chatModel.id === optimisticModelId,
(chatModel) => chatModel.id === optimisticModelId
),
[optimisticModelId, availableChatModels],
[optimisticModelId, availableChatModels]
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger
asChild
className={cn(
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className,
"w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
>
<Button
className="md:h-[34px] md:px-2"
data-testid="model-selector"
variant="outline"
className="md:h-[34px] md:px-2"
>
{selectedChatModel?.name}
<ChevronDownIcon />
@ -71,6 +69,8 @@ export function ModelSelector({
return (
<DropdownMenuItem
asChild
data-active={id === optimisticModelId}
data-testid={`model-selector-item-${id}`}
key={id}
onSelect={() => {
@ -81,16 +81,14 @@ export function ModelSelector({
saveChatModelAsCookie(id);
});
}}
data-active={id === optimisticModelId}
asChild
>
<button
className="group/item flex w-full flex-row items-center justify-between gap-2 sm:gap-4"
type="button"
className="flex flex-row gap-2 justify-between items-center w-full group/item sm:gap-4"
>
<div className="flex flex-col gap-1 items-start">
<div className="flex flex-col items-start gap-1">
<div className="text-sm sm:text-base">{chatModel.name}</div>
<div className="text-xs line-clamp-2 text-muted-foreground">
<div className="line-clamp-2 text-muted-foreground text-xs">
{chatModel.description}
</div>
</div>
@ -105,4 +103,4 @@ export function ModelSelector({
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View file

@ -1,51 +1,51 @@
'use client';
"use client";
import type { UIMessage } from 'ai';
import type { UseChatHelpers } from "@ai-sdk/react";
import { Trigger } from "@radix-ui/react-select";
import type { UIMessage } from "ai";
import equal from "fast-deep-equal";
import {
useRef,
useEffect,
useState,
useCallback,
type Dispatch,
type SetStateAction,
type ChangeEvent,
type Dispatch,
memo,
type SetStateAction,
startTransition,
useCallback,
useEffect,
useMemo,
} from 'react';
import { toast } from 'sonner';
import { useLocalStorage, useWindowSize } from 'usehooks-ts';
import {
ArrowUpIcon,
PaperclipIcon,
CpuIcon,
StopIcon,
ChevronDownIcon,
} from './icons';
import { PreviewAttachment } from './preview-attachment';
import { Button } from './ui/button';
import { SuggestedActions } from './suggested-actions';
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { SelectItem } from "@/components/ui/select";
import { chatModels } from "@/lib/ai/models";
import { myProvider } from "@/lib/ai/providers";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
import { Context } from "./elements/context";
import {
PromptInput,
PromptInputModelSelect,
PromptInputModelSelectContent,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
PromptInputSubmit,
PromptInputModelSelect,
PromptInputModelSelectContent,
} from './elements/prompt-input';
import { SelectItem } from '@/components/ui/select';
import * as SelectPrimitive from '@radix-ui/react-select';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { Attachment, ChatMessage } from '@/lib/types';
import type { AppUsage } from '@/lib/usage';
import { chatModels } from '@/lib/ai/models';
import { saveChatModelAsCookie } from '@/app/(chat)/actions';
import { startTransition } from 'react';
import { Context } from './elements/context';
import { myProvider } from '@/lib/ai/providers';
} from "./elements/prompt-input";
import {
ArrowUpIcon,
ChevronDownIcon,
CpuIcon,
PaperclipIcon,
StopIcon,
} from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
function PureMultimodalInput({
chatId,
@ -67,13 +67,13 @@ function PureMultimodalInput({
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>['status'];
status: UseChatHelpers<ChatMessage>["status"];
stop: () => void;
attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
messages: Array<UIMessage>;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
className?: string;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
@ -83,40 +83,40 @@ function PureMultimodalInput({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px';
}
};
const resetHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px';
}
};
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
'input',
'',
"input",
""
);
useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value;
// Prefer DOM value over localStorage to handle hydration
const finalValue = domValue || localStorageInput || '';
const finalValue = domValue || localStorageInput || "";
setInput(finalValue);
adjustHeight();
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [adjustHeight, localStorageInput, setInput]);
useEffect(() => {
setLocalStorageInput(input);
@ -127,31 +127,31 @@ function PureMultimodalInput({
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const submitForm = useCallback(() => {
window.history.replaceState({}, '', `/chat/${chatId}`);
window.history.replaceState({}, "", `/chat/${chatId}`);
sendMessage({
role: 'user',
role: "user",
parts: [
...attachments.map((attachment) => ({
type: 'file' as const,
type: "file" as const,
url: attachment.url,
name: attachment.name,
mediaType: attachment.contentType,
})),
{
type: 'text',
type: "text",
text: input,
},
],
});
setAttachments([]);
setLocalStorageInput('');
setLocalStorageInput("");
resetHeight();
setInput('');
setInput("");
if (width && width > 768) {
textareaRef.current?.focus();
@ -165,15 +165,16 @@ function PureMultimodalInput({
setLocalStorageInput,
width,
chatId,
resetHeight,
]);
const uploadFile = async (file: File) => {
const uploadFile = useCallback(async (file: File) => {
const formData = new FormData();
formData.append('file', file);
formData.append("file", file);
try {
const response = await fetch('/api/files/upload', {
method: 'POST',
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
@ -184,17 +185,17 @@ function PureMultimodalInput({
return {
url,
name: pathname,
contentType: contentType,
contentType,
};
}
const { error } = await response.json();
toast.error(error);
} catch (error) {
toast.error('Failed to upload file, please try again!');
} catch (_error) {
toast.error("Failed to upload file, please try again!");
}
};
}, []);
const modelResolver = useMemo(() => {
const _modelResolver = useMemo(() => {
return myProvider.languageModel(selectedModelId);
}, [selectedModelId]);
@ -202,7 +203,7 @@ function PureMultimodalInput({
() => ({
usage,
}),
[usage],
[usage]
);
const handleFileChange = useCallback(
@ -215,7 +216,7 @@ function PureMultimodalInput({
const uploadPromises = files.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined,
(attachment) => attachment !== undefined
);
setAttachments((currentAttachments) => [
@ -223,42 +224,41 @@ function PureMultimodalInput({
...successfullyUploadedAttachments,
]);
} catch (error) {
console.error('Error uploading files!', error);
console.error("Error uploading files!", error);
} finally {
setUploadQueue([]);
}
},
[setAttachments],
[setAttachments, uploadFile]
);
return (
<div className="flex relative flex-col gap-4 w-full">
<div className={cn("relative flex w-full flex-col gap-4", className)}>
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
sendMessage={sendMessage}
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
/>
)}
<input
type="file"
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
ref={fileInputRef}
multiple
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<PromptInput
className="p-3 rounded-xl border transition-all duration-200 border-border bg-background shadow-xs focus-within:border-border hover:border-muted-foreground/50"
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
onSubmit={(event) => {
event.preventDefault();
if (status !== 'ready') {
toast.error('Please wait for the model to finish its response!');
if (status !== "ready") {
toast.error("Please wait for the model to finish its response!");
} else {
submitForm();
}
@ -266,19 +266,19 @@ function PureMultimodalInput({
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
className="flex overflow-x-scroll flex-row gap-2 items-end"
>
{attachments.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={attachment}
key={attachment.url}
onRemove={() => {
setAttachments((currentAttachments) =>
currentAttachments.filter((a) => a.url !== attachment.url),
currentAttachments.filter((a) => a.url !== attachment.url)
);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
}}
/>
@ -286,50 +286,53 @@ function PureMultimodalInput({
{uploadQueue.map((filename) => (
<PreviewAttachment
key={filename}
attachment={{
url: '',
url: "",
name: filename,
contentType: '',
contentType: "",
}}
isUploading={true}
key={filename}
/>
))}
</div>
)}
<div className="flex flex-row gap-1 items-start sm:gap-2">
<div className="flex flex-row items-start gap-1 sm:gap-2">
<PromptInputTextarea
data-testid="multimodal-input"
ref={textareaRef}
placeholder="Send a message..."
value={input}
onChange={handleInput}
minHeight={44}
maxHeight={200}
disableAutoResize={true}
className="grow resize-none border-0! p-2 border-none! bg-transparent text-sm outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
rows={1}
autoFocus
/>{' '}
className="grow resize-none border-0! border-none! bg-transparent p-2 text-sm outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
data-testid="multimodal-input"
disableAutoResize={true}
maxHeight={200}
minHeight={44}
onChange={handleInput}
placeholder="Send a message..."
ref={textareaRef}
rows={1}
value={input}
/>{" "}
<Context {...contextProps} />
</div>
<PromptInputToolbar className="!border-top-0 border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5">
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
/>
<ModelSelectorCompact selectedModelId={selectedModelId} onModelChange={onModelChange} />
</PromptInputTools>
{status === 'submitted' ? (
<StopButton stop={stop} setMessages={setMessages} />
{status === "submitted" ? (
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
status={status}
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
disabled={!input.trim() || uploadQueue.length > 0}
className="rounded-full transition-colors duration-200 size-8 bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
status={status}
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
@ -343,15 +346,24 @@ function PureMultimodalInput({
export const MultimodalInput = memo(
PureMultimodalInput,
(prevProps, nextProps) => {
if (prevProps.input !== nextProps.input) return false;
if (prevProps.status !== nextProps.status) return false;
if (!equal(prevProps.attachments, nextProps.attachments)) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
if (prevProps.input !== nextProps.input) {
return false;
if (prevProps.selectedModelId !== nextProps.selectedModelId) return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.attachments, nextProps.attachments)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
return true;
},
}
);
function PureAttachmentsButton({
@ -360,20 +372,20 @@ function PureAttachmentsButton({
selectedModelId,
}: {
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
status: UseChatHelpers<ChatMessage>['status'];
status: UseChatHelpers<ChatMessage>["status"];
selectedModelId: string;
}) {
const isReasoningModel = selectedModelId === 'chat-model-reasoning';
const isReasoningModel = selectedModelId === "chat-model-reasoning";
return (
<Button
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
data-testid="attachments-button"
className="p-1 h-8 rounded-lg transition-colors aspect-square hover:bg-accent"
disabled={status !== "ready" || isReasoningModel}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
disabled={status !== 'ready' || isReasoningModel}
variant="ghost"
>
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
@ -397,12 +409,11 @@ function PureModelSelectorCompact({
}, [selectedModelId]);
const selectedModel = chatModels.find(
(model) => model.id === optimisticModelId,
(model) => model.id === optimisticModelId
);
return (
<PromptInputModelSelect
value={selectedModel?.name}
onValueChange={(modelName) => {
const model = chatModels.find((m) => m.name === modelName);
if (model) {
@ -413,30 +424,25 @@ function PureModelSelectorCompact({
});
}
}}
value={selectedModel?.name}
>
<SelectPrimitive.Trigger
<Trigger
className="flex h-8 items-center gap-2 rounded-lg border-0 bg-background px-2 text-foreground shadow-none transition-colors hover:bg-accent focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
type="button"
className="flex gap-2 items-center px-2 h-8 rounded-lg border-0 shadow-none transition-colors bg-background text-foreground hover:bg-accent focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<CpuIcon size={16} />
<span className="hidden text-xs font-medium sm:block">
<span className="hidden font-medium text-xs sm:block">
{selectedModel?.name}
</span>
<ChevronDownIcon size={16} />
</SelectPrimitive.Trigger>
</Trigger>
<PromptInputModelSelectContent className="min-w-[260px] p-0">
<div className="flex flex-col gap-px">
{chatModels.map((model) => (
<SelectItem
key={model.id}
value={model.name}
className="px-3 py-2 text-xs"
>
<div className="flex flex-col flex-1 gap-1 min-w-0">
<div className="text-xs font-medium truncate">{model.name}</div>
<div className="text-[10px] text-muted-foreground truncate leading-tight">
{model.description}
</div>
<SelectItem key={model.id} value={model.name}>
<div className="truncate font-medium text-xs">{model.name}</div>
<div className="mt-px truncate text-[10px] text-muted-foreground leading-tight">
{model.description}
</div>
</SelectItem>
))}
@ -453,12 +459,12 @@ function PureStopButton({
setMessages,
}: {
stop: () => void;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
}) {
return (
<Button
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="stop-button"
className="p-1 rounded-full transition-colors duration-200 size-7 bg-foreground text-background hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
onClick={(event) => {
event.preventDefault();
stop();

View file

@ -1,34 +1,32 @@
import type { Attachment } from '@/lib/types';
import { Loader } from './elements/loader';
import { CrossSmallIcon } from './icons';
import { Button } from './ui/button';
import Image from 'next/image';
import Image from "next/image";
import type { Attachment } from "@/lib/types";
import { Loader } from "./elements/loader";
import { CrossSmallIcon } from "./icons";
import { Button } from "./ui/button";
export const PreviewAttachment = ({
attachment,
isUploading = false,
onRemove,
onEdit,
}: {
attachment: Attachment;
isUploading?: boolean;
onRemove?: () => void;
onEdit?: () => void;
}) => {
const { name, url, contentType } = attachment;
return (
<div
data-testid="input-attachment-preview"
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
data-testid="input-attachment-preview"
>
{contentType?.startsWith('image') ? (
{contentType?.startsWith("image") ? (
<Image
src={url}
alt={name ?? 'An image attachment'}
alt={name ?? "An image attachment"}
className="size-full object-cover"
width={64}
height={64}
src={url}
width={64}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
@ -44,10 +42,10 @@ export const PreviewAttachment = ({
{onRemove && !isUploading && (
<Button
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
size="sm"
variant="destructive"
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<CrossSmallIcon size={8} />
</Button>

View file

@ -1,46 +1,43 @@
'use client';
"use client";
import React, { memo, useEffect, useMemo, useState } from 'react';
import DataGrid, { textEditor } from 'react-data-grid';
import { parse, unparse } from 'papaparse';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { useTheme } from "next-themes";
import { parse, unparse } from "papaparse";
import { memo, useEffect, useMemo, useState } from "react";
import DataGrid, { textEditor } from "react-data-grid";
import { cn } from "@/lib/utils";
import 'react-data-grid/lib/styles.css';
import "react-data-grid/lib/styles.css";
type SheetEditorProps = {
content: string;
saveContent: (content: string, isCurrentVersion: boolean) => void;
status: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
isCurrentVersion: boolean;
status: string;
};
const MIN_ROWS = 50;
const MIN_COLS = 26;
const PureSpreadsheetEditor = ({
content,
saveContent,
status,
isCurrentVersion,
}: SheetEditorProps) => {
const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const { resolvedTheme } = useTheme();
const parseData = useMemo(() => {
if (!content) return Array(MIN_ROWS).fill(Array(MIN_COLS).fill(''));
if (!content) {
return new Array(MIN_ROWS).fill(new Array(MIN_COLS).fill(""));
}
const result = parse<string[]>(content, { skipEmptyLines: true });
const paddedData = result.data.map((row) => {
const paddedRow = [...row];
while (paddedRow.length < MIN_COLS) {
paddedRow.push('');
paddedRow.push("");
}
return paddedRow;
});
while (paddedData.length < MIN_ROWS) {
paddedData.push(Array(MIN_COLS).fill(''));
paddedData.push(new Array(MIN_COLS).fill(""));
}
return paddedData;
@ -48,13 +45,13 @@ const PureSpreadsheetEditor = ({
const columns = useMemo(() => {
const rowNumberColumn = {
key: 'rowNumber',
name: '',
key: "rowNumber",
name: "",
frozen: true,
width: 50,
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
cellClass: 'border-t border-r dark:bg-zinc-950 dark:text-zinc-50',
headerCellClass: 'border-t border-r dark:bg-zinc-900 dark:text-zinc-50',
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
};
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
@ -62,11 +59,11 @@ const PureSpreadsheetEditor = ({
name: String.fromCharCode(65 + i),
renderEditCell: textEditor,
width: 120,
cellClass: cn(`border-t dark:bg-zinc-950 dark:text-zinc-50`, {
'border-l': i !== 0,
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
"border-l": i !== 0,
}),
headerCellClass: cn(`border-t dark:bg-zinc-900 dark:text-zinc-50`, {
'border-l': i !== 0,
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
"border-l": i !== 0,
}),
}));
@ -81,7 +78,7 @@ const PureSpreadsheetEditor = ({
};
columns.slice(1).forEach((col, colIndex) => {
rowData[col.key] = row[colIndex] || '';
rowData[col.key] = row[colIndex] || "";
});
return rowData;
@ -102,7 +99,7 @@ const PureSpreadsheetEditor = ({
setLocalRows(newRows);
const updatedData = newRows.map((row) => {
return columns.slice(1).map((col) => row[col.key] || '');
return columns.slice(1).map((col) => row[col.key] || "");
});
const newCsvContent = generateCsv(updatedData);
@ -111,21 +108,21 @@ const PureSpreadsheetEditor = ({
return (
<DataGrid
className={resolvedTheme === 'dark' ? 'rdg-dark' : 'rdg-light'}
className={resolvedTheme === "dark" ? "rdg-dark" : "rdg-light"}
columns={columns}
rows={localRows}
enableVirtualization
onRowsChange={handleRowsChange}
onCellClick={(args) => {
if (args.column.key !== 'rowNumber') {
args.selectCell(true);
}
}}
style={{ height: '100%' }}
defaultColumnOptions={{
resizable: true,
sortable: true,
}}
enableVirtualization
onCellClick={(args) => {
if (args.column.key !== "rowNumber") {
args.selectCell(true);
}
}}
onRowsChange={handleRowsChange}
rows={localRows}
style={{ height: "100%" }}
/>
);
};
@ -134,7 +131,7 @@ function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) {
return (
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
prevProps.content === nextProps.content &&
prevProps.saveContent === nextProps.saveContent
);

View file

@ -1,10 +1,15 @@
import type { Chat } from '@/lib/db/schema';
import Link from "next/link";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from './ui/sidebar';
import Link from 'next/link';
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
import {
DropdownMenu,
DropdownMenuContent,
@ -14,17 +19,12 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
} from "./ui/dropdown-menu";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from './icons';
import { memo } from 'react';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "./ui/sidebar";
const PureChatItem = ({
chat,
@ -61,7 +61,7 @@ const PureChatItem = ({
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
@ -72,28 +72,28 @@ const PureChatItem = ({
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType('private');
setVisibilityType("private");
}}
>
<div className="flex flex-row items-center gap-2">
<LockIcon size={12} />
<span>Private</span>
</div>
{visibilityType === 'private' ? (
{visibilityType === "private" ? (
<CheckCircleFillIcon />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType('public');
setVisibilityType("public");
}}
>
<div className="flex flex-row items-center gap-2">
<GlobeIcon />
<span>Public</span>
</div>
{visibilityType === 'public' ? <CheckCircleFillIcon /> : null}
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
@ -113,6 +113,8 @@ const PureChatItem = ({
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) return false;
if (prevProps.isActive !== nextProps.isActive) {
return false;
}
return true;
});

View file

@ -1,11 +1,12 @@
'use client';
"use client";
import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns';
import { useParams, useRouter } from 'next/navigation';
import type { User } from 'next-auth';
import { useState } from 'react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import useSWRInfinite from "swr/infinite";
import {
AlertDialog,
AlertDialogAction,
@ -15,18 +16,17 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
} from "@/components/ui/alert-dialog";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
useSidebar,
} from '@/components/ui/sidebar';
import type { Chat } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils';
import { ChatItem } from './sidebar-history-item';
import useSWRInfinite from 'swr/infinite';
import { LoaderIcon } from './icons';
} from "@/components/ui/sidebar";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { ChatItem } from "./sidebar-history-item";
type GroupedChats = {
today: Chat[];
@ -36,10 +36,10 @@ type GroupedChats = {
older: Chat[];
};
export interface ChatHistory {
chats: Array<Chat>;
export type ChatHistory = {
chats: Chat[];
hasMore: boolean;
}
};
const PAGE_SIZE = 20;
@ -72,23 +72,27 @@ const groupChatsByDate = (chats: Chat[]): GroupedChats => {
lastWeek: [],
lastMonth: [],
older: [],
} as GroupedChats,
} as GroupedChats
);
};
export function getChatHistoryPaginationKey(
pageIndex: number,
previousPageData: ChatHistory,
previousPageData: ChatHistory
) {
if (previousPageData && previousPageData.hasMore === false) {
return null;
}
if (pageIndex === 0) return `/api/history?limit=${PAGE_SIZE}`;
if (pageIndex === 0) {
return `/api/history?limit=${PAGE_SIZE}`;
}
const firstChatFromPage = previousPageData.chats.at(-1);
if (!firstChatFromPage) return null;
if (!firstChatFromPage) {
return null;
}
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
@ -119,13 +123,13 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
? paginatedChatHistories.every((page) => page.chats.length === 0)
: false;
const handleDelete = async () => {
const handleDelete = () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
method: 'DELETE',
method: "DELETE",
});
toast.promise(deletePromise, {
loading: 'Deleting chat...',
loading: "Deleting chat...",
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
@ -136,15 +140,15 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
}
});
return 'Chat deleted successfully';
return "Chat deleted successfully";
},
error: 'Failed to delete chat',
error: "Failed to delete chat",
});
setShowDeleteDialog(false);
if (deleteId === id) {
router.push('/');
router.push("/");
}
};
@ -170,14 +174,14 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
key={item}
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
'--skeleton-width': `${item}%`,
"--skeleton-width": `${item}%`,
} as React.CSSProperties
}
/>
@ -209,7 +213,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats,
(paginatedChatHistory) => paginatedChatHistory.chats
);
const groupedChats = groupChatsByDate(chatsFromHistory);
@ -223,9 +227,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.today.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -243,9 +247,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -263,9 +267,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -283,9 +287,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -303,9 +307,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.older.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -343,7 +347,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>

View file

@ -1,14 +1,14 @@
import type { ComponentProps } from 'react';
import type { ComponentProps } from "react";
import { type SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { SidebarLeftIcon } from './icons';
import { Button } from './ui/button';
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { SidebarLeftIcon } from "./icons";
import { Button } from "./ui/button";
export function SidebarToggle({
className,
@ -19,10 +19,10 @@ export function SidebarToggle({
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn("h-8 px-2 md:h-fit md:px-2", className)}
data-testid="sidebar-toggle-button"
onClick={toggleSidebar}
variant="outline"
className="h-8 px-2 md:h-fit md:px-2"
>
<SidebarLeftIcon size={16} />
</Button>

View file

@ -1,41 +1,40 @@
'use client';
import { ChevronUp } from 'lucide-react';
import Image from 'next/image';
import type { User } from 'next-auth';
import { signOut, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
"use client";
import { ChevronUp } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { signOut, useSession } from "next-auth/react";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useRouter } from 'next/navigation';
import { toast } from './toast';
import { LoaderIcon } from './icons';
import { guestRegex } from '@/lib/constants';
} from "@/components/ui/sidebar";
import { guestRegex } from "@/lib/constants";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = guestRegex.test(data?.user?.email ?? '');
const isGuest = guestRegex.test(data?.user?.email ?? "");
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{status === 'loading' ? (
{status === "loading" ? (
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row gap-2">
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
@ -49,63 +48,63 @@ export function SidebarUserNav({ user }: { user: User }) {
</SidebarMenuButton>
) : (
<SidebarMenuButton
data-testid="user-nav-button"
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
>
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
alt={user.email ?? "User Avatar"}
className="rounded-full"
height={24}
src={`https://avatar.vercel.sh/${user.email}`}
width={24}
/>
<span data-testid="user-email" className="truncate">
{isGuest ? 'Guest' : user?.email}
<span className="truncate" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-popper-anchor-width)"
data-testid="user-nav-menu"
side="top"
className="w-(--radix-popper-anchor-width)"
>
<DropdownMenuItem
data-testid="user-nav-item-theme"
className="cursor-pointer"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
>
{`Toggle ${resolvedTheme === 'light' ? 'dark' : 'light'} mode`}
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
type="button"
className="w-full cursor-pointer"
onClick={() => {
if (status === 'loading') {
if (status === "loading") {
toast({
type: 'error',
type: "error",
description:
'Checking authentication status, please try again!',
"Checking authentication status, please try again!",
});
return;
}
if (isGuest) {
router.push('/login');
router.push("/login");
} else {
signOut({
redirectTo: '/',
redirectTo: "/",
});
}
}}
type="button"
>
{isGuest ? 'Login to your account' : 'Sign out'}
{isGuest ? "Login to your account" : "Sign out"}
</button>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -1,22 +1,22 @@
import Form from 'next/form';
import Form from "next/form";
import { signOut } from '@/app/(auth)/auth';
import { signOut } from "@/app/(auth)/auth";
export const SignOutForm = () => {
return (
<Form
className="w-full"
action={async () => {
'use server';
"use server";
await signOut({
redirectTo: '/',
redirectTo: "/",
});
}}
className="w-full"
>
<button
type="submit"
className="w-full px-1 py-0.5 text-left text-red-500"
type="submit"
>
Sign out
</button>

View file

@ -1,10 +1,10 @@
'use client';
"use client";
import { useFormStatus } from 'react-dom';
import { useFormStatus } from "react-dom";
import { LoaderIcon } from '@/components/icons';
import { LoaderIcon } from "@/components/icons";
import { Button } from './ui/button';
import { Button } from "./ui/button";
export function SubmitButton({
children,
@ -17,10 +17,10 @@ export function SubmitButton({
return (
<Button
type={pending ? 'button' : 'submit'}
aria-disabled={pending || isSuccessful}
disabled={pending || isSuccessful}
className="relative"
disabled={pending || isSuccessful}
type={pending ? "button" : "submit"}
>
{children}
@ -31,7 +31,7 @@ export function SubmitButton({
)}
<output aria-live="polite" className="sr-only">
{pending || isSuccessful ? 'Loading' : 'Submit form'}
{pending || isSuccessful ? "Loading" : "Submit form"}
</output>
</Button>
);

View file

@ -1,53 +1,49 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { memo } from 'react';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { ChatMessage } from '@/lib/types';
import { Suggestion } from './elements/suggestion';
import type { UseChatHelpers } from "@ai-sdk/react";
import { motion } from "framer-motion";
import { memo } from "react";
import type { ChatMessage } from "@/lib/types";
import { Suggestion } from "./elements/suggestion";
import type { VisibilityType } from "./visibility-selector";
interface SuggestedActionsProps {
type SuggestedActionsProps = {
chatId: string;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
selectedVisibilityType: VisibilityType;
}
};
function PureSuggestedActions({
chatId,
sendMessage,
selectedVisibilityType,
}: SuggestedActionsProps) {
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
const suggestedActions = [
'What are the advantages of using Next.js?',
"What are the advantages of using Next.js?",
"Write code to demonstrate Dijkstra's algorithm",
'Help me write an essay about Silicon Valley',
'What is the weather in San Francisco?',
"Help me write an essay about Silicon Valley",
"What is the weather in San Francisco?",
];
return (
<div
data-testid="suggested-actions"
className="grid w-full gap-2 sm:grid-cols-2"
data-testid="suggested-actions"
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.05 * index }}
initial={{ opacity: 0, y: 20 }}
key={suggestedAction}
transition={{ delay: 0.05 * index }}
>
<Suggestion
suggestion={suggestedAction}
className="h-auto w-full whitespace-normal p-3 text-left"
onClick={(suggestion) => {
window.history.replaceState({}, '', `/chat/${chatId}`);
window.history.replaceState({}, "", `/chat/${chatId}`);
sendMessage({
role: 'user',
parts: [{ type: 'text', text: suggestion }],
role: "user",
parts: [{ type: "text", text: suggestion }],
});
}}
className="h-auto w-full whitespace-normal p-3 text-left"
suggestion={suggestedAction}
>
{suggestedAction}
</Suggestion>
@ -60,10 +56,13 @@ function PureSuggestedActions({
export const SuggestedActions = memo(
PureSuggestedActions,
(prevProps, nextProps) => {
if (prevProps.chatId !== nextProps.chatId) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
if (prevProps.chatId !== nextProps.chatId) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
},
}
);

View file

@ -1,15 +1,14 @@
'use client';
"use client";
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { useWindowSize } from 'usehooks-ts';
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useWindowSize } from "usehooks-ts";
import type { UISuggestion } from '@/lib/editor/suggestions';
import { CrossIcon, MessageIcon } from './icons';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
import type { ArtifactKind } from './artifact';
import type { UISuggestion } from "@/lib/editor/suggestions";
import { cn } from "@/lib/utils";
import type { ArtifactKind } from "./artifact";
import { CrossIcon, MessageIcon } from "./icons";
import { Button } from "./ui/button";
export const Suggestion = ({
suggestion,
@ -25,27 +24,14 @@ export const Suggestion = ({
return (
<AnimatePresence>
{!isExpanded ? (
{isExpanded ? (
<motion.div
className={cn('cursor-pointer p-1 text-muted-foreground', {
'-right-8 absolute': artifactKind === 'text',
'sticky top-0 right-4': artifactKind === 'code',
})}
onClick={() => {
setIsExpanded(true);
}}
whileHover={{ scale: 1.1 }}
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
) : (
<motion.div
key={suggestion.id}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: -20 }}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key={suggestion.id}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
@ -54,24 +40,37 @@ export const Suggestion = ({
<div className="font-medium">Assistant</div>
</div>
<button
type="button"
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
variant="outline"
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
</motion.div>
) : (
<motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", {
"-right-8 absolute": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code",
})}
onClick={() => {
setIsExpanded(true);
}}
whileHover={{ scale: 1.1 }}
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
)}
</AnimatePresence>
);

View file

@ -1,35 +1,35 @@
'use client';
"use client";
import { exampleSetup } from 'prosemirror-example-setup';
import { inputRules } from 'prosemirror-inputrules';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import React, { memo, useEffect, useRef } from 'react';
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from '@/lib/db/schema';
import type { Suggestion } from "@/lib/db/schema";
import {
documentSchema,
handleTransaction,
headingRule,
} from '@/lib/editor/config';
} from "@/lib/editor/config";
import {
buildContentFromDocument,
buildDocumentFromContent,
createDecorations,
} from '@/lib/editor/functions';
} from "@/lib/editor/functions";
import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
} from '@/lib/editor/suggestions';
} from "@/lib/editor/suggestions";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: 'streaming' | 'idle';
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Array<Suggestion>;
suggestions: Suggestion[];
};
function PureEditor({
@ -74,7 +74,7 @@ function PureEditor({
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, []);
}, [content]);
useEffect(() => {
if (editorRef.current) {
@ -93,19 +93,19 @@ function PureEditor({
useEffect(() => {
if (editorRef.current && content) {
const currentContent = buildContentFromDocument(
editorRef.current.state.doc,
editorRef.current.state.doc
);
if (status === 'streaming') {
if (status === "streaming") {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content,
newDocument.content
);
transaction.setMeta('no-save', true);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
return;
}
@ -116,10 +116,10 @@ function PureEditor({
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content,
newDocument.content
);
transaction.setMeta('no-save', true);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
}
}
@ -129,14 +129,14 @@ function PureEditor({
if (editorRef.current?.state.doc && content) {
const projectedSuggestions = projectWithPositions(
editorRef.current.state.doc,
suggestions,
suggestions
).filter(
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd,
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
const decorations = createDecorations(
projectedSuggestions,
editorRef.current,
editorRef.current
);
const transaction = editorRef.current.state.tr;
@ -155,7 +155,7 @@ function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
prevProps.suggestions === nextProps.suggestions &&
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
prevProps.content === nextProps.content &&
prevProps.onSaveContent === nextProps.onSaveContent
);

View file

@ -1,7 +1,7 @@
'use client';
"use client";
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types';
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View file

@ -1,18 +1,18 @@
'use client';
"use client";
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
import { toast as sonnerToast } from 'sonner';
import { CheckCircleFillIcon, WarningIcon } from './icons';
import { cn } from '@/lib/utils';
import { type ReactNode, useEffect, useRef, useState } from "react";
import { toast as sonnerToast } from "sonner";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, WarningIcon } from "./icons";
const iconsByType: Record<'success' | 'error', ReactNode> = {
const iconsByType: Record<"success" | "error", ReactNode> = {
success: <CheckCircleFillIcon />,
error: <WarningIcon />,
};
export function toast(props: Omit<ToastProps, 'id'>) {
export function toast(props: Omit<ToastProps, "id">) {
return sonnerToast.custom((id) => (
<Toast id={id} type={props.type} description={props.description} />
<Toast description={props.description} id={id} type={props.type} />
));
}
@ -24,7 +24,9 @@ function Toast(props: ToastProps) {
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
if (!el) {
return;
}
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
@ -37,28 +39,28 @@ function Toast(props: ToastProps) {
ro.observe(el);
return () => ro.disconnect();
}, [description]);
}, []);
return (
<div className="flex toast-mobile:w-[356px] w-full justify-center">
<div
className={cn(
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
multiLine ? "items-start" : "items-center"
)}
data-testid="toast"
key={id}
className={cn(
'flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3',
multiLine ? 'items-start' : 'items-center',
)}
>
<div
data-type={type}
className={cn(
'data-[type=error]:text-red-600 data-[type=success]:text-green-600',
{ 'pt-1': multiLine },
"data-[type=error]:text-red-600 data-[type=success]:text-green-600",
{ "pt-1": multiLine }
)}
data-type={type}
>
{iconsByType[type]}
</div>
<div ref={descriptionRef} className="text-sm text-zinc-950">
<div className="text-sm text-zinc-950" ref={descriptionRef}>
{description}
</div>
</div>
@ -66,8 +68,8 @@ function Toast(props: ToastProps) {
);
}
interface ToastProps {
type ToastProps = {
id: string | number;
type: 'success' | 'error';
type: "success" | "error";
description: string;
}
};

View file

@ -1,11 +1,13 @@
'use client';
import cx from 'classnames';
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from 'framer-motion';
} from "framer-motion";
import { nanoid } from "nanoid";
import {
type Dispatch,
memo,
@ -14,21 +16,18 @@ import {
useEffect,
useRef,
useState,
} from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { nanoid } from 'nanoid';
} from "react";
import { useOnClickOutside } from "usehooks-ts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ArrowUpIcon, StopIcon, SummarizeIcon } from './icons';
import { artifactDefinitions, type ArtifactKind } from './artifact';
import type { ArtifactToolbarItem } from './create-artifact';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
} from "@/components/ui/tooltip";
import type { ChatMessage } from "@/lib/types";
import { type ArtifactKind, artifactDefinitions } from "./artifact";
import type { ArtifactToolbarItem } from "./create-artifact";
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
type ToolProps = {
description: string;
@ -38,11 +37,11 @@ type ToolProps = {
isToolbarVisible?: boolean;
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
onClick: ({
sendMessage,
}: {
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => void;
};
@ -89,40 +88,42 @@ const Tool = ({
<Tooltip open={isHovered && !isAnimating}>
<TooltipTrigger asChild>
<motion.div
className={cx('rounded-full p-3', {
'bg-primary text-primary-foreground!': selectedTool === description,
})}
onHoverStart={() => {
setIsHovered(true);
}}
onHoverEnd={() => {
if (selectedTool !== description) setIsHovered(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSelect();
}
}}
initial={{ scale: 1, opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className={cx("rounded-full p-3", {
"bg-primary text-primary-foreground!": selectedTool === description,
})}
exit={{
scale: 0.9,
opacity: 0,
transition: { duration: 0.1 },
}}
initial={{ scale: 1, opacity: 0 }}
onClick={() => {
handleSelect();
}}
onHoverEnd={() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}}
onHoverStart={() => {
setIsHovered(true);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSelect();
}
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
{selectedTool === description ? <ArrowUpIcon /> : icon}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background"
side="left"
sideOffset={16}
className="rounded-2xl bg-foreground p-3 px-4 text-background"
>
{description}
</TooltipContent>
@ -130,7 +131,7 @@ const Tool = ({
);
};
const randomArr = [...Array(6)].map((x) => nanoid(5));
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
const ReadingLevelSelector = ({
setSelectedTool,
@ -139,15 +140,15 @@ const ReadingLevelSelector = ({
}: {
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => {
const LEVELS = [
'Elementary',
'Middle School',
'Keep current level',
'High School',
'College',
'Graduate',
"Elementary",
"Middle School",
"Keep current level",
"High School",
"College",
"Graduate",
];
const y = useMotionValue(-40 * 2);
@ -159,7 +160,7 @@ const ReadingLevelSelector = ({
useState<boolean>(false);
useEffect(() => {
const unsubscribe = yToLevel.on('change', (latest) => {
const unsubscribe = yToLevel.on("change", (latest) => {
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
setCurrentLevel(level);
});
@ -171,11 +172,11 @@ const ReadingLevelSelector = ({
<div className="relative flex flex-col items-center justify-end">
{randomArr.map((id) => (
<motion.div
key={id}
className="flex size-[40px] flex-row items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex size-[40px] flex-row items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={id}
transition={{ delay: 0.1 }}
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
@ -187,37 +188,23 @@ const ReadingLevelSelector = ({
<TooltipTrigger asChild>
<motion.div
className={cx(
'absolute flex flex-row items-center rounded-full border bg-background p-3',
"absolute flex flex-row items-center rounded-full border bg-background p-3",
{
'bg-primary text-primary-foreground': currentLevel !== 2,
'bg-background text-foreground': currentLevel === 2,
},
"bg-primary text-primary-foreground": currentLevel !== 2,
"bg-background text-foreground": currentLevel === 2,
}
)}
style={{ y }}
drag="y"
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
dragElastic={0}
dragMomentum={false}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1 }}
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
onDragEnd={() => {
if (currentLevel === 2) {
setSelectedTool(null);
} else {
setHasUserSelectedLevel(true);
}
}}
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: 'user',
role: "user",
parts: [
{
type: 'text',
type: "text",
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
@ -226,14 +213,28 @@ const ReadingLevelSelector = ({
setSelectedTool(null);
}
}}
onDragEnd={() => {
if (currentLevel === 2) {
setSelectedTool(null);
} else {
setHasUserSelectedLevel(true);
}
}}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
style={{ y }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
side="left"
sideOffset={16}
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
>
{LEVELS[currentLevel]}
</TooltipContent>
@ -255,32 +256,32 @@ export const Tools = ({
isToolbarVisible: boolean;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: Array<ArtifactToolbarItem>;
tools: ArtifactToolbarItem[];
}) => {
const [primaryTool, ...secondaryTools] = tools;
return (
<motion.div
className="flex flex-col gap-1.5"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-1.5"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
<Tool
key={secondaryTool.description}
description={secondaryTool.description}
icon={secondaryTool.icon}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
sendMessage={sendMessage}
isAnimating={isAnimating}
key={secondaryTool.description}
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
))}
</AnimatePresence>
@ -288,13 +289,13 @@ export const Tools = ({
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
isToolbarVisible={isToolbarVisible}
setIsToolbarVisible={setIsToolbarVisible}
sendMessage={sendMessage}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
/>
</motion.div>
);
@ -311,10 +312,10 @@ const PureToolbar = ({
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
status: UseChatHelpers<ChatMessage>['status'];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
stop: UseChatHelpers<ChatMessage>['stop'];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
status: UseChatHelpers<ChatMessage>["status"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
artifactKind: ArtifactKind;
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
@ -354,17 +355,17 @@ const PureToolbar = ({
}, []);
useEffect(() => {
if (status === 'streaming') {
if (status === "streaming") {
setIsToolbarVisible(false);
}
}, [status, setIsToolbarVisible]);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifactKind,
(definition) => definition.kind === artifactKind
);
if (!artifactDefinition) {
throw new Error('Artifact definition not found!');
throw new Error("Artifact definition not found!");
}
const toolsByArtifactKind = artifactDefinition.toolbar;
@ -376,11 +377,9 @@ const PureToolbar = ({
return (
<TooltipProvider delayDuration={0}>
<motion.div
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
initial={{ opacity: 0, y: -20, scale: 1 }}
animate={
isToolbarVisible
? selectedTool === 'adjust-reading-level'
? selectedTool === "adjust-reading-level"
? {
opacity: 1,
y: 0,
@ -397,34 +396,40 @@ const PureToolbar = ({
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
onHoverStart={() => {
if (status === 'streaming') return;
cancelCloseTimer();
setIsToolbarVisible(true);
}}
onHoverEnd={() => {
if (status === 'streaming') return;
startCloseTimer();
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
setIsAnimating(false);
}}
onAnimationStart={() => {
setIsAnimating(true);
}}
onAnimationComplete={() => {
setIsAnimating(false);
onHoverEnd={() => {
if (status === "streaming") {
return;
}
startCloseTimer();
}}
onHoverStart={() => {
if (status === "streaming") {
return;
}
cancelCloseTimer();
setIsToolbarVisible(true);
}}
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{status === 'streaming' ? (
{status === "streaming" ? (
<motion.div
key="stop-icon"
initial={{ scale: 1 }}
animate={{ scale: 1.4 }}
exit={{ scale: 1 }}
className="p-3"
exit={{ scale: 1 }}
initial={{ scale: 1 }}
key="stop-icon"
onClick={() => {
stop();
setMessages((messages) => messages);
@ -432,20 +437,20 @@ const PureToolbar = ({
>
<StopIcon />
</motion.div>
) : selectedTool === 'adjust-reading-level' ? (
) : selectedTool === "adjust-reading-level" ? (
<ReadingLevelSelector
isAnimating={isAnimating}
key="reading-level-selector"
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
isAnimating={isAnimating}
/>
) : (
<Tools
key="tools"
sendMessage={sendMessage}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
@ -457,9 +462,15 @@ const PureToolbar = ({
};
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) return false;
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) return false;
if (prevProps.artifactKind !== nextProps.artifactKind) return false;
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
return false;
}
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
return true;
});

View file

@ -1,16 +1,16 @@
'use client';
"use client"
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@ -18,14 +18,14 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
@ -36,14 +36,14 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg',
className,
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
@ -51,13 +51,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
@ -65,13 +65,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -79,11 +79,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('font-semibold text-lg', className)}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
@ -91,12 +91,12 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
@ -107,8 +107,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@ -117,14 +117,14 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
@ -138,4 +138,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
}

View file

@ -1,9 +1,9 @@
'use client';
"use client"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@ -12,13 +12,13 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@ -26,11 +26,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback };
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -1,27 +1,27 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
);
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
)
}
export { Badge, badgeVariants };
export { Badge, badgeVariants }

View file

@ -1,56 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from "react"
import { Slot as SlotPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
);
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const Comp = asChild ? SlotPrimitive.Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants };
export { Button, buttonVariants }

View file

@ -1,6 +1,6 @@
import * as React from 'react';
import * as React from "react"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-xs',
className,
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = 'Card';
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -23,11 +23,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
@ -36,13 +36,13 @@ const CardTitle = React.forwardRef<
<div
ref={ref}
className={cn(
'font-semibold text-2xl leading-none tracking-tight',
className,
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
@ -50,19 +50,19 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -70,17 +70,10 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
))
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -1,45 +1,45 @@
'use client';
"use client"
import * as React from 'react';
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext);
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
throw new Error("useCarousel must be used within a <Carousel />")
}
return context;
return context
}
const Carousel = React.forwardRef<
@ -48,7 +48,7 @@ const Carousel = React.forwardRef<
>(
(
{
orientation = 'horizontal',
orientation = "horizontal",
opts,
setApi,
plugins,
@ -56,69 +56,69 @@ const Carousel = React.forwardRef<
children,
...props
},
ref,
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
return
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
);
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return;
return
}
setApi(api);
}, [api, setApi]);
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return;
return
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
@ -127,7 +127,7 @@ const Carousel = React.forwardRef<
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
@ -137,7 +137,7 @@ const Carousel = React.forwardRef<
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
@ -145,38 +145,38 @@ const Carousel = React.forwardRef<
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = 'Carousel';
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
const { orientation } = useCarousel()
return (
<div
@ -184,21 +184,21 @@ const CarouselItem = React.forwardRef<
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
@ -206,11 +206,11 @@ const CarouselPrevious = React.forwardRef<
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 -translate-y-1/2 top-1/2'
: '-top-12 -translate-x-1/2 left-1/2 rotate-90',
className,
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
@ -219,15 +219,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
@ -235,11 +235,11 @@ const CarouselNext = React.forwardRef<
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 -translate-y-1/2 top-1/2'
: '-bottom-12 -translate-x-1/2 left-1/2 rotate-90',
className,
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
@ -248,9 +248,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
@ -259,4 +259,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
};
}

View file

@ -1,11 +1,11 @@
'use client';
"use client"
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
const Collapsible = CollapsiblePrimitive.Root;
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -1,44 +1,44 @@
'use client';
"use client"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
));
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -99,8 +99,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -123,8 +123,8 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
@ -135,26 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 font-semibold text-sm',
inset && 'pl-8',
className,
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
@ -197,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
}

View file

@ -1,29 +1,29 @@
'use client';
"use client"
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root;
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent };
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -1,22 +1,22 @@
import * as React from 'react';
import * as React from "react"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
)
}
)
Input.displayName = "Input"
export { Input };
export { Input }

Some files were not shown because too many files have changed in this diff Show more