Skip to content

Commit

Permalink
Local only chat (#3)
Browse files Browse the repository at this point in the history
* add trpc and remove some callbacks

* can send messages + change to relative import for types (dunno why that works)

* create navigate to chat page

* messages can stream in fine

* stream down db chat messages

* chat page ui input

* grows from top expands from bottom

* fix scrolling

* remark markdown

* uselayouteffect scroll adjustment
  • Loading branch information
zolinthecow authored Aug 13, 2024
1 parent 623ab37 commit 0ba2265
Show file tree
Hide file tree
Showing 18 changed files with 1,039 additions and 224 deletions.
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cors": "^9.0.1",
"@repo/db": "workspace:*",
"@trpc/server": "11.0.0-rc.477",
"dotenv": "^16.4.5",
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dotenv/config';

import fastifyCors from '@fastify/cors';
import {
type FastifyTRPCPluginOptions,
fastifyTRPCPlugin,
Expand All @@ -15,6 +16,13 @@ const server = fastify({
maxParamLength: 5000,
});

server.register(fastifyCors, {
origin: ['http://localhost:5173'],
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['*'],
credentials: true,
});

server.register(AIServiceSingletonPlugin);

server.register(fastifyTRPCPlugin, {
Expand All @@ -34,7 +42,7 @@ server.register(fastifyTRPCPlugin, {
(async () => {
try {
console.info('[INFO]: Starting server...');
await server.listen({ port: SERVER_PORT });
await server.listen({ port: SERVER_PORT, host: '0.0.0.0' });
console.info(`[INFO]: Listening on port ${SERVER_PORT}`);
} catch (err) {
server.log.error(err);
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/trpc/routers/chat/createChat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DBChat } from '@repo/db';
import { DateTime } from 'luxon';
import { ulid } from 'ulid';
import { publicProcedure } from '../../trpc';

export const createChat = publicProcedure.mutation(async ({ ctx }) => {
const newChat: DBChat = {
id: ulid(),
userID: ctx.user.id,
previewName: '',
createdAt: DateTime.now().toJSDate(),
updatedAt: DateTime.now().toJSDate(),
};
return newChat;
});
4 changes: 3 additions & 1 deletion apps/server/src/trpc/routers/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { router } from '@/trpc/trpc';
import { router } from '../../trpc';
import { createChat } from './createChat';
import { sendMessage } from './sendMessage';

export const chatRouter = router({
sendMessage,
createChat,
});
64 changes: 34 additions & 30 deletions apps/server/src/trpc/routers/chat/sendMessage.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,47 @@
import { publicProcedure } from '@/trpc/trpc';
import {
type DBChat,
type DBChatMessage,
DBChatMessageSchema,
DBChatSchema,
} from '@repo/db';
import { type DBChatMessage, DBChatMessageSchema } from '@repo/db';
import { DateTime } from 'luxon';
import { ulid } from 'ulid';
import { z } from 'zod';
import { publicProcedure } from '../../trpc';

export const SendMessageSchema = z.object({
message: z.string(),
customSystemPrompt: z.string().optional(),
previousMessages: z.array(DBChatMessageSchema).optional(),
chat: DBChatSchema.optional(),
chatID: z.string(),
});
type SendMessageOutput =
| {
type: 'userMessage';
message: DBChatMessage;
}
| {
type: 'messageChunk';
messageChunk: DBChatMessage;
}
| {
type: 'completeMessage';
message: DBChatMessage;
};

export const sendMessage = publicProcedure
.input(SendMessageSchema)
.mutation(async function* ({ input, ctx }) {
let chat: DBChat;
if (input.chat) {
chat = input.chat;
} else {
let previewName = input.message;
if (previewName.length > 15) {
previewName = `${previewName.substring(0, 12)}...`;
}
chat = {
.mutation(async function* ({
input,
ctx,
}): AsyncGenerator<SendMessageOutput> {
yield {
type: 'userMessage',
message: {
id: ulid(),
userID: ctx.user.id,
previewName,
chatID: input.chatID,
messageType: 'user',
messageContent: input.message,
createdAt: DateTime.now().toJSDate(),
updatedAt: DateTime.now().toJSDate(),
};
yield {
type: 'newChat',
chat,
};
}
},
};

const openaiClient = ctx.aiService.getOpenAIClient();
const chatMessages = ctx.aiService.getChatPromptMessages({
Expand All @@ -51,15 +55,15 @@ export const sendMessage = publicProcedure
stream: true,
});

const messageId = ulid();
const messageID = ulid();
let fullMessage = '';
for await (const chunk of chatIterator) {
yield {
type: 'messageChunk',
messageChunk: {
id: messageId,
id: messageID,
userID: ctx.user.id,
chatID: chat.id,
chatID: input.chatID,
messageType: 'assistant',
messageContent: chunk.choices[0]?.delta.content || '',
createdAt: DateTime.now().toJSDate(),
Expand All @@ -72,9 +76,9 @@ export const sendMessage = publicProcedure
yield {
type: 'completeMessage',
message: {
id: messageId,
id: messageID,
userID: ctx.user.id,
chatID: chat.id,
chatID: input.chatID,
messageType: 'assistant',
messageContent: fullMessage,
createdAt: DateTime.now().toJSDate(),
Expand Down
21 changes: 17 additions & 4 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,31 @@
"@milkdown/utils": "^7.5.0",
"@radix-ui/react-slot": "^1.1.0",
"@repo/server": "workspace:*",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-router": "^1.47.1",
"@trpc/client": "11.0.0-rc.477",
"@trpc/react-query": "11.0.0-rc.477",
"@trpc/server": "11.0.0-rc.477",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.426.0",
"luxon": "^3.5.0",
"milkdown-plugin-placeholder": "^0.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react": "19.0.0-rc-d48603a5-20240813",
"react-dom": "19.0.0-rc-d48603a5-20240813",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"ulid": "^2.3.0"
},
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/server": "workspace:*",
"@tanstack/router-devtools": "^1.47.1",
"@tanstack/router-plugin": "^1.47.0",
"@types/luxon": "^3.4.2",
"@types/node": "^22.1.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"globals": "^15.9.0",
Expand All @@ -48,5 +57,9 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.3",
"vite": "^5.4.0"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}
158 changes: 158 additions & 0 deletions apps/ui/src/components/InputBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { editorView, editorViewCtx } from '@milkdown/core';
import type { Ctx } from '@milkdown/ctx';
import { TextSelection } from '@milkdown/prose/state';
import { MilkdownProvider, useInstance } from '@milkdown/react';
import { getMarkdown } from '@milkdown/utils';
import { ArrowUp } from 'lucide-react';
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { MilkdownEditor } from './Milkdown/Milkdown';
import { Button } from './ui/button';

type InputBoxProps = {
handleSubmit: (text: string) => void;
placeholderText: string;
};

const InputBox: React.FC<InputBoxProps> = (props) => {
return (
<MilkdownProvider>
<InputBoxInner {...props} />
</MilkdownProvider>
);
};

const InputBoxInner: React.FC<InputBoxProps> = ({
handleSubmit,
placeholderText,
}) => {
const [loading, getEditor] = useInstance();
const editorAction = useCallback(
(fn: (ctx: Ctx) => void) => {
if (loading) return null;
return getEditor().action(fn);
},
[loading, getEditor],
);
const [inputContent, setInputContent] = useState('');

const _handleSubmit = useCallback((): void => {
editorAction((ctx) => {
const md = getMarkdown()(ctx);
handleSubmit(md);

const view = ctx.get(editorViewCtx);
const { tr } = view.state;
tr.delete(0, view.state.doc.content.size);
view.dispatch(tr);

setInputContent('');
});
}, [handleSubmit, editorAction]);

useEffect(() => {
let cleanup: (() => void) | undefined;

const setupEditor = () => {
const handleContentUpdated = () =>
editorAction((ctx) => {
const md = getMarkdown()(ctx);
setInputContent(md);
});

const handleKeyUp = (e: KeyboardEvent) => {
handleContentUpdated();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey
) {
e.preventDefault();
_handleSubmit();
}
};

editorAction((ctx) => {
const view = ctx.get(editorViewCtx);
view.dom.addEventListener('keydown', handleKeyDown);
view.dom.addEventListener('keyup', handleKeyUp);
cleanup = () => {
view.dom.removeEventListener('keydown', handleKeyDown);
view.dom.removeEventListener('keyup', handleKeyUp);
};
});

handleContentUpdated();
};

if (!loading) {
setupEditor();
}

return () => {
if (cleanup) {
cleanup();
}
};
}, [loading, editorAction, _handleSubmit]);

const focusOnEditor = useCallback(() => {
editorAction((ctx) => {
const view = ctx.get(editorViewCtx);
const { state } = view;
if (!state.selection) {
const selection = TextSelection.create(state.doc, 1);
view.focus();
view.dispatch(state.tr.setSelection(selection));
} else {
view.focus();
}
});
}, [editorAction]);

useEffect(() => {
focusOnEditor();
}, [focusOnEditor]);

return (
// biome-ignore lint/a11y/useKeyWithClickEvents: This is technically a text input
<div
className="flex items-start w-full h-full overflow-y-scroll bg-muted p-2"
onClick={() => focusOnEditor()}
>
<MilkdownEditor placeholderText={placeholderText} />
<div className="w-8 relative">
<SubmitButton
onSubmit={_handleSubmit}
show={inputContent.length > 0}
/>
</div>
</div>
);
};

type SubmitButtonProps = {
onSubmit: () => void;
show: boolean;
};
const SubmitButton: React.FC<SubmitButtonProps> = ({ onSubmit, show }) => {
return (
<Button
// We only want to show the submit button if there is content in the box.
className={`
transition-all duration-200 ease-in-out
${show ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
fixed w-8 h-8 p-0 rounded-xl bg-primary text-primary-foreground flex items-center justify-center
`}
onClick={onSubmit}
>
<ArrowUp className="w-4 h-4" />
</Button>
);
};

export default InputBox;
7 changes: 5 additions & 2 deletions apps/ui/src/components/Milkdown/Milkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ import codeBlockSyntaxPlugin from './milkdownPlugins/codeBlockSyntaxPlugin';
import headerSyntaxPlugin from './milkdownPlugins/headerSyntaxPlugin';
import inlineCodePlugin from './milkdownPlugins/inlineCodeSyntaxPlugin';

export const MilkdownEditor: React.FC = () => {
type Props = {
placeholderText: string;
};
export const MilkdownEditor: React.FC<Props> = ({ placeholderText }) => {
const { get } = useEditor((root) =>
Editor.make()
.config(nord)
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, '');
ctx.set(placeholderCtx, 'How can Charlie help you today?');
ctx.set(placeholderCtx, placeholderText);
})
.use(listener)
.use(commonmark)
Expand Down
Loading

0 comments on commit 0ba2265

Please sign in to comment.