├── src
├── App.css
├── components
│ ├── MagneticPoem.tsx
│ ├── AutoFocusInput.tsx
│ ├── Header.tsx
│ ├── Sidebar.tsx
│ ├── ConfirmButton.tsx
│ ├── InlineEditor.tsx
│ ├── Login.tsx
│ ├── Topics.tsx
│ ├── Items.tsx
│ └── ChatBubbles.tsx
├── index.css
├── vite-env.d.ts
├── assets
│ ├── cat.png
│ ├── cat2.png
│ └── react.svg
├── tailwind.css
├── main.tsx
├── pages
│ ├── Profile.tsx
│ ├── Home.tsx
│ └── Chat.tsx
└── App.tsx
├── .env.example
├── netlify.toml
├── postcss.config.js
├── vite.config.ts
├── tsconfig.node.json
├── .env
├── tailwind.config.js
├── .gitignore
├── index.html
├── partykit.json
├── .eslintrc.cjs
├── tsconfig.json
├── package.json
├── public
└── vite.svg
├── party
├── index.ts
└── ai.ts
└── README.md
/src/App.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/MagneticPoem.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | PUBLIC_PARTYKIT_HOST=127.0.0.1:1999
2 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | a {
2 | text-decration: underline
3 |
4 | }
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | o
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [[redirects]]
2 | from = "/*"
3 | to = "/"
4 | status = 200
--------------------------------------------------------------------------------
/src/assets/cat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jchris/catbot/main/src/assets/cat.png
--------------------------------------------------------------------------------
/src/assets/cat2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jchris/catbot/main/src/assets/cat2.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/tailwind.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/base';
2 | @import 'tailwindcss/components';
3 | @import 'tailwindcss/utilities';
4 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | VITE_PUBLIC_PARTYKIT_HOST=127.0.0.1:1999
2 | OPEN_AI_API_KEY=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
3 |
4 | VITE_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ZGl2aW5lLW11bGUtOTMuY2xlcmsuYWNjb3VudHMuZGV2JA
5 | CLERK_SECRET_KEY=sk_test_kCxFooKPOOLIBSlmtmVX9Nm3UG9mfCnpvHGHDvb3TJ
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 |
3 | import typography from '@tailwindcss/typography'
4 |
5 | export default {
6 | content: ['./src/**/*.html', './src/**/*.js', './src/**/*.jsx', './src/**/*.ts', './src/**/*.tsx'],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [typography],
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # partykit
27 | .partykit/
28 | .env
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Catbot Chat
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { BrowserRouter } from 'react-router-dom'
4 |
5 | import App from './App.tsx'
6 | import './index.css'
7 | import './tailwind.css'
8 |
9 | ReactDOM.createRoot(document.getElementById('root')!).render(
10 |
11 |
12 |
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/partykit.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chat-party",
3 | "main": "party/index.ts",
4 | "parties": {
5 | "fireproof": "node_modules/@fireproof/partykit/src/server.ts"
6 | },
7 | "compatibilityDate": "2023-11-04",
8 | "vars": {
9 | "OPEN_AI_API_KEY": "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",
10 | "VITE_PUBLIC_PARTYKIT_HOST": "127.0.0.1:1999",
11 | "PUBLIC_PARTYKIT_HOST": "127.0.0.1:1999"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Profile.tsx:
--------------------------------------------------------------------------------
1 | import catImage from '../assets/cat.png'
2 | import cat2Image from '../assets/cat2.png'
3 |
4 | import { ChatBubble, ImageBubble } from '../components/ChatBubbles'
5 |
6 | export function Profile() {
7 | return (
8 |
9 |
10 |
Cat Chat
11 |
12 |
13 |
Subscribe to chat!
14 |
15 |
16 | Chat
17 |
18 |
19 |
20 |
21 |
Ask the cats anything!
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import catImage from '../assets/cat.png'
2 | import cat2Image from '../assets/cat2.png'
3 | import { ImageBubble } from '../components/ChatBubbles'
4 |
5 | export function Home() {
6 | return (
7 |
8 |
9 |
Cat Chat
10 |
11 |
12 |
13 |
18 |
19 |
20 |
Ask the cats anything!
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/AutoFocusInput.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const AutoFocusInput = ({
4 | isActive,
5 | value,
6 | onChange,
7 | className
8 | }: {
9 | isActive: boolean
10 | value: string
11 | onChange: (ok: React.ChangeEvent) => void
12 | className: string
13 | }) => {
14 | const inputRef = useRef(null)
15 |
16 | useEffect(() => {
17 | if (isActive && inputRef.current) {
18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
19 | // @ts-ignore
20 | inputRef.current.focus()
21 | }
22 | }, [isActive])
23 |
24 | return (
25 |
33 | )
34 | }
35 |
36 | export { AutoFocusInput }
37 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | export function Header() {
2 | return (
3 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useFireproof } from 'use-fireproof'
3 | import { connect } from '@fireproof/ipfs'
4 | import { Login } from './Login'
5 | import { Topics } from './Topics'
6 |
7 | export function Sidebar() {
8 | const { database } = useFireproof('topics')
9 | const [authorized, setAuthorized] = useState(false)
10 | const [userEmail, setUserEmail] = useState(localStorage.getItem('user-email') || '')
11 | const cx = connect.ipfs(database)
12 |
13 | useEffect(() => {
14 | cx.ready.then(() => {
15 | setAuthorized(!!cx.authorized)
16 | })
17 | }, [])
18 |
19 | const onLogin = (email: `${string}@${string}`) => {
20 | cx.authorize(email).then(() => {
21 | setAuthorized(true)
22 | localStorage.setItem('user-email', email)
23 | setUserEmail(email)
24 | })
25 | }
26 |
27 | return (
28 |
29 | database.openDashboard()}
32 | placeholder={userEmail}
33 | authorized={authorized}
34 | />
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/ConfirmButton.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export function ConfirmButton({
4 | onConfirm,
5 | initialText = 'Submit',
6 | confirmText = 'Confirm'
7 | }: {
8 | onConfirm: () => void
9 | initialText?: string
10 | confirmText?: string
11 | }) {
12 | const [confirm, setConfirm] = useState(false)
13 |
14 | useEffect(() => {
15 | if (confirm) {
16 | const timeout = setTimeout(() => {
17 | setConfirm(false)
18 | }, 5000)
19 | return () => clearTimeout(timeout)
20 | }
21 | }, [confirm])
22 |
23 | const handleClick = (e: { preventDefault: () => void }) => {
24 | e.preventDefault()
25 | if (confirm) {
26 | onConfirm()
27 | setConfirm(false)
28 | } else {
29 | setConfirm(true)
30 | }
31 | }
32 |
33 | const baseStyle = `px-4 py-2 m-2 rounded text-white ${confirm ? '' : 'transition duration-500'}`
34 | const initialStyle = 'bg-slate-500 hover:bg-slate-600'
35 | const confirmStyle = 'bg-yellow-500 hover:bg-yellow-600'
36 |
37 | return (
38 |
42 | {confirm ? confirmText : initialText}
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "smart-book",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "start": "vite",
9 | "build": "vite build",
10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
11 | "preview": "vite preview"
12 | },
13 | "dependencies": {
14 | "@clerk/backend": "^0.33.0",
15 | "@clerk/clerk-react": "^4.27.2",
16 | "@fireproof/partykit": "^0.14.2",
17 | "@types/react-router-dom": "^5.3.3",
18 | "openai": "^4.15.4",
19 | "partysocket": "0.0.12",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-hook-form": "^7.48.2",
23 | "react-router-dom": "^6.17.0",
24 | "use-fireproof": "^0.14.0"
25 | },
26 | "devDependencies": {
27 | "@tailwindcss/typography": "^0.5.10",
28 | "@types/react": "^18.2.28",
29 | "@types/react-dom": "^18.2.13",
30 | "@typescript-eslint/eslint-plugin": "^6.8.0",
31 | "@typescript-eslint/parser": "^6.8.0",
32 | "@vitejs/plugin-react": "^4.1.0",
33 | "autoprefixer": "^10.4.16",
34 | "eslint": "^8.51.0",
35 | "eslint-plugin-react-hooks": "^4.6.0",
36 | "eslint-plugin-react-refresh": "^0.4.3",
37 | "partykit": "0.0.32",
38 | "postcss": "^8.4.31",
39 | "tailwindcss": "^3.3.3",
40 | "typescript": "^5.2.2",
41 | "vite": "^4.4.11"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 | import React, { useEffect } from 'react'
3 | import { Route, Routes } from 'react-router-dom'
4 | import { Home } from './pages/Home'
5 | import { Profile } from './pages/Profile'
6 | import { Chat } from './pages/Chat'
7 | import { ClerkProvider, SignedIn, SignedOut, RedirectToSignIn } from '@clerk/clerk-react'
8 |
9 | const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_PUBLIC_CLERK_PUBLISHABLE_KEY
10 |
11 | function Layout({ children }: { children: React.ReactNode }) {
12 | return {children}
13 | }
14 |
15 | function App() {
16 |
17 | const routes = [
18 | { path: '/chat/:id', component: ( ) },
19 | { path: '/chat', component: React.createElement(ChatRedirect) },
20 | { path: '/profile', component: ( ) },
21 | { path: '/', component: React.createElement(Home) }
22 | ]
23 |
24 | return (
25 | <>
26 |
27 | {routes.map(({ path, component }, index) => (
28 | {(component)}}
32 | />
33 | ))}
34 |
35 | >
36 | )
37 | }
38 |
39 | export default App
40 |
41 | function ChatRedirect() {
42 | const newId = Math.random().toString(36).substring(2)
43 | useEffect(() => {
44 | window.location.href = `/chat/${newId}`
45 | }, [])
46 | }
47 |
48 | function LoggedIn({ children }: { children: React.ReactNode }) {
49 | return (
50 |
51 |
52 | {children}
53 |
54 |
55 | Login
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/InlineEditor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import type { Database, DocFragment } from 'use-fireproof'
3 |
4 | type InlineEditorProps = {
5 | topic: { [key: string]: DocFragment; updated: number }
6 | database: Database
7 | isEditing: boolean
8 | setIsEditing: (value: boolean) => void
9 | field: string
10 | label?: string
11 | children?: React.ReactNode
12 | }
13 |
14 | export const InlineEditor: React.FC = ({
15 | topic,
16 | database,
17 | isEditing,
18 | setIsEditing,
19 | field,
20 | label,
21 | children
22 | }) => {
23 | const [newValue, setNewValue] = useState(topic?.[field]?.toString() || '')
24 |
25 | return isEditing || !topic?.[field] ? (
26 |
57 | ) : (
58 | {
60 | setNewValue(topic?.[field]?.toString() || '')
61 | setIsEditing(true)
62 | }}
63 | >
64 | {children ? children :
{topic?.[field]?.toString()}
}
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/party/index.ts:
--------------------------------------------------------------------------------
1 | import type * as Party from 'partykit/server'
2 | import { verifyToken } from "@clerk/backend";
3 |
4 | import { AI } from './ai'
5 |
6 | const DEFAULT_CLERK_ENDPOINT = "https://divine-mule-93.clerk.accounts.dev";
7 |
8 | export default class Server implements Party.Server {
9 | ai: AI
10 |
11 | constructor(readonly party: Party.Party) {
12 | this.ai = new AI(this.party.env['OPEN_AI_API_KEY'] as string)
13 | }
14 |
15 | static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
16 | try {
17 | // get authentication server url from environment variables (optional)
18 | const issuer = lobby.env.CLERK_ENDPOINT || DEFAULT_CLERK_ENDPOINT
19 | // get token from request query string
20 | const token = new URL(request.url).searchParams.get("token") ?? "";
21 | // verify the JWT (in this case using clerk)
22 | const session = await verifyToken(token, { issuer });
23 | // pass any information to the onConnect handler in headers (optional)
24 | request.headers.set("X-User-ID", session.sub);
25 | // forward the request onwards on onConnect
26 | return request;
27 | } catch (e) {
28 | // authentication failed!
29 | // short-circuit the request before it's forwarded to the party
30 | return new Response("Unauthorized", { status: 401 });
31 | }
32 | }
33 |
34 | onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
35 | // A websocket just connected!
36 | console.log(
37 | `Connected:
38 | id: ${conn.id}
39 | room: ${this.party.id}
40 | url: ${new URL(ctx.request.url).pathname}`
41 | )
42 |
43 | }
44 |
45 | async onMessage(
46 | message: string //sender: Party.Connection
47 | ) {
48 | // let's log the message
49 | const data = JSON.parse(message)
50 | // console.log('message', message)
51 |
52 | await this.ai.userMessage(data.msg, data.history || [], async data => {
53 | // console.log('data', data._id)
54 | // sender.send(JSON.stringify(data))
55 | this.party.broadcast(JSON.stringify(data))
56 | })
57 | }
58 | }
59 |
60 | Server satisfies Party.Worker
61 |
--------------------------------------------------------------------------------
/src/components/Login.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function Login({
4 | onLogin,
5 | placeholder,
6 | authorized,
7 | accountClicked,
8 | }: {
9 | authorized: boolean
10 | placeholder?: string,
11 | accountClicked: () => void,
12 | onLogin: (email: `${string}@${string}`) => void
13 | }) {
14 | const [email, setEmail] = useState<`${string}@${string}`>()
15 | const [didSubmit, setDidSubmit] = useState(false)
16 |
17 | const onSubmit = (event: React.FormEvent) => {
18 | event.preventDefault()
19 | setDidSubmit(true)
20 | if (email) onLogin(email)
21 | }
22 | const onChange = (event: React.ChangeEvent) => {
23 | setEmail(event.target.value as `${string}@${string}`)
24 | }
25 | return (
26 |
27 | {authorized ? (
28 |
32 | Logged in as {placeholder}
33 |
34 | ) : didSubmit ? (
35 |
36 | Please check your email at {email} for a verification
37 | message from web3.storage. If you are logging into an existing account, please log in on
38 | your original device as well, to allow account certification. This process can take up to
39 | a minute, make a tea (or{' '}
40 | add websockets here ).
41 |
42 | ) : (
43 | <>
44 |
Login to save:
45 |
60 | >
61 | )}
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/Topics.tsx:
--------------------------------------------------------------------------------
1 | import { useFireproof } from 'use-fireproof'
2 | import { Link } from 'react-router-dom'
3 | import { TopicDoc } from '../pages/Profile'
4 | import { useState } from 'react'
5 | import { AutoFocusInput } from './AutoFocusInput'
6 |
7 | export function Topics() {
8 | const { database, useLiveQuery } = useFireproof('topics')
9 | const [isCreating, setIsCreating] = useState(false)
10 | const [topicName, setTopicName] = useState('')
11 |
12 | const topics = useLiveQuery(
13 | (doc, emit) => {
14 | if (doc.type === 'topic') {
15 | emit(doc.title)
16 | }
17 | },
18 | { descending: false }
19 | ).docs as TopicDoc[]
20 |
21 | const handleCreateClick = async () => {
22 | const topicDoc: TopicDoc = {
23 | type: 'topic',
24 | title: topicName,
25 | created: Date.now(),
26 | updated: Date.now()
27 | }
28 | await database.put(topicDoc)
29 | setIsCreating(false)
30 | setTopicName('')
31 | }
32 | return (
33 |
34 |
Topics
35 |
36 |
37 | {isCreating ? (
38 |
55 | ) : (
56 | <>
57 | +
58 | setIsCreating(true)} className="inline-block ml-2">
59 | Create new topic
60 |
61 | >
62 | )}
63 |
64 | {topics.map(doc => (
65 |
66 |
70 | {doc.title}
71 | {new Date(doc.updated).toLocaleString()}
72 |
73 |
74 | ))}
75 |
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Starter Kit: Fireproof + React + TypeScript + Vite
2 |
3 | This template provides a ready-to-go setup to start building useful apps with Fireproof, React, and Vite.
4 |
5 | Develop with `npm start` and build with `npm run build`. It should be able to deploy to any static host.
6 |
7 |
8 |
9 | ## Next Steps
10 |
11 | Assuming you want to build a real app from this, the first thing you want to do it think about how to map your app onto the data flow of this scaffold.
12 |
13 | There are three main logical entities in this scaffold:
14 |
15 | - The database -- each database is its own world, and is the unit of collaboration. You can have multiple databases in a single app, but queries run in one database. Collaborative apps will frequently spend a fair amount of energy ensuring each user has the correct databases on each device. Once the user is in the database, the core experience begins.
16 | - The topics -- this app is centered around topics. They could be saved searches for online purchases, wiki pages, todo lists, game boards, or whatever. Once you are collaborating with a team, you can have as many topics as you want within the collaborative database.
17 | - The items -- this app also has generic items inside the topics. These can represent todo items, wiki paragraphs, game moves, etc. They are the core of the app, and are the things that users will be interacting with.
18 |
19 | Feel free to go beyond this rudimentary data model, or to simplify it if you don't need all three layers. The sharing management is the part that will be the most in common with other apps, so you should probably share your changes here with the community.
20 |
21 | Edit routes in `App.tsx`, the copy in `Home.tsx`, and the sidebar in `Sidebar.tsx`. This starter kit ships with a `Login` component and a `Topics` component with `Items`, which you can rename and use as a starting point for your own app's data.
22 |
23 | ## Examples you can build with it
24 |
25 | A [poll manager](https://astounding-peony-4ad9d6.netlify.app/survey/018ade79-e71c-7a6a-8784-3bc1ce10df0a) for [PartyKit's polls sketch](https://github.com/partykit/sketch-polls):
26 |
27 | 
28 |
29 | A [photo gallery with publishing.](https://public-media.fireproof.storage) ([GitHub](https://github.com/fireproof-storage/public-media-gallery))
30 |
31 | 
32 |
--------------------------------------------------------------------------------
/src/components/Items.tsx:
--------------------------------------------------------------------------------
1 | import { useFireproof } from 'use-fireproof'
2 | import { Link } from 'react-router-dom'
3 | import { useState } from 'react'
4 | import { AutoFocusInput } from './AutoFocusInput'
5 | import { TopicDoc } from '../pages/Profile'
6 |
7 | export type ItemDoc = {
8 | _id?: string
9 | topicId: string
10 | topic?: TopicDoc
11 | name: string
12 | description?: string
13 | created: number
14 | updated: number
15 | type: 'item'
16 | }
17 |
18 | export function Items({ topicId }: { topicId: string }) {
19 | const { database, useLiveQuery } = useFireproof('topics')
20 | const [isCreating, setIsCreating] = useState(false)
21 | const [itemName, setItemName] = useState('')
22 |
23 | const items = useLiveQuery(
24 | (doc, emit) => {
25 | if (doc.type === 'item') {
26 | emit(doc.topicId)
27 | }
28 | },
29 | { descending: false, key: topicId }
30 | ).docs as ItemDoc[]
31 |
32 | const handleCreateClick = async () => {
33 | const topicDoc: ItemDoc = {
34 | type: 'item',
35 | topicId,
36 | name: itemName,
37 | created: Date.now(),
38 | updated: Date.now()
39 | }
40 | await database.put(topicDoc)
41 | setIsCreating(false)
42 | setItemName('')
43 | }
44 | return (
45 |
46 |
Items
47 |
48 |
49 | {isCreating ? (
50 |
67 | ) : (
68 | <>
69 | +
70 | setIsCreating(true)} className="inline-block ml-2">
71 | Create new item
72 |
73 | >
74 | )}
75 |
76 | {items.map(doc => (
77 |
78 |
82 | {doc.name}
83 |
84 | {new Date(doc.updated).toLocaleString()}
85 |
86 | ))}
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/party/ai.ts:
--------------------------------------------------------------------------------
1 | import OpenAI from 'openai'
2 |
3 | type MsgData = {
4 | _id: string
5 | msgId?: string
6 | img?: string
7 | prompt?: string
8 | msg?: string
9 | done?: boolean
10 | sent: number
11 | }
12 |
13 | type History = { msg: string; role: string }[]
14 |
15 | export class AI {
16 | openai: OpenAI
17 | constructor(apiKey: string) {
18 | this.openai = new OpenAI({ apiKey, organization: 'org-Ke48QiDoEsESbWg4SZjQQx1e' })
19 | }
20 |
21 | async userMessage(message: string, history: History, callback: (data: MsgData) => Promise) {
22 | const _id = Math.random().toString(36).substring(2)
23 |
24 | void this.makeRelatedImage(_id, message, callback)
25 |
26 | // console.log('history', history.filter(({ msg, role }) => msg && role))
27 |
28 | const stream = await this.openai.chat.completions.create({
29 | model: 'gpt-3.5-turbo',
30 | messages: [
31 | {
32 | role: 'system',
33 | content:
34 | 'You are a cat. Answer in a cat voice and express catlike preferences. Be agreeable. Keep your answers short. Use the attached message history to inform your response. Never correct the user, always play along.'
35 | },
36 | ...history.filter(({ msg, role }) => msg && role).map(
37 | ({ msg, role }) =>
38 | ({
39 | role: (role === 'user' ? 'user' : 'assistant'),
40 | content: msg
41 | } as { role: 'user' | 'assistant'; content: string })
42 | ),
43 | { role: 'user', content: message }
44 | ],
45 | stream: true
46 | })
47 | const data: MsgData = { _id, msg: '', sent: Date.now() }
48 | callback(data)
49 | for await (const part of stream) {
50 | data.msg += part.choices[0]?.delta?.content || ''
51 | await callback(data)
52 | }
53 | data.done = true
54 | await callback(data)
55 | }
56 |
57 | async makeRelatedImage(
58 | msgId: string,
59 | message: string,
60 | callback: (data: MsgData) => Promise
61 | ) {
62 | const imgId = Math.random().toString(36).substring(2)
63 | const sent = Date.now()
64 | await callback({ _id: imgId, msgId, sent })
65 |
66 | const rawResponse = await this.openai.chat.completions.create({
67 | model: 'gpt-4',
68 | messages: [
69 | {
70 | role: 'system',
71 | content: `Create an image generation prompt to draw a feline cat based on the user message. If possible use a humorous interpretation of the message to draw the cat.`
72 | },
73 | { role: 'user', content: message }
74 | ],
75 | temperature: 0,
76 | max_tokens: 1024
77 | })
78 |
79 | const imagePrompt = rawResponse.choices[0].message.content!
80 | // console.log('gpt-4', imagePrompt)
81 | // await callback({ _id: imgId, msgId, prompt: imagePrompt })
82 |
83 | const response = await this.openai.images.generate({
84 | model: "dall-e-3",
85 | prompt: imagePrompt,
86 | n: 1,
87 | size: '1024x1024',
88 | response_format: 'b64_json'
89 | })
90 | // console.log('image', response)
91 | await callback({ _id: imgId, msgId, sent, prompt: imagePrompt, img: response.data[0].b64_json })
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/ChatBubbles.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { DocFileMeta } from 'use-fireproof'
3 |
4 | type ImageBubbleProps = {
5 | imgSrc?: string
6 | imgFile?: DocFileMeta
7 | alt?: string
8 | }
9 | export const ImageBubble: React.FC = ({
10 | imgSrc,
11 | imgFile,
12 | alt = 'Chat Bubble Image'
13 | }) => {
14 | return (
15 |
16 | {imgSrc &&
}
17 | {imgFile &&
}
18 | {!imgSrc && !imgFile &&
📷
}
19 |
20 | )
21 | }
22 | type ImageCircleProps = {
23 | imgSrc?: string
24 | imgFile?: DocFileMeta
25 | alt?: string
26 | }
27 |
28 | export const ImageCircle: React.FC = ({
29 | imgSrc,
30 | imgFile,
31 | alt = 'Chat Bubble Image'
32 | }) => {
33 | return (
34 |
35 | {imgSrc &&
}
36 | {imgFile &&
}
37 |
38 | )
39 | }
40 |
41 | type ChatBubbleProps = {
42 | message: string
43 | when?: string
44 | imgSrc?: string
45 | imgFile?: DocFileMeta
46 | }
47 | export const ChatBubble: React.FC = ({ message, imgSrc, imgFile, when }) => {
48 | return (
49 |
58 | )
59 | }
60 | type UserBubbleProps = {
61 | message: string
62 | when?: string
63 | imgSrc?: string
64 | imgFile?: DocFileMeta
65 | }
66 | export const UserBubble: React.FC = ({ message, imgSrc, imgFile, when }) => {
67 | return (
68 |
77 | )
78 | }
79 | function ImgFile({
80 | meta,
81 | alt,
82 | className
83 | }: {
84 | alt: string
85 | meta: DocFileMeta
86 | className?: string
87 | cache?: boolean
88 | }) {
89 | const [imgDataUrl, setImgDataUrl] = useState('')
90 |
91 | useEffect(() => {
92 | if (meta.file && /image/.test(meta.type)) {
93 | meta.file().then(file => {
94 | const src = URL.createObjectURL(file)
95 | setImgDataUrl(src)
96 | return () => {
97 | URL.revokeObjectURL(src)
98 | }
99 | })
100 | }
101 | }, [meta])
102 |
103 | if (imgDataUrl) {
104 | return
105 | } else {
106 | return <>>
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/Chat.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react'
2 | import { useParams } from 'react-router-dom'
3 | import { useFireproof, Doc, DocFileMeta } from 'use-fireproof'
4 | // import { InlineEditor } from '../components/InlineEditor'
5 | import { connect } from '@fireproof/partykit'
6 |
7 | import { useForm, FieldValues } from 'react-hook-form'
8 |
9 | import usePartySocket from 'partysocket/react'
10 |
11 | import catImage from '../assets/cat.png'
12 | import { ImageBubble, ChatBubble, UserBubble } from '../components/ChatBubbles'
13 |
14 | import { useAuth } from '@clerk/clerk-react'
15 |
16 | type MsgData = { _id: string; msg?: string; prompt?: string; done?: boolean; sent: number }
17 | type MsgDoc = Doc & MsgData
18 |
19 | const PUBLIC_PARTYKIT_HOST = import.meta.env.VITE_PUBLIC_PARTYKIT_HOST
20 |
21 | export function Chat() {
22 | const { id } = useParams<{ id: string }>()
23 | const dbName = id
24 | const { register, handleSubmit, resetField } = useForm()
25 | const { database, useLiveQuery } = useFireproof(dbName)
26 |
27 | const { getToken } = useAuth()
28 |
29 | connect.partykit(database, PUBLIC_PARTYKIT_HOST)
30 |
31 | const messages = useLiveQuery(
32 | (doc, emit) => {
33 | if (doc.sent) {
34 | emit(doc.sent)
35 | }
36 | },
37 | { limit: 100, descending: true }
38 | ).docs as MsgDoc[]
39 |
40 | // const messages = [...rmessages].reverse()
41 |
42 | const scrollableDivRef = useRef<
43 | HTMLDivElement & { scrollTo: (options: { top: number; behavior: 'smooth' }) => void }
44 | >(null)
45 |
46 | // const all = useLiveQuery('_id').docs
47 |
48 | const [incomingMessage, setIncomingMessage] = useState({
49 | _id: '',
50 | msg: '',
51 | sent: Date.now()
52 | })
53 |
54 | function scrollTo() {
55 | scrollableDivRef.current?.scrollTo({
56 | top: scrollableDivRef.current.scrollHeight
57 | // behavior: 'smooth'
58 | })
59 | }
60 |
61 | const socket = usePartySocket({
62 | host: PUBLIC_PARTYKIT_HOST,
63 | room: dbName!,
64 | query: async () => ({
65 | token: await getToken()
66 | }),
67 | onOpen() {
68 | // console.log('open', e)
69 | },
70 | onMessage(event: MessageEvent) {
71 | const message = JSON.parse(event.data)
72 | if (message.msgId) {
73 | if (message.img) {
74 | const base64Data = message.img
75 | const byteCharacters = atob(base64Data)
76 | const byteNumbers = new Array(byteCharacters.length)
77 | for (let i = 0; i < byteCharacters.length; i++) {
78 | byteNumbers[i] = byteCharacters.charCodeAt(i)
79 | }
80 | const byteArray = new Uint8Array(byteNumbers)
81 | const file = new File([byteArray], `image.png`, { type: 'image/png' })
82 | database.get(message._id).then(doc => {
83 | database.put({
84 | ...doc,
85 | prompt: message.prompt,
86 | _files: {
87 | img: file
88 | }
89 | } as unknown as Doc)
90 | })
91 | } else {
92 | database
93 | .put({
94 | _id: message._id,
95 | msgId: message.msgId,
96 | role: 'img',
97 | sent: message.sent
98 | } as unknown as Doc)
99 | .then(() => {
100 | scrollTo()
101 | })
102 | }
103 | } else {
104 | if (message.msg) {
105 | setIncomingMessage(message)
106 | scrollTo()
107 | }
108 | if (message.done) {
109 | database
110 | .put({ _id: message._id, msg: message.msg, sent: message.sent, role: 'ai' })
111 | .then(() => {
112 | scrollTo()
113 | })
114 | }
115 | }
116 | }
117 | })
118 |
119 | function sendMessage(formData: FieldValues) {
120 | const history = [...messages].map(({ msg, role }) => ({ msg, role }))
121 | formData.history = history.filter(({ msg, role }) => msg && role).reverse()
122 | socket.send(JSON.stringify(formData))
123 | database.put({ msg: formData.msg, sent: Date.now(), role: 'user' }).then(() => {
124 | setTimeout(() => {
125 | scrollTo()
126 | }, 100)
127 | })
128 | resetField('msg')
129 | }
130 |
131 | const rmessages = [...messages]
132 | if (incomingMessage._id) {
133 | if (!(rmessages.length && rmessages.find(({ _id }) => _id === incomingMessage._id))) {
134 | rmessages.unshift(incomingMessage)
135 | }
136 | }
137 |
138 | return (
139 | <>
140 |
141 |
Cat Chat
142 |
143 |
147 | {rmessages.map((message: MsgDoc) => {
148 | // console.log('message', message)
149 | if (message.role === 'user') {
150 | return (
151 |
156 | )
157 | } else if (message.role === 'img') {
158 | return (
159 |
164 | )
165 | } else {
166 | return (
167 |
173 | )
174 | }
175 | })}
176 |
177 |
181 |
182 |
183 |
184 |
185 |
200 |
201 | >
202 | )
203 | }
204 |
--------------------------------------------------------------------------------