feat: ✨ add custom ai chat for cohabit
This commit is contained in:
parent
c2f3945f65
commit
d948da7a29
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
28
fresh.gen.ts
28
fresh.gen.ts
|
@ -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
64
islands/AiChatBox.css
Normal 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
176
islands/AiChatBox.tsx
Normal 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}`
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
||||||
|
|
Loading…
Reference in a new issue