rename frontend subfolder
This commit is contained in:
parent
0a894237bb
commit
16f084bfcd
21 changed files with 12 additions and 2 deletions
16
frontend/src/App.svelte
Normal file
16
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script>
|
||||
import Chat from "./lib/Chat.svelte";
|
||||
import ChatList from "./lib/ChatList.svelte";
|
||||
</script>
|
||||
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col h-screen">
|
||||
<Chat />
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<label for="drawer-toggle" class="drawer-overlay"></label>
|
||||
<ChatList />
|
||||
</div>
|
||||
</div>
|
||||
3
frontend/src/app.css
Normal file
3
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@plugin "@tailwindcss/typography";
|
||||
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
66
frontend/src/lib/Chat.svelte
Normal file
66
frontend/src/lib/Chat.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script>
|
||||
import ChatMessage from "./ChatMessage.svelte";
|
||||
import { chatStore } from "./chatStore.svelte.js";
|
||||
</script>
|
||||
|
||||
<!-- header -->
|
||||
<header class="p-4">
|
||||
<h1 class="text-5xl font-bold">Multi AI Chat</h1>
|
||||
</header>
|
||||
|
||||
<!-- messages -->
|
||||
<main class="flex-1 p-4 space-y-3 overflow-y-auto">
|
||||
{#each chatStore.messages as m (m.id)}
|
||||
<ChatMessage message={m} />
|
||||
{/each}
|
||||
|
||||
{#if chatStore.loading}
|
||||
<div class="chat chat-start">
|
||||
<div class="chat-bubble chat-bubble-secondary loading"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- input -->
|
||||
<footer class="bg-neutral-content rounded-xl">
|
||||
<div class="flex items-center">
|
||||
<div class="form-control flex-1 m-4">
|
||||
<textarea
|
||||
class="textarea w-full"
|
||||
rows="1"
|
||||
placeholder="Type something…"
|
||||
bind:value={chatStore.input}
|
||||
onkeydown={chatStore.handleKey}
|
||||
disabled={chatStore.loading}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center m-4">
|
||||
<select
|
||||
class="select select-bordered join-item"
|
||||
bind:value={chatStore.model}
|
||||
disabled={chatStore.loading || chatStore.loadingModels}
|
||||
>
|
||||
{#if chatStore.loadingModels}
|
||||
<option value="" disabled>Loading models...</option>
|
||||
{:else if chatStore.models.length === 0}
|
||||
<option value="" disabled>No model available</option>
|
||||
{:else}
|
||||
{#each chatStore.models as modelOption}
|
||||
<option value={modelOption.id || modelOption}>
|
||||
{modelOption.name || modelOption.id || modelOption}
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-primary ml-auto"
|
||||
onclick={chatStore.send}
|
||||
disabled={!chatStore.input.trim() || chatStore.models.length === 0}
|
||||
>
|
||||
{#if chatStore.loading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}Send{/if}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
33
frontend/src/lib/ChatList.svelte
Normal file
33
frontend/src/lib/ChatList.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import { chatStore } from "./chatStore.svelte.js";
|
||||
</script>
|
||||
|
||||
<aside class="menu p-4 w-64 bg-base-200 min-h-full">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-lg font-bold">Chats</span>
|
||||
<button
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick={() =>
|
||||
chatStore.selectChat(null) && chatStore.createAndSelect()}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="menu menu-compact">
|
||||
{#each chatStore.history as c}
|
||||
<li>
|
||||
<a
|
||||
href="/{c.id}"
|
||||
class={chatStore.chatId === c.id ? "active" : ""}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
chatStore.selectChat(c.id);
|
||||
}}
|
||||
>
|
||||
{c.title}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
29
frontend/src/lib/ChatMessage.svelte
Normal file
29
frontend/src/lib/ChatMessage.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script>
|
||||
import { marked } from "marked";
|
||||
let { message } = $props(); // { id, role, text }
|
||||
|
||||
const text = $derived(message.text);
|
||||
const me = $derived(message.role == "user");
|
||||
|
||||
/* optional: allow HTML inside the markdown (default is escaped) */
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
</script>
|
||||
|
||||
{#if me}
|
||||
<div class="chat chat-end">
|
||||
<div class="chat-bele chat-bubble chat-bubble-primary">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="chat chat-start">
|
||||
<div
|
||||
class="chat-bele chat-bubble {message.role === 'error'
|
||||
? 'text-error'
|
||||
: ''} prose max-w-none"
|
||||
>
|
||||
<!-- eslint-disable svelte/no-at-html-tags -->
|
||||
{@html marked(text)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
35
frontend/src/lib/chatApi.svelte.js
Normal file
35
frontend/src/lib/chatApi.svelte.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const API = "http://localhost:8000"; // change if needed
|
||||
|
||||
export async function createChat(model = "qwen/qwen3-235b-a22b-2507") {
|
||||
const r = await fetch(`${API}/chats`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
return r.json(); // { chat_id }
|
||||
}
|
||||
|
||||
export async function sendUserMessage(chatId, text, model = "") {
|
||||
const r = await fetch(`${API}/chats/${chatId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, model }),
|
||||
});
|
||||
return r.json(); // { message_id }
|
||||
}
|
||||
|
||||
export function openStream(chatId, messageId) {
|
||||
return new EventSource(
|
||||
`${API}/chats/${chatId}/stream?message_id=${messageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchModels() {
|
||||
try {
|
||||
const response = await fetch(`${API}/models`);
|
||||
const data = await response.json();
|
||||
return data.models || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
153
frontend/src/lib/chatStore.svelte.js
Normal file
153
frontend/src/lib/chatStore.svelte.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { createChat, sendUserMessage, openStream, fetchModels } from "./chatApi.svelte.js";
|
||||
|
||||
const STORAGE_KEY = "chatHistory";
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(list) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
export const chatStore = (() => {
|
||||
let chatId = $state(null);
|
||||
let messages = $state([]);
|
||||
let loading = $state(false);
|
||||
let input = $state("");
|
||||
let model = $state("qwen/qwen3-235b-a22b-2507"); // default
|
||||
let models = $state([]);
|
||||
let loadingModels = $state(true);
|
||||
|
||||
// public helpers
|
||||
const history = $derived(loadHistory());
|
||||
|
||||
function pushHistory(id, title, msgs) {
|
||||
console.log(`push history: ${id} - ${title}`);
|
||||
const h = history.filter((c) => c.id !== id);
|
||||
h.unshift({ id, title, messages: msgs });
|
||||
saveHistory(h.slice(0, 50)); // keep last 50
|
||||
}
|
||||
|
||||
async function selectChat(id) {
|
||||
if (id === chatId) return;
|
||||
chatId = id;
|
||||
const stored = loadHistory().find((c) => c.id === id);
|
||||
messages = stored?.messages || [];
|
||||
loading = true;
|
||||
loading = false;
|
||||
window.history.replaceState({}, "", `/${id}`);
|
||||
}
|
||||
|
||||
async function createAndSelect() {
|
||||
const { id } = await createChat(model);
|
||||
console.log(id);
|
||||
selectChat(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!input.trim()) return;
|
||||
if (!chatId) await createAndSelect();
|
||||
|
||||
const userMsg = { id: crypto.randomUUID(), role: "user", text: input };
|
||||
messages = [...messages, userMsg];
|
||||
|
||||
pushHistory(chatId, userMsg.text.slice(0, 30), messages);
|
||||
|
||||
loading = true;
|
||||
const { message_id } = await sendUserMessage(chatId, input, model);
|
||||
input = "";
|
||||
|
||||
let assistantMsg = { id: message_id, role: "assistant", text: "" };
|
||||
messages = [...messages, assistantMsg];
|
||||
|
||||
const es = openStream(chatId, message_id);
|
||||
es.onmessage = (e) => {
|
||||
assistantMsg = { ...assistantMsg, text: assistantMsg.text + e.data };
|
||||
messages = [...messages.slice(0, -1), assistantMsg];
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
loading = false;
|
||||
};
|
||||
es.addEventListener("done", (e) => {
|
||||
console.log(e);
|
||||
es.close();
|
||||
loading = false;
|
||||
pushHistory(chatId, userMsg.text.slice(0, 30), messages);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
loadingModels = true;
|
||||
models = await fetchModels();
|
||||
loadingModels = false;
|
||||
|
||||
// Set default model if available and not already set
|
||||
if (models.length > 0 && !model) {
|
||||
model = models[0].id || models[0];
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
// initial route handling
|
||||
const path = window.location.pathname.slice(1);
|
||||
const storedHistory = loadHistory();
|
||||
if (path && !storedHistory.find((c) => c.id === path)) {
|
||||
createAndSelect();
|
||||
} else if (path) {
|
||||
selectChat(path);
|
||||
}
|
||||
|
||||
// Load models on initialization
|
||||
loadModels();
|
||||
|
||||
return {
|
||||
get chatId() {
|
||||
return chatId;
|
||||
},
|
||||
get messages() {
|
||||
return messages;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get input() {
|
||||
return input;
|
||||
},
|
||||
set input(v) {
|
||||
input = v;
|
||||
},
|
||||
get model() {
|
||||
return model;
|
||||
},
|
||||
set model(v) {
|
||||
model = v;
|
||||
},
|
||||
get models() {
|
||||
return models;
|
||||
},
|
||||
get loadingModels() {
|
||||
return loadingModels;
|
||||
},
|
||||
get history() {
|
||||
return loadHistory();
|
||||
},
|
||||
selectChat,
|
||||
send,
|
||||
handleKey,
|
||||
createAndSelect,
|
||||
loadModels,
|
||||
};
|
||||
})();
|
||||
31
frontend/src/lib/router.svelte.js
Normal file
31
frontend/src/lib/router.svelte.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { chatStore } from "./chatStore.svelte.js";
|
||||
|
||||
// keyed by chat_id → chatStore instance
|
||||
const cache = $state({});
|
||||
|
||||
// which chat is on screen right now
|
||||
export const activeChatId = $state(null);
|
||||
|
||||
export function getStore(chatId) {
|
||||
if (!cache[chatId]) {
|
||||
cache[chatId] = chatStore(chatId);
|
||||
}
|
||||
return cache[chatId];
|
||||
}
|
||||
|
||||
export function switchChat(chatId) {
|
||||
activeChatId = chatId;
|
||||
}
|
||||
|
||||
export function newChat() {
|
||||
const id = "chat_" + crypto.randomUUID();
|
||||
switchChat(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
// restore last opened chat (or create first one)
|
||||
(() => {
|
||||
const ids = JSON.parse(localStorage.getItem("chat_ids") || "[]");
|
||||
if (ids.length) switchChat(ids[0]);
|
||||
else newChat();
|
||||
})();
|
||||
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue