From d948da7a2929da61a5297e419f9397d85500beba Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Mon, 19 Feb 2024 17:12:35 +0100 Subject: [PATCH] feat: :sparkles: add custom ai chat for cohabit --- components/Header.tsx | 5 +- deno.json | 5 +- fresh.gen.ts | 28 ++++-- islands/AiChatBox.css | 64 ++++++++++++ islands/AiChatBox.tsx | 176 +++++++++++++++++++++++++++++++++ src/stylesheets/components.css | 1 + 6 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 islands/AiChatBox.css create mode 100644 islands/AiChatBox.tsx diff --git a/components/Header.tsx b/components/Header.tsx index 137fffe..6cd519c 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -2,6 +2,7 @@ import { asset } from '$fresh/runtime.ts' import SearchBox from '../islands/SearchBox.tsx' import ThemePicker from '../islands/ThemePicker.tsx' import MoreBox from '../islands/MoreBox.tsx' +import AiChatBox from '../islands/AiChatBox.tsx' export function Header() { return ( @@ -69,9 +70,7 @@ export function Header() { - + diff --git a/deno.json b/deno.json index 0a01468..249d640 100644 --- a/deno.json +++ b/deno.json @@ -35,10 +35,11 @@ "$std/": "https://deno.land/std@0.208.0/", "univoq": "https://deno.land/x/univoq@0.2.0/mod.ts", "@univoq/": "https://deno.land/x/univoq@0.2.0/", - "css_bundler": "../../../github.com/JOTSR/fresh_css_bundler/plugin.ts" + "css_bundler": "../../../github.com/JOTSR/fresh_css_bundler/plugin.ts", + "gfm": "https://deno.land/x/gfm@0.6.0/mod.ts" }, "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" } -} +} \ No newline at end of file diff --git a/fresh.gen.ts b/fresh.gen.ts index d418b56..b2df20c 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,37 +4,45 @@ import * as $_404 from './routes/_404.tsx' import * as $_app from './routes/_app.tsx' -import * as $contacts_index from './routes/contacts/index.tsx' -import * as $equipes_id_ from './routes/equipes/[id].tsx' -import * as $equipes_index from './routes/equipes/index.tsx' -import * as $faq_index from './routes/faq/index.tsx' +import * as $blog_id_ from './routes/blog/[id].tsx' +import * as $blog_index from './routes/blog/index.tsx' import * as $index from './routes/index.tsx' import * as $machines_id_ from './routes/machines/[id].tsx' import * as $machines_index from './routes/machines/index.tsx' +import * as $membres_id_ from './routes/membres/[id].tsx' +import * as $membres_index from './routes/membres/index.tsx' import * as $profil_admin from './routes/profil/admin.tsx' import * as $profil_index from './routes/profil/index.tsx' import * as $projets_id_ from './routes/projets/[id].tsx' import * as $projets_index from './routes/projets/index.tsx' - +import * as $AiChatBox from './islands/AiChatBox.tsx' +import * as $MoreBox from './islands/MoreBox.tsx' +import * as $SearchBox from './islands/SearchBox.tsx' +import * as $ThemePicker from './islands/ThemePicker.tsx' import { type Manifest } from '$fresh/server.ts' const manifest = { routes: { './routes/_404.tsx': $_404, './routes/_app.tsx': $_app, - './routes/contacts/index.tsx': $contacts_index, - './routes/equipes/[id].tsx': $equipes_id_, - './routes/equipes/index.tsx': $equipes_index, - './routes/faq/index.tsx': $faq_index, + './routes/blog/[id].tsx': $blog_id_, + './routes/blog/index.tsx': $blog_index, './routes/index.tsx': $index, './routes/machines/[id].tsx': $machines_id_, './routes/machines/index.tsx': $machines_index, + './routes/membres/[id].tsx': $membres_id_, + './routes/membres/index.tsx': $membres_index, './routes/profil/admin.tsx': $profil_admin, './routes/profil/index.tsx': $profil_index, './routes/projets/[id].tsx': $projets_id_, './routes/projets/index.tsx': $projets_index, }, - islands: {}, + islands: { + './islands/AiChatBox.tsx': $AiChatBox, + './islands/MoreBox.tsx': $MoreBox, + './islands/SearchBox.tsx': $SearchBox, + './islands/ThemePicker.tsx': $ThemePicker, + }, baseUrl: import.meta.url, } satisfies Manifest diff --git a/islands/AiChatBox.css b/islands/AiChatBox.css new file mode 100644 index 0000000..4791313 --- /dev/null +++ b/islands/AiChatBox.css @@ -0,0 +1,64 @@ +.islands__ai_chat_box__button { + color: var(--_font-color); + border: var(--_border-size) solid transparent; + outline: none; + background-color: transparent; + + &:active, + &:focus-visible { + border-color: currentColor; + } +} + +.islands__ai_chat_box__dialog { + justify-content: center; + align-content: end; + width: 80%; + height: 80%; + border: var(--_border-size) solid currentColor; + background: var(--_background-image) repeat top left / 800px; + background-color: var(--_background-color); + + &[open] { + display: grid; + } +} + +.islands__ai_chat_box__dialog__content { + overflow-y: scroll; + display: grid; + align-content: end; + gap: var(--_gap); + max-height: 100%; + overscroll-behavior: contain; + + & div { + display: block; + } +} + +.islands__ai_chat_box__history__user { + justify-self: right; +} + +.islands__ai_chat_box__history__bot { + justify-self: left; +} + +.islands__ai_chat_box__dialog__form { + display: flex; + gap: var(--_gap); + color: var(--_font-color); + + & > * { + background-color: var(--_translucent); + outline: none; + padding: var(--_gap-half); + border: var(--_border-size) solid transparent; + } + + & > *:active, + & > *:focus-visible { + border-color: currentColor; + } +} diff --git a/islands/AiChatBox.tsx b/islands/AiChatBox.tsx new file mode 100644 index 0000000..0cff435 --- /dev/null +++ b/islands/AiChatBox.tsx @@ -0,0 +1,176 @@ +import { Signal, signal, useSignal } from '@preact/signals' +import { useEffect, useRef } from 'preact/hooks' +import { JSX } from 'preact' +import { JsonParseStream } from '$std/json/mod.ts' +import { CSS, render as renderMd } from 'gfm' + +const systemHistory = signal([{ + role: 'system', + content: + `Tu es un assistant spécialisé dans l'open source qui s'appel Coh@bot. Tu répond en français avec des réponse courtes. Tu aide les usagers du Fablab nommé Cohabit en leur proposant des solutions techniques appropriées en français. Tu formule des réponses courtes et précises en français et en markdown si besoin.`, +}]) + +const currentReader = signal | null>( + null, +) +let currentResponse: string[] = [] + +function MdCell({ children }: { children: Signal }) { + return ( +
+
+ ) +} + +type BotMessage = { + role: string + content: string +} + +type BotResponse = { + model: string + created_at: string + message: { + role: 'assistant' | 'user' | 'system' + content: string + images?: string + } + done: boolean +} + +async function aiRequest( + { model, messages, stream }: { + model: string + messages: BotMessage[] + stream: boolean + }, +) { + systemHistory.value = [...systemHistory.peek(), ...messages] + + const body = JSON.stringify({ + model, + messages: systemHistory, + stream, + }) + + console.log('send', JSON.parse(body)) + + const response = await fetch('http://localhost:11434/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + body, + }) + + if (response.ok) { + return response.body?.pipeThrough(new TextDecoderStream()).pipeThrough( + new JsonParseStream(), + ) as ReadableStream ?? new ReadableStream() + } + + throw new Error(response.statusText) +} + +export default function AiChatBox() { + const dialog = useRef(null) + const form = useRef(null) + const history = useSignal([]) + + useEffect(() => { + dialog.current?.addEventListener('click', (event) => { + if (event.target === dialog.current) { + dialog.current?.close() + } + }) + + form.current?.addEventListener( + 'submit', + (event) => chatListener(event, history), + ) + }, []) + + return ( + <> + + + +
{history}
+
+ + +
+
+ + ) +} + +async function chatListener(event: Event, history: Signal) { + event.preventDefault() + const form = event.target as HTMLFormElement + + const query = new FormData(form).get( + 'query', + ) as string + + form.reset() + + const userEntry = ( + {query} + ) + + const botMessage = signal('') + + const botEntry = ( + + {botMessage} + + ) + + history.value = [...history.peek(), userEntry, botEntry] + + if (currentResponse.length !== 0) { + systemHistory.value = [...systemHistory.peek(), { + role: 'assistant', + content: currentResponse.join(''), + }] + currentResponse = [] + } + await currentReader.value?.cancel() + + const response = await aiRequest({ + model: 'mistral', + messages: [{ + role: 'user', + content: query, + }], + stream: true, + }) + + const reader = response.getReader() + currentReader.value = reader + + while (true) { + const { value, done } = await reader.read() + currentResponse.push(value?.message.content ?? '') + if (done) break + console.log(value.message.content) + botMessage.value = `${botMessage.peek()}${value.message.content}` + } +} diff --git a/src/stylesheets/components.css b/src/stylesheets/components.css index 6d67cd1..957bdae 100644 --- a/src/stylesheets/components.css +++ b/src/stylesheets/components.css @@ -11,3 +11,4 @@ @import url('../../islands/ThemePicker.css'); @import url('../../islands/SearchBox.css'); @import url('../../islands/MoreBox.css'); +@import url('../../islands/AiChatBox.css');