Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
akazwz committed Nov 6, 2024
0 parents commit c3322a3
Show file tree
Hide file tree
Showing 76 changed files with 11,121 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
.env
.react-router
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules

/.cache
/build
.env
.react-router
fly.toml
44 changes: 44 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
ARG NODE_VERSION=20.11.0
FROM node:${NODE_VERSION}-slim as base

# Node.js app lives here
WORKDIR /app

# Set production environment
ENV NODE_ENV="production"

# Install pnpm
ARG PNPM_VERSION=9.12.3
RUN npm install -g pnpm@$PNPM_VERSION


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3

# Install node modules
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false

# Copy application code
COPY . .

# Build application
RUN pnpm run build

# Remove development dependencies
RUN pnpm prune --prod


# Final stage for app image
FROM base

# Copy built application
COPY --from=build /app /app

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD [ "pnpm", "run", "start" ]
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Welcome to React Router!

- 📖 [React Router docs](https://reactrouter.com/dev)

## Development

Run the dev server:

```shellscript
npm run dev
```

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying Node applications, the built-in app server is production-ready.

Make sure to deploy the output of `npm run build`

- `build/server`
- `build/client`

## Styling

This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
65 changes: 65 additions & 0 deletions app/.client/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { IdbFs, PGlite } from "@electric-sql/pglite";
import { getItem, setItem } from "localforage";
import wasm from "node_modules/@electric-sql/pglite/dist/postgres.wasm?url";
import wasmData from "node_modules/@electric-sql/pglite/dist/postgres.data?url";
import { drizzle } from "drizzle-orm/pglite";
import * as s from "~/drizzle/schema";

async function getWasm() {
const item = (await getItem("pg_wasm")) as ArrayBuffer;
if (item) {
return await WebAssembly.compile(item);
}
const resp = await fetch(wasm);
const data = await resp.arrayBuffer();
await setItem("pg_wasm", data);
return await WebAssembly.compile(data);
}

async function getWasmData() {
const item = (await getItem("pg_data")) as Blob;
if (item) {
return item;
}
const resp = await fetch(wasmData);
const data = await resp.blob();
await setItem("pg_data", data);
return data;
}

export const client = new PGlite({
wasmModule: await getWasm(),
fsBundle: await getWasmData(),
fs: new IdbFs("chatbot"),
});

await client.exec(`
CREATE TABLE IF NOT EXISTS "conversations" (
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "images" (
"id" varchar PRIMARY KEY NOT NULL,
"url" varchar NOT NULL,
"prompt" varchar NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "messages" (
"id" varchar PRIMARY KEY NOT NULL,
"conversation_id" varchar NOT NULL,
"role" varchar NOT NULL,
"content" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
`);

export const schema = s;
export const db = drizzle(client, {
schema,
});
146 changes: 146 additions & 0 deletions app/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import ky from "ky";

export const BASE_API_URL = "https://api.pexni.com";

let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

function subscribeTokenRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onRefreshed(token: string) {
for (const subscriber of refreshSubscribers) {
subscriber(token);
}
refreshSubscribers = [];
}
const refreshAccessToken = async (): Promise<string> => {
const refresh_token = localStorage.getItem("refresh_token");
const resp = await api
.post("refresh_token", {
json: { token: refresh_token },
})
.json<SigninResp>();
localStorage.setItem("access_token", resp.access_token);
localStorage.setItem("refresh_token", resp.refresh_token);
onRefreshed(resp.access_token);
return resp.access_token;
};
export const api = ky.create({
prefixUrl: BASE_API_URL,
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem("access_token");
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
],
afterResponse: [
async (request, options, response) => {
if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
try {
const token = await refreshAccessToken();
request.headers.set("Authorization", `Bearer ${token}`);
return api(request, options);
} catch (error) {
console.error("Failed to refresh token", error);
} finally {
isRefreshing = false;
}
} else {
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
request.headers.set("Authorization", `Bearer ${token}`);
resolve(api(request, options));
});
});
}
}
},
],
},
});

interface SigninResp {
access_token: string;
refresh_token: string;
}

interface User {
id: string;
username: string;
}

export interface Conversation {
id: string;
name: string;
pinned: boolean;
created_at: string;
updated_at: string;
}

export interface Message {
id: string;
conversation_id: string;
user_id: string;
role: string;
content: string;
created_at: string;
updated_at: string;
}

export interface SendMessageData {
role: string;
content: string;
}

export const signin = (data: { username: string; password: string }) =>
api.post("signin", { json: data }).json<SigninResp>();

export const signup = (data: { username: string; password: string }) =>
api.post("signup", { json: data }).json<User>();

export const account = () => api.get("account").json<User>();

export const getConversations = () =>
api.get("conversations").json<Conversation[]>();

export const createConversation = () =>
api.post("conversations").json<Conversation>();

export const getConversation = (conversationId: string) =>
api.get(`conversations/${conversationId}`).json<Conversation>();

export const deleteConversation = (conversationId: string) =>
api.delete(`conversations/${conversationId}`);

export const getMessages = (conversationId: string) =>
api.get(`conversations/${conversationId}/messages`).json<Message[]>();

export const chat = (messages: SendMessageData[]) =>
api.post("chat", {
json: {
messages: messages,
model: "@cf/qwen/qwen1.5-14b-chat-awq",
},
});

export const generateImages = (prompt: string) =>
api.post("images", { json: { prompt }, timeout: 200000 }).json<{
image: string;
}>();

export const getImageBinary = (key: string) => api.get(`images/${key}`).blob();

export const summarize = (messages: SendMessageData[]) =>
api
.post("summarize", {
json: {
messages: messages,
},
})
.json<string>();
Binary file added app/assets/duck.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions app/components/auto-resized-textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import AutoResize from "react-textarea-autosize";
import { chakra, useRecipe } from "@chakra-ui/react";

const StyledAutoResize = chakra(AutoResize);

type AutoResizedTextareaProps = React.ComponentProps<typeof StyledAutoResize>;

export function AutoResizedTextarea(props: AutoResizedTextareaProps) {
const recipe = useRecipe({ key: "textarea" });
const styles = recipe({ size: "sm" });
return <StyledAutoResize {...props} css={styles} />;
}
63 changes: 63 additions & 0 deletions app/components/chat-bubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { HStack, VStack, Text } from "@chakra-ui/react";
import { BotIcon } from "lucide-react";
import { memo } from "react";
import { formatRelative } from "date-fns";
import { zhCN } from "date-fns/locale";
import Markdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import { Avatar } from "~/components/ui/avatar";
import { Prose } from "~/components/ui/prose";
import { Message } from "~/drizzle/schema";

interface ChatBubbleProps {
message: Message;
}

export const ChatBubbleMemo = memo(({ message }: ChatBubbleProps) => {
return <ChatBubble message={message} />;
});

export function ChatBubble({ message }: ChatBubbleProps) {
return (
<HStack
w="full"
rounded="sm"
p={2}
alignItems="start"
flexDir={message.role === "user" ? "row-reverse" : "row"}
>
{message.role !== "user" && (
<Avatar
size="sm"
fallback={<BotIcon size={20} strokeWidth="1.5px" />}
/>
)}
<VStack w="full" alignItems={message.role === "user" ? "end" : "start"}>
<Text textStyle="xs" color="gray">
{formatRelative(message.createdAt, new Date(), {
locale: zhCN,
})}
</Text>
<Prose
bgColor="gray.100"
color="gray.800"
px={3}
rounded="2xl"
css={{
"& pre": {
whiteSpace: "pre-wrap",
},
}}
>
<Markdown
rehypePlugins={[rehypeHighlight]}
remarkPlugins={[remarkGfm]}
>
{message.content}
</Markdown>
</Prose>
</VStack>
</HStack>
);
}
Loading

0 comments on commit c3322a3

Please sign in to comment.