feat: add custom ai chat for cohabit

This commit is contained in:
Julien Oculi 2024-02-19 17:12:35 +01:00
parent c2f3945f65
commit d948da7a29
6 changed files with 264 additions and 15 deletions

View file

@ -2,6 +2,7 @@ import { asset } from '$fresh/runtime.ts'
import SearchBox from '../islands/SearchBox.tsx' import SearchBox from '../islands/SearchBox.tsx'
import ThemePicker from '../islands/ThemePicker.tsx' import ThemePicker from '../islands/ThemePicker.tsx'
import MoreBox from '../islands/MoreBox.tsx' import MoreBox from '../islands/MoreBox.tsx'
import AiChatBox from '../islands/AiChatBox.tsx'
export function Header() { export function Header() {
return ( return (
@ -69,9 +70,7 @@ export function Header() {
<a href='/profil'> <a href='/profil'>
<i class='ri-user-line'></i> <i class='ri-user-line'></i>
</a> </a>
<button> <AiChatBox />
<i class='ri-bard-line'></i>
</button>
</MoreBox> </MoreBox>
</div> </div>
</header> </header>

View file

@ -35,7 +35,8 @@
"$std/": "https://deno.land/std@0.208.0/", "$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/mod.ts",
"@univoq/": "https://deno.land/x/univoq@0.2.0/", "@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": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",

View file

@ -4,37 +4,45 @@
import * as $_404 from './routes/_404.tsx' import * as $_404 from './routes/_404.tsx'
import * as $_app from './routes/_app.tsx' import * as $_app from './routes/_app.tsx'
import * as $contacts_index from './routes/contacts/index.tsx' import * as $blog_id_ from './routes/blog/[id].tsx'
import * as $equipes_id_ from './routes/equipes/[id].tsx' import * as $blog_index from './routes/blog/index.tsx'
import * as $equipes_index from './routes/equipes/index.tsx'
import * as $faq_index from './routes/faq/index.tsx'
import * as $index from './routes/index.tsx' import * as $index from './routes/index.tsx'
import * as $machines_id_ from './routes/machines/[id].tsx' import * as $machines_id_ from './routes/machines/[id].tsx'
import * as $machines_index from './routes/machines/index.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_admin from './routes/profil/admin.tsx'
import * as $profil_index from './routes/profil/index.tsx' import * as $profil_index from './routes/profil/index.tsx'
import * as $projets_id_ from './routes/projets/[id].tsx' import * as $projets_id_ from './routes/projets/[id].tsx'
import * as $projets_index from './routes/projets/index.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' import { type Manifest } from '$fresh/server.ts'
const manifest = { const manifest = {
routes: { routes: {
'./routes/_404.tsx': $_404, './routes/_404.tsx': $_404,
'./routes/_app.tsx': $_app, './routes/_app.tsx': $_app,
'./routes/contacts/index.tsx': $contacts_index, './routes/blog/[id].tsx': $blog_id_,
'./routes/equipes/[id].tsx': $equipes_id_, './routes/blog/index.tsx': $blog_index,
'./routes/equipes/index.tsx': $equipes_index,
'./routes/faq/index.tsx': $faq_index,
'./routes/index.tsx': $index, './routes/index.tsx': $index,
'./routes/machines/[id].tsx': $machines_id_, './routes/machines/[id].tsx': $machines_id_,
'./routes/machines/index.tsx': $machines_index, './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/admin.tsx': $profil_admin,
'./routes/profil/index.tsx': $profil_index, './routes/profil/index.tsx': $profil_index,
'./routes/projets/[id].tsx': $projets_id_, './routes/projets/[id].tsx': $projets_id_,
'./routes/projets/index.tsx': $projets_index, './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, baseUrl: import.meta.url,
} satisfies Manifest } satisfies Manifest

64
islands/AiChatBox.css Normal file
View file

@ -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;
}
}

176
islands/AiChatBox.tsx Normal file
View file

@ -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<BotMessage[]>([{
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<ReadableStreamDefaultReader<BotResponse> | null>(
null,
)
let currentResponse: string[] = []
function MdCell({ children }: { children: Signal<string> }) {
return (
<div
class='markdown-body'
dangerouslySetInnerHTML={{ __html: renderMd(children.value) }}
>
</div>
)
}
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<BotResponse> ?? new ReadableStream<BotResponse>()
}
throw new Error(response.statusText)
}
export default function AiChatBox() {
const dialog = useRef<HTMLDialogElement>(null)
const form = useRef<HTMLFormElement>(null)
const history = useSignal<JSX.Element[]>([])
useEffect(() => {
dialog.current?.addEventListener('click', (event) => {
if (event.target === dialog.current) {
dialog.current?.close()
}
})
form.current?.addEventListener(
'submit',
(event) => chatListener(event, history),
)
}, [])
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }}></style>
<button
class='islands__ai_chat_box__button'
onClick={() => dialog.current?.showModal()}
>
<i class='ri-bard-line'></i>
</button>
<dialog ref={dialog} class='islands__ai_chat_box__dialog'>
<div class='islands__ai_chat_box__dialog__content'>{history}</div>
<form ref={form} class='islands__ai_chat_box__dialog__form'>
<input
type='text'
name='query'
placeholder='Saisissez une requête ...'
autoFocus
autoComplete='off'
/>
<button>
<i class='ri-send-plane-2-line'></i>
</button>
</form>
</dialog>
</>
)
}
async function chatListener(event: Event, history: Signal<JSX.Element[]>) {
event.preventDefault()
const form = event.target as HTMLFormElement
const query = new FormData(form).get(
'query',
) as string
form.reset()
const userEntry = (
<span class='islands__ai_chat_box__history__user'>{query}</span>
)
const botMessage = signal('')
const botEntry = (
<span class='islands__ai_chat_box__history_bot'>
<MdCell>{botMessage}</MdCell>
</span>
)
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}`
}
}

View file

@ -11,3 +11,4 @@
@import url('../../islands/ThemePicker.css'); @import url('../../islands/ThemePicker.css');
@import url('../../islands/SearchBox.css'); @import url('../../islands/SearchBox.css');
@import url('../../islands/MoreBox.css'); @import url('../../islands/MoreBox.css');
@import url('../../islands/AiChatBox.css');