picklefried706 commited on
Commit
40a9423
·
verified ·
1 Parent(s): 13c97f4

Upload folder using huggingface_hub

Browse files
.vscode/launch.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "type": "chrome",
9
+ "request": "launch",
10
+ "name": "Launch Chrome against localhost",
11
+ "url": "http://localhost:8080",
12
+ "webRoot": "${workspaceFolder}"
13
+ }
14
+ ]
15
+ }
README.md CHANGED
@@ -1,23 +1,58 @@
1
- ---
2
- license: llama3
3
- datasets:
4
- - shareAI/ShareGPT-Chinese-English-90k
5
- - OpenAssistant/oasst2
6
- - tatsu-lab/alpaca
7
- language:
8
- - en
9
- base_model:
10
- - meta-llama/Meta-Llama-3-8B-Instruct
11
- pipeline_tag: text-generation
12
- library_name: transformers
13
- tags:
14
- - text-generation
15
- - chat
16
- - assistant
17
- - lora
18
- - conversational
19
- - qlora
20
- - fine-tuned
21
- - llama
22
- - transformers
23
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NovaChat Platform
2
+
3
+ A production-grade AI chatbot platform with live web search, streaming responses, and a modern UI.
4
+
5
+ ## Folder Structure
6
+ - backend
7
+ - src
8
+ - ai (routing, memory, context compression)
9
+ - search (search, extraction, ranking, cache)
10
+ - utils (LLM streaming client)
11
+ - frontend
12
+ - src (React UI)
13
+
14
+ ## Requirements
15
+ - Node.js 20+
16
+ - npm 10+
17
+
18
+ ## Backend Setup
19
+ ```bash
20
+ cd backend
21
+ npm install
22
+ npm run build
23
+ set LLM_API_KEY=YOUR_KEY_HERE
24
+ set LLM_MODEL=gpt-4o-mini
25
+ set LLM_API_BASE=https://api.openai.com
26
+ npm start
27
+ ```
28
+
29
+ ## Frontend Setup
30
+ ```bash
31
+ cd frontend
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ The frontend is proxied to `http://localhost:8080` for API calls.
37
+
38
+ ## Docker Setup (Compose)
39
+ ```bash
40
+ set LLM_API_KEY=YOUR_KEY_HERE
41
+ set LLM_MODEL=gpt-4o-mini
42
+ set LLM_API_BASE=https://api.openai.com
43
+ docker compose up --build
44
+ ```
45
+
46
+ Open `http://localhost:4173`.
47
+
48
+ ## Performance Notes
49
+ - SSE streaming for token-by-token delivery.
50
+ - Parallel search fetch + content extraction.
51
+ - LRU caches for search results and conversation memory.
52
+ - Minimal sync blocking in the request path.
53
+
54
+ ## Environment Variables
55
+ - `LLM_API_KEY`: API key for OpenAI-compatible endpoint
56
+ - `LLM_API_BASE`: Base URL for the OpenAI-compatible API (default: `https://api.openai.com`)
57
+ - `LLM_MODEL`: Model id (default: `gpt-4o-mini`)
58
+ - `PORT`: Backend port (default: `8080`)
backend/Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Dockerfile
2
+ FROM node:20-alpine
3
+
4
+ WORKDIR /app
5
+
6
+ COPY package.json package-lock.json* ./
7
+ RUN npm install
8
+
9
+ COPY tsconfig.json ./
10
+ COPY src ./src
11
+
12
+ RUN npm run build
13
+
14
+ ENV PORT=8080
15
+ EXPOSE 8080
16
+
17
+ CMD ["npm", "start"]
backend/package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ai-chatbot-backend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "node --watch --enable-source-maps dist/index.js",
8
+ "build": "tsc -p tsconfig.json",
9
+ "start": "node dist/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@fastify/cors": "^10.0.0",
13
+ "@mozilla/readability": "^0.5.0",
14
+ "fastify": "^5.2.1",
15
+ "jsdom": "^26.0.0",
16
+ "lru-cache": "^11.0.2",
17
+ "undici": "^6.21.0",
18
+ "zod": "^3.23.8"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.10.2",
22
+ "typescript": "^5.7.2"
23
+ }
24
+ }
backend/src/ai/context.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChatMessage } from "../types.js";
2
+
3
+ const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
4
+
5
+ export function summarizeExtractive(messages: ChatMessage[], maxSentences = 5) {
6
+ const text = messages.map((m) => `${m.role}: ${m.content}`).join(" ");
7
+ const sentences = text.split(SENTENCE_SPLIT).filter(Boolean);
8
+ if (sentences.length <= maxSentences) return sentences.join(" ");
9
+ const head = sentences.slice(0, Math.ceil(maxSentences / 2));
10
+ const tail = sentences.slice(-Math.floor(maxSentences / 2));
11
+ return [...head, ...tail].join(" ");
12
+ }
13
+
14
+ export function estimateTokens(text: string) {
15
+ return Math.ceil(text.split(/\s+/).length * 1.35);
16
+ }
17
+
18
+ export function totalTokens(messages: ChatMessage[]) {
19
+ return messages.reduce((acc, msg) => acc + estimateTokens(msg.content), 0);
20
+ }
backend/src/ai/engine.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChatMessage, ChatRequest, SearchSummary } from "../types.js";
2
+ import { memoryStore } from "./memory.js";
3
+ import { summarizeExtractive, totalTokens } from "./context.js";
4
+ import { buildSystemPrompt, shouldSearch, trimHistory } from "./router.js";
5
+ import { searchWeb } from "../search/search.js";
6
+ import { streamChatCompletion } from "../utils/stream.js";
7
+
8
+ const MAX_CONTEXT_TOKENS = 2600;
9
+
10
+ export type EngineEvents = {
11
+ onStatus: (message: string) => void;
12
+ onToken: (token: string) => void;
13
+ onDone: () => void;
14
+ onError: (message: string) => void;
15
+ };
16
+
17
+ function buildSearchContext(summary: SearchSummary) {
18
+ const lines: string[] = [];
19
+ summary.results.forEach((r, idx) => {
20
+ lines.push(`Source ${idx + 1}: ${r.title} (${r.url})`);
21
+ lines.push(r.snippet);
22
+ });
23
+ summary.topContent.forEach((c, idx) => {
24
+ lines.push(`Content ${idx + 1}: ${c.title}`);
25
+ lines.push(c.content.slice(0, 1200));
26
+ });
27
+ return lines.join("\n");
28
+ }
29
+
30
+ export async function handleChat(request: ChatRequest, events: EngineEvents) {
31
+ const session = memoryStore.get(request.sessionId);
32
+ const userMessage: ChatMessage = {
33
+ id: `msg_${Date.now()}`,
34
+ role: "user",
35
+ content: request.message,
36
+ createdAt: Date.now()
37
+ };
38
+
39
+ session.messages.push(userMessage);
40
+
41
+ const useSearch = shouldSearch(request.message, request.mode ?? "auto");
42
+ let searchSummary: SearchSummary | null = null;
43
+
44
+ if (useSearch) {
45
+ events.onStatus("Searching web...");
46
+ try {
47
+ searchSummary = await searchWeb(request.message);
48
+ events.onStatus("Summarizing sources...");
49
+ } catch (err) {
50
+ const msg = err instanceof Error ? err.message : "Search failed";
51
+ events.onStatus("Search failed, continuing without web context.");
52
+ events.onError(msg);
53
+ }
54
+ }
55
+
56
+ if (totalTokens(session.messages) > MAX_CONTEXT_TOKENS) {
57
+ const trimmed = trimHistory(session.messages, 12);
58
+ session.summary = summarizeExtractive(trimmed, 6);
59
+ session.messages = trimmed;
60
+ }
61
+
62
+ const searchContext = searchSummary ? buildSearchContext(searchSummary) : null;
63
+ const systemPrompt = buildSystemPrompt(session.summary, searchContext);
64
+
65
+ const payloadMessages = [
66
+ { role: "system", content: systemPrompt },
67
+ ...session.messages.map((m) => ({ role: m.role, content: m.content }))
68
+ ];
69
+
70
+ const assistantMessage: ChatMessage = {
71
+ id: `msg_${Date.now()}_assistant`,
72
+ role: "assistant",
73
+ content: "",
74
+ createdAt: Date.now()
75
+ };
76
+
77
+ try {
78
+ await streamChatCompletion(payloadMessages, (token) => {
79
+ assistantMessage.content += token;
80
+ events.onToken(token);
81
+ });
82
+ session.messages.push(assistantMessage);
83
+ memoryStore.set(request.sessionId, session);
84
+ events.onDone();
85
+ } catch (err) {
86
+ const msg = err instanceof Error ? err.message : "LLM call failed";
87
+ events.onError(msg);
88
+ }
89
+ }
backend/src/ai/memory.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChatMessage } from "../types.js";
2
+ import { LRUCache } from "lru-cache";
3
+
4
+ export type SessionState = {
5
+ messages: ChatMessage[];
6
+ summary: string;
7
+ lastAccess: number;
8
+ };
9
+
10
+ const sessions = new LRUCache<string, SessionState>({
11
+ max: 5000,
12
+ ttl: 1000 * 60 * 60 * 6
13
+ });
14
+
15
+ export const memoryStore = {
16
+ get(sessionId: string): SessionState {
17
+ const existing = sessions.get(sessionId);
18
+ if (existing) {
19
+ existing.lastAccess = Date.now();
20
+ return existing;
21
+ }
22
+ const created: SessionState = {
23
+ messages: [],
24
+ summary: "",
25
+ lastAccess: Date.now()
26
+ };
27
+ sessions.set(sessionId, created);
28
+ return created;
29
+ },
30
+ set(sessionId: string, state: SessionState) {
31
+ state.lastAccess = Date.now();
32
+ sessions.set(sessionId, state);
33
+ }
34
+ };
backend/src/ai/router.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChatMessage } from "../types.js";
2
+
3
+ const SEARCH_HINTS = [
4
+ "search",
5
+ "browse",
6
+ "look up",
7
+ "latest",
8
+ "today",
9
+ "current",
10
+ "news",
11
+ "price",
12
+ "stock",
13
+ "weather",
14
+ "schedule",
15
+ "open ai",
16
+ "openai",
17
+ "documentation",
18
+ "docs"
19
+ ];
20
+
21
+ export function shouldSearch(userMessage: string, mode: "auto" | "search" | "no-search") {
22
+ if (mode === "search") return true;
23
+ if (mode === "no-search") return false;
24
+ const normalized = userMessage.toLowerCase();
25
+ return SEARCH_HINTS.some((hint) => normalized.includes(hint));
26
+ }
27
+
28
+ export function buildSystemPrompt(summary: string, searchContext: string | null) {
29
+ const base = [
30
+ "You are a production-grade AI assistant. Be concise, accurate, and clear.",
31
+ "Use markdown only when it improves clarity.",
32
+ "If citing sources, include short parenthetical source labels like (Source 1).",
33
+ "If you use web results, prefer them over memory."
34
+ ];
35
+ if (summary) {
36
+ base.push(`Conversation summary: ${summary}`);
37
+ }
38
+ if (searchContext) {
39
+ base.push(`Web context:\n${searchContext}`);
40
+ }
41
+ return base.join("\n");
42
+ }
43
+
44
+ export function trimHistory(messages: ChatMessage[], max = 20) {
45
+ if (messages.length <= max) return messages;
46
+ return messages.slice(messages.length - max);
47
+ }
backend/src/index.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Fastify from "fastify";
2
+ import cors from "@fastify/cors";
3
+ import { z } from "zod";
4
+ import { handleChat } from "./ai/engine.js";
5
+ import { ChatRequest } from "./types.js";
6
+
7
+ const server = Fastify({
8
+ logger: true
9
+ });
10
+
11
+ await server.register(cors, {
12
+ origin: true,
13
+ credentials: true
14
+ });
15
+
16
+ server.get("/health", async () => ({ status: "ok" }));
17
+
18
+ server.post("/api/chat", async (request, reply) => {
19
+ const body = request.body as ChatRequest;
20
+ const schema = z.object({
21
+ sessionId: z.string().min(1),
22
+ message: z.string().min(1),
23
+ mode: z.enum(["auto", "search", "no-search"]).optional()
24
+ });
25
+ const parsed = schema.safeParse(body);
26
+ if (!parsed.success) {
27
+ reply.status(400);
28
+ return { error: parsed.error.flatten() };
29
+ }
30
+
31
+ const chunks: string[] = [];
32
+ await handleChat(parsed.data, {
33
+ onStatus: () => {},
34
+ onToken: (token) => chunks.push(token),
35
+ onDone: () => {},
36
+ onError: () => {}
37
+ });
38
+
39
+ return { message: chunks.join("") };
40
+ });
41
+
42
+ server.get("/api/chat/stream", async (request, reply) => {
43
+ const query = request.query as Record<string, string>;
44
+ const schema = z.object({
45
+ sessionId: z.string().min(1),
46
+ message: z.string().min(1),
47
+ mode: z.enum(["auto", "search", "no-search"]).optional()
48
+ });
49
+ const parsed = schema.safeParse(query);
50
+ if (!parsed.success) {
51
+ reply.status(400);
52
+ return reply.send({ error: parsed.error.flatten() });
53
+ }
54
+
55
+ reply.raw.writeHead(200, {
56
+ "Content-Type": "text/event-stream",
57
+ "Cache-Control": "no-cache, no-transform",
58
+ Connection: "keep-alive",
59
+ "X-Accel-Buffering": "no"
60
+ });
61
+ reply.raw.flushHeaders?.();
62
+
63
+ const writeEvent = (event: string, data: string) => {
64
+ reply.raw.write(`event: ${event}\n`);
65
+ reply.raw.write(`data: ${data}\n\n`);
66
+ };
67
+
68
+ const requestPayload: ChatRequest = {
69
+ sessionId: parsed.data.sessionId,
70
+ message: parsed.data.message,
71
+ mode: parsed.data.mode
72
+ };
73
+
74
+ let closed = false;
75
+ request.raw.on("close", () => {
76
+ closed = true;
77
+ });
78
+
79
+ await handleChat(requestPayload, {
80
+ onStatus: (message) => !closed && writeEvent("status", JSON.stringify({ message })),
81
+ onToken: (token) => !closed && writeEvent("delta", JSON.stringify({ token })),
82
+ onDone: () => !closed && writeEvent("done", JSON.stringify({ ok: true })),
83
+ onError: (message) => !closed && writeEvent("error", JSON.stringify({ message }))
84
+ });
85
+
86
+ reply.raw.end();
87
+ });
88
+
89
+ const port = Number(process.env.PORT ?? 8080);
90
+ server.listen({ port, host: "0.0.0.0" }).catch((err) => {
91
+ server.log.error(err);
92
+ process.exit(1);
93
+ });
backend/src/search/cache.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LRUCache } from "lru-cache";
2
+ import { SearchSummary } from "../types.js";
3
+
4
+ const cache = new LRUCache<string, SearchSummary>({
5
+ max: 200,
6
+ ttl: 1000 * 60 * 10
7
+ });
8
+
9
+ export function getCached(query: string) {
10
+ return cache.get(query);
11
+ }
12
+
13
+ export function setCached(query: string, summary: SearchSummary) {
14
+ cache.set(query, summary);
15
+ }
backend/src/search/duckduckgo.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { JSDOM } from "jsdom";
2
+ import { fetchText } from "./fetch.js";
3
+
4
+ export type RawSearchResult = {
5
+ title: string;
6
+ url: string;
7
+ snippet: string;
8
+ };
9
+
10
+ export async function searchDuckDuckGo(query: string, limit = 6): Promise<RawSearchResult[]> {
11
+ const url = `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`;
12
+ const html = await fetchText(url, {
13
+ headers: {
14
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
15
+ }
16
+ });
17
+ const dom = new JSDOM(html);
18
+ const document = dom.window.document;
19
+ const links = Array.from(document.querySelectorAll("a.result-link"));
20
+ const results: RawSearchResult[] = [];
21
+
22
+ for (const link of links) {
23
+ if (results.length >= limit) break;
24
+ const title = link.textContent?.trim() ?? "";
25
+ const url = link.getAttribute("href") ?? "";
26
+ const snippetEl = link.closest("tr")?.nextElementSibling?.querySelector("td.result-snippet");
27
+ const snippet = snippetEl?.textContent?.trim() ?? "";
28
+ if (title && url) {
29
+ results.push({ title, url, snippet });
30
+ }
31
+ }
32
+
33
+ return results;
34
+ }
backend/src/search/extract.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { JSDOM } from "jsdom";
2
+ import { Readability } from "@mozilla/readability";
3
+ import { fetchText } from "./fetch.js";
4
+
5
+ export async function extractReadable(url: string) {
6
+ const html = await fetchText(url, {
7
+ headers: {
8
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
9
+ }
10
+ });
11
+ const dom = new JSDOM(html, { url });
12
+ const reader = new Readability(dom.window.document);
13
+ const article = reader.parse();
14
+ if (article?.textContent) {
15
+ return {
16
+ title: article.title ?? "",
17
+ content: article.textContent.replace(/\s+/g, " ").trim()
18
+ };
19
+ }
20
+
21
+ const fallbackText = dom.window.document.body?.textContent ?? "";
22
+ return {
23
+ title: dom.window.document.title ?? "",
24
+ content: fallbackText.replace(/\s+/g, " ").trim()
25
+ };
26
+ }
backend/src/search/fetch.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { request } from "undici";
2
+
3
+ export async function fetchText(url: string, init?: { headers?: Record<string, string> }) {
4
+ const { body } = await request(url, {
5
+ method: "GET",
6
+ headers: {
7
+ "Accept": "text/html,application/xhtml+xml",
8
+ ...(init?.headers ?? {})
9
+ },
10
+ maxRedirections: 5
11
+ });
12
+ return await body.text();
13
+ }
backend/src/search/rank.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RawSearchResult } from "./duckduckgo.js";
2
+
3
+ export function rankResults(query: string, results: RawSearchResult[]) {
4
+ const terms = tokenize(query);
5
+ return results
6
+ .map((r) => ({
7
+ ...r,
8
+ score: scoreDoc(terms, `${r.title} ${r.snippet}`)
9
+ }))
10
+ .sort((a, b) => b.score - a.score);
11
+ }
12
+
13
+ function tokenize(text: string) {
14
+ return text
15
+ .toLowerCase()
16
+ .split(/\W+/)
17
+ .filter((t) => t.length > 2);
18
+ }
19
+
20
+ function scoreDoc(terms: string[], text: string) {
21
+ const tokens = tokenize(text);
22
+ if (!tokens.length) return 0;
23
+ const freq = new Map<string, number>();
24
+ tokens.forEach((t) => freq.set(t, (freq.get(t) ?? 0) + 1));
25
+ let score = 0;
26
+ terms.forEach((term) => {
27
+ const count = freq.get(term) ?? 0;
28
+ score += count;
29
+ });
30
+ return score / tokens.length;
31
+ }
backend/src/search/search.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SearchSummary, SearchResult } from "../types.js";
2
+ import { searchDuckDuckGo } from "./duckduckgo.js";
3
+ import { rankResults } from "./rank.js";
4
+ import { extractReadable } from "./extract.js";
5
+ import { getCached, setCached } from "./cache.js";
6
+
7
+ export async function searchWeb(query: string): Promise<SearchSummary> {
8
+ const cached = getCached(query);
9
+ if (cached) return cached;
10
+
11
+ const raw = await searchDuckDuckGo(query, 8);
12
+ const ranked = rankResults(query, raw);
13
+ const top = ranked.slice(0, 5);
14
+
15
+ const contentResults: Array<{
16
+ url: string;
17
+ title: string;
18
+ content: string;
19
+ }> = [];
20
+
21
+ await Promise.all(
22
+ top.map(async (item) => {
23
+ try {
24
+ const article = await extractReadable(item.url);
25
+ if (article.content) {
26
+ contentResults.push({
27
+ url: item.url,
28
+ title: article.title || item.title,
29
+ content: article.content
30
+ });
31
+ }
32
+ } catch {
33
+ // Ignore extraction errors per-source
34
+ }
35
+ })
36
+ );
37
+
38
+ const summary: SearchSummary = {
39
+ query,
40
+ results: top.map((r, idx) => ({
41
+ title: r.title,
42
+ url: r.url,
43
+ snippet: r.snippet,
44
+ score: r.score + (top.length - idx) * 0.01
45
+ })),
46
+ topContent: contentResults
47
+ };
48
+
49
+ setCached(query, summary);
50
+ return summary;
51
+ }
backend/src/types.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Role = "system" | "user" | "assistant" | "tool";
2
+
3
+ export type ChatMessage = {
4
+ id: string;
5
+ role: Role;
6
+ content: string;
7
+ createdAt: number;
8
+ };
9
+
10
+ export type ChatRequest = {
11
+ sessionId: string;
12
+ message: string;
13
+ mode?: "auto" | "search" | "no-search";
14
+ };
15
+
16
+ export type SearchResult = {
17
+ title: string;
18
+ url: string;
19
+ snippet: string;
20
+ score: number;
21
+ content: string;
22
+ };
23
+
24
+ export type SearchSummary = {
25
+ query: string;
26
+ results: Array<{
27
+ title: string;
28
+ url: string;
29
+ snippet: string;
30
+ score: number;
31
+ }>;
32
+ topContent: Array<{
33
+ url: string;
34
+ title: string;
35
+ content: string;
36
+ }>;
37
+ };
backend/src/utils/stream.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { request } from "undici";
2
+
3
+ const API_BASE = process.env.LLM_API_BASE ?? "https://api.openai.com";
4
+ const API_KEY = process.env.LLM_API_KEY ?? "";
5
+ const MODEL = process.env.LLM_MODEL ?? "gpt-4o-mini";
6
+
7
+ export async function streamChatCompletion(
8
+ messages: Array<{ role: string; content: string }>,
9
+ onToken: (token: string) => void
10
+ ) {
11
+ if (!API_KEY) {
12
+ throw new Error("Missing LLM_API_KEY environment variable.");
13
+ }
14
+
15
+ const payload = {
16
+ model: MODEL,
17
+ messages,
18
+ stream: true,
19
+ temperature: 0.3
20
+ };
21
+
22
+ const { body, statusCode } = await request(`${API_BASE}/v1/chat/completions`, {
23
+ method: "POST",
24
+ headers: {
25
+ "Authorization": `Bearer ${API_KEY}`,
26
+ "Content-Type": "application/json"
27
+ },
28
+ body: JSON.stringify(payload)
29
+ });
30
+
31
+ if (statusCode < 200 || statusCode >= 300) {
32
+ const errorText = await body.text();
33
+ throw new Error(`LLM request failed (${statusCode}): ${errorText.slice(0, 200)}`);
34
+ }
35
+
36
+ const reader = body.getReader();
37
+ const decoder = new TextDecoder();
38
+ let buffer = "";
39
+
40
+ while (true) {
41
+ const { value, done } = await reader.read();
42
+ if (done) break;
43
+ buffer += decoder.decode(value, { stream: true });
44
+
45
+ let boundary = buffer.indexOf("\n\n");
46
+ while (boundary !== -1) {
47
+ const chunk = buffer.slice(0, boundary).trim();
48
+ buffer = buffer.slice(boundary + 2);
49
+ if (chunk.startsWith("data:")) {
50
+ const data = chunk.replace(/^data:\s*/, "").trim();
51
+ if (data === "[DONE]") return;
52
+ try {
53
+ const parsed = JSON.parse(data);
54
+ const delta = parsed.choices?.[0]?.delta?.content;
55
+ if (delta) onToken(delta);
56
+ } catch {
57
+ // Ignore JSON parse errors in partial chunks
58
+ }
59
+ }
60
+ boundary = buffer.indexOf("\n\n");
61
+ }
62
+ }
63
+ }
backend/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }
docker-compose.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ backend:
3
+ build:
4
+ context: ./backend
5
+ environment:
6
+ - LLM_API_KEY=${LLM_API_KEY}
7
+ - LLM_MODEL=${LLM_MODEL:-gpt-4o-mini}
8
+ - LLM_API_BASE=${LLM_API_BASE:-https://api.openai.com}
9
+ - PORT=8080
10
+ ports:
11
+ - "8080:8080"
12
+ restart: unless-stopped
13
+
14
+ frontend:
15
+ build:
16
+ context: ./frontend
17
+ environment:
18
+ - VITE_API_BASE=http://localhost:8080
19
+ ports:
20
+ - "4173:4173"
21
+ depends_on:
22
+ - backend
23
+ restart: unless-stopped
frontend/Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend Dockerfile
2
+ FROM node:20-alpine
3
+
4
+ WORKDIR /app
5
+
6
+ COPY package.json package-lock.json* ./
7
+ RUN npm install
8
+
9
+ COPY vite.config.ts tsconfig.json index.html ./
10
+ COPY src ./src
11
+
12
+ RUN npm run build
13
+
14
+ EXPOSE 4173
15
+
16
+ CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "4173"]
frontend/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>NovaChat</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ai-chatbot-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^18.3.12",
17
+ "@types/react-dom": "^18.3.1",
18
+ "@vitejs/plugin-react": "^4.3.4",
19
+ "typescript": "^5.7.2",
20
+ "vite": "^6.0.4"
21
+ }
22
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ type Role = "user" | "assistant" | "system";
4
+
5
+ type ChatMessage = {
6
+ id: string;
7
+ role: Role;
8
+ content: string;
9
+ createdAt: number;
10
+ };
11
+
12
+ type StatusEvent = {
13
+ message: string;
14
+ };
15
+
16
+ const SHORTCUTS = [
17
+ { combo: "Ctrl+Enter", action: "Send" },
18
+ { combo: "Ctrl+K", action: "Focus input" },
19
+ { combo: "Esc", action: "Clear" }
20
+ ];
21
+
22
+ function useSessionId() {
23
+ const [sessionId] = useState(() => {
24
+ const cached = localStorage.getItem("novachat_session");
25
+ if (cached) return cached;
26
+ const created = crypto.randomUUID();
27
+ localStorage.setItem("novachat_session", created);
28
+ return created;
29
+ });
30
+ return sessionId;
31
+ }
32
+
33
+ export default function App() {
34
+ const sessionId = useSessionId();
35
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
36
+ const [input, setInput] = useState("");
37
+ const [status, setStatus] = useState("Idle");
38
+ const [isStreaming, setIsStreaming] = useState(false);
39
+ const [theme, setTheme] = useState(() => localStorage.getItem("novachat_theme") ?? "dark");
40
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
41
+ const bottomRef = useRef<HTMLDivElement | null>(null);
42
+ const streamRef = useRef<EventSource | null>(null);
43
+
44
+ useEffect(() => {
45
+ document.documentElement.dataset.theme = theme;
46
+ localStorage.setItem("novachat_theme", theme);
47
+ }, [theme]);
48
+
49
+ useEffect(() => {
50
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
51
+ }, [messages, status]);
52
+
53
+ useEffect(() => {
54
+ const handler = (event: KeyboardEvent) => {
55
+ if (event.ctrlKey && event.key.toLowerCase() === "k") {
56
+ event.preventDefault();
57
+ inputRef.current?.focus();
58
+ }
59
+ if (event.ctrlKey && event.key === "Enter") {
60
+ event.preventDefault();
61
+ handleSend();
62
+ }
63
+ if (event.key === "Escape") {
64
+ setInput("");
65
+ }
66
+ };
67
+ window.addEventListener("keydown", handler);
68
+ return () => window.removeEventListener("keydown", handler);
69
+ });
70
+
71
+ const groupedMessages = useMemo(() => {
72
+ const groups: ChatMessage[][] = [];
73
+ messages.forEach((msg) => {
74
+ const lastGroup = groups[groups.length - 1];
75
+ if (lastGroup && lastGroup[lastGroup.length - 1].role === msg.role) {
76
+ lastGroup.push(msg);
77
+ } else {
78
+ groups.push([msg]);
79
+ }
80
+ });
81
+ return groups;
82
+ }, [messages]);
83
+
84
+ const handleSend = () => {
85
+ const trimmed = input.trim();
86
+ if (!trimmed || isStreaming) return;
87
+
88
+ const userMessage: ChatMessage = {
89
+ id: `user_${Date.now()}`,
90
+ role: "user",
91
+ content: trimmed,
92
+ createdAt: Date.now()
93
+ };
94
+
95
+ setMessages((prev) => [...prev, userMessage]);
96
+ setInput("");
97
+ setIsStreaming(true);
98
+ setStatus("Connecting...");
99
+
100
+ streamRef.current?.close();
101
+ const url = new URL("/api/chat/stream", window.location.origin);
102
+ url.searchParams.set("sessionId", sessionId);
103
+ url.searchParams.set("message", trimmed);
104
+
105
+ const stream = new EventSource(url);
106
+ streamRef.current = stream;
107
+
108
+ let assistantId = `assistant_${Date.now()}`;
109
+
110
+ const appendAssistant = (token: string) => {
111
+ setMessages((prev) => {
112
+ const next = [...prev];
113
+ const existing = next.find((msg) => msg.id === assistantId);
114
+ if (existing) {
115
+ existing.content += token;
116
+ return [...next];
117
+ }
118
+ return [...next, { id: assistantId, role: "assistant", content: token, createdAt: Date.now() }];
119
+ });
120
+ };
121
+
122
+ stream.addEventListener("status", (event) => {
123
+ const data = JSON.parse((event as MessageEvent).data) as StatusEvent;
124
+ setStatus(data.message);
125
+ });
126
+
127
+ stream.addEventListener("delta", (event) => {
128
+ const data = JSON.parse((event as MessageEvent).data) as { token: string };
129
+ appendAssistant(data.token);
130
+ setStatus("Responding...");
131
+ });
132
+
133
+ stream.addEventListener("done", () => {
134
+ setIsStreaming(false);
135
+ setStatus("Idle");
136
+ stream.close();
137
+ });
138
+
139
+ stream.addEventListener("error", (event) => {
140
+ setIsStreaming(false);
141
+ setStatus("Connection error");
142
+ stream.close();
143
+ });
144
+ };
145
+
146
+ return (
147
+ <div className="app">
148
+ <header className="topbar">
149
+ <div className="brand">
150
+ <div className="brand-mark" />
151
+ <div>
152
+ <div className="brand-title">NovaChat</div>
153
+ <div className="brand-subtitle">Real-time intelligence engine</div>
154
+ </div>
155
+ </div>
156
+ <div className="topbar-actions">
157
+ <button className="ghost" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
158
+ {theme === "dark" ? "Light" : "Dark"} mode
159
+ </button>
160
+ <button className="ghost" onClick={() => setMessages([])}>
161
+ New session
162
+ </button>
163
+ </div>
164
+ </header>
165
+
166
+ <main className="layout">
167
+ <section className="chat">
168
+ <div className="chat-header">
169
+ <div>
170
+ <div className="chat-title">Control Room</div>
171
+ <div className="chat-subtitle">Ultra-low latency responses with live web search.</div>
172
+ </div>
173
+ <div className="status">
174
+ <span className={isStreaming ? "pulse" : "dot"} />
175
+ {status}
176
+ </div>
177
+ </div>
178
+
179
+ <div className="chat-body">
180
+ {groupedMessages.length === 0 ? (
181
+ <div className="empty">
182
+ <h2>Ask anything. Get fast, grounded answers.</h2>
183
+ <p>Type a request and NovaChat will decide when to pull live web sources.</p>
184
+ <div className="shortcut-grid">
185
+ {SHORTCUTS.map((shortcut) => (
186
+ <div key={shortcut.combo} className="shortcut-card">
187
+ <div className="shortcut-combo">{shortcut.combo}</div>
188
+ <div className="shortcut-action">{shortcut.action}</div>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ ) : (
194
+ groupedMessages.map((group) => (
195
+ <div key={group[0].id} className={`message-group ${group[0].role}`}>
196
+ <div className="message-meta">{group[0].role === "user" ? "You" : "Nova"}</div>
197
+ <div className="message-stack">
198
+ {group.map((msg) => (
199
+ <div key={msg.id} className="message-bubble">
200
+ {msg.content}
201
+ </div>
202
+ ))}
203
+ </div>
204
+ </div>
205
+ ))
206
+ )}
207
+ {isStreaming && (
208
+ <div className="typing">
209
+ <span />
210
+ <span />
211
+ <span />
212
+ </div>
213
+ )}
214
+ <div ref={bottomRef} />
215
+ </div>
216
+
217
+ <div className="chat-input">
218
+ <textarea
219
+ ref={inputRef}
220
+ placeholder="Ask NovaChat to research, summarize, or build something..."
221
+ value={input}
222
+ onChange={(event) => setInput(event.target.value)}
223
+ rows={2}
224
+ />
225
+ <div className="input-actions">
226
+ <div className="input-hint">Session: {sessionId.slice(0, 8)}</div>
227
+ <button className="primary" onClick={handleSend} disabled={isStreaming || !input.trim()}>
228
+ {isStreaming ? "Streaming..." : "Send"}
229
+ </button>
230
+ </div>
231
+ </div>
232
+ </section>
233
+
234
+ <aside className="sidebar">
235
+ <div className="card">
236
+ <h3>Live Ops</h3>
237
+ <p>Search cache and streaming are running in parallel for zero-lag delivery.</p>
238
+ <div className="metric">
239
+ <span>Latency target</span>
240
+ <strong>&lt; 800ms</strong>
241
+ </div>
242
+ <div className="metric">
243
+ <span>Streaming</span>
244
+ <strong>Token-by-token</strong>
245
+ </div>
246
+ </div>
247
+ <div className="card">
248
+ <h3>Search Stack</h3>
249
+ <p>Multi-source fetch, extraction, ranking, and summarization pipeline.</p>
250
+ <div className="badge-row">
251
+ <span>DuckDuckGo</span>
252
+ <span>Readability</span>
253
+ <span>LRU Cache</span>
254
+ </div>
255
+ </div>
256
+ <div className="card">
257
+ <h3>Controls</h3>
258
+ <p>NovaChat automatically decides when to search the web.</p>
259
+ <button className="ghost full" onClick={() => setStatus("Manual search mode coming soon")}>Enable manual search</button>
260
+ </div>
261
+ </aside>
262
+ </main>
263
+ </div>
264
+ );
265
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App";
4
+ import "./styles.css";
5
+
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
frontend/src/styles.css ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #0e0f13;
4
+ --bg-muted: #16181f;
5
+ --panel: #1d2029;
6
+ --text: #f6f4f0;
7
+ --text-muted: #b9becb;
8
+ --accent: #f68b1f;
9
+ --accent-soft: rgba(246, 139, 31, 0.18);
10
+ --border: rgba(255, 255, 255, 0.08);
11
+ --shadow: 0 30px 60px rgba(0, 0, 0, 0.35);
12
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
13
+ --sans: "Space Grotesk", "Segoe UI", sans-serif;
14
+ }
15
+
16
+ :root[data-theme="light"] {
17
+ --bg: #f6f3ee;
18
+ --bg-muted: #efe8de;
19
+ --panel: #ffffff;
20
+ --text: #1b1a19;
21
+ --text-muted: #5f5b56;
22
+ --accent: #db3a2f;
23
+ --accent-soft: rgba(219, 58, 47, 0.15);
24
+ --border: rgba(0, 0, 0, 0.1);
25
+ --shadow: 0 30px 60px rgba(12, 12, 12, 0.15);
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ font-family: var(--sans);
35
+ background: radial-gradient(circle at top left, rgba(246, 139, 31, 0.12), transparent 55%),
36
+ radial-gradient(circle at 20% 20%, rgba(115, 143, 255, 0.15), transparent 45%),
37
+ var(--bg);
38
+ color: var(--text);
39
+ min-height: 100vh;
40
+ }
41
+
42
+ .app {
43
+ display: flex;
44
+ flex-direction: column;
45
+ min-height: 100vh;
46
+ }
47
+
48
+ .topbar {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ padding: 24px 40px;
53
+ border-bottom: 1px solid var(--border);
54
+ backdrop-filter: blur(14px);
55
+ position: sticky;
56
+ top: 0;
57
+ z-index: 10;
58
+ }
59
+
60
+ .brand {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 16px;
64
+ }
65
+
66
+ .brand-mark {
67
+ width: 44px;
68
+ height: 44px;
69
+ border-radius: 14px;
70
+ background: linear-gradient(135deg, var(--accent), #f7c975);
71
+ box-shadow: 0 12px 30px rgba(246, 139, 31, 0.35);
72
+ }
73
+
74
+ .brand-title {
75
+ font-weight: 600;
76
+ letter-spacing: 0.04em;
77
+ }
78
+
79
+ .brand-subtitle {
80
+ font-size: 0.85rem;
81
+ color: var(--text-muted);
82
+ }
83
+
84
+ .topbar-actions {
85
+ display: flex;
86
+ gap: 12px;
87
+ }
88
+
89
+ .layout {
90
+ display: grid;
91
+ grid-template-columns: minmax(0, 1fr) 320px;
92
+ gap: 28px;
93
+ padding: 28px 40px 40px;
94
+ flex: 1;
95
+ }
96
+
97
+ .chat {
98
+ background: var(--panel);
99
+ border-radius: 28px;
100
+ box-shadow: var(--shadow);
101
+ display: flex;
102
+ flex-direction: column;
103
+ min-height: 70vh;
104
+ border: 1px solid var(--border);
105
+ }
106
+
107
+ .chat-header {
108
+ padding: 28px 28px 16px;
109
+ display: flex;
110
+ justify-content: space-between;
111
+ align-items: center;
112
+ border-bottom: 1px solid var(--border);
113
+ }
114
+
115
+ .chat-title {
116
+ font-size: 1.2rem;
117
+ font-weight: 600;
118
+ }
119
+
120
+ .chat-subtitle {
121
+ font-size: 0.9rem;
122
+ color: var(--text-muted);
123
+ }
124
+
125
+ .status {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 10px;
129
+ font-size: 0.85rem;
130
+ color: var(--text-muted);
131
+ }
132
+
133
+ .dot,
134
+ .pulse {
135
+ width: 10px;
136
+ height: 10px;
137
+ border-radius: 50%;
138
+ background: var(--accent);
139
+ }
140
+
141
+ .pulse {
142
+ position: relative;
143
+ }
144
+
145
+ .pulse::after {
146
+ content: "";
147
+ position: absolute;
148
+ inset: -4px;
149
+ border-radius: 50%;
150
+ border: 1px solid var(--accent);
151
+ animation: pulse 1.6s ease-in-out infinite;
152
+ }
153
+
154
+ @keyframes pulse {
155
+ 0% {
156
+ transform: scale(0.6);
157
+ opacity: 0.6;
158
+ }
159
+ 100% {
160
+ transform: scale(1.6);
161
+ opacity: 0;
162
+ }
163
+ }
164
+
165
+ .chat-body {
166
+ padding: 24px 28px;
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 22px;
170
+ overflow-y: auto;
171
+ flex: 1;
172
+ }
173
+
174
+ .empty {
175
+ padding: 40px 0 20px;
176
+ }
177
+
178
+ .empty h2 {
179
+ font-size: 1.8rem;
180
+ margin-bottom: 12px;
181
+ }
182
+
183
+ .empty p {
184
+ color: var(--text-muted);
185
+ max-width: 520px;
186
+ }
187
+
188
+ .shortcut-grid {
189
+ margin-top: 24px;
190
+ display: grid;
191
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
192
+ gap: 12px;
193
+ }
194
+
195
+ .shortcut-card {
196
+ background: var(--bg-muted);
197
+ padding: 14px;
198
+ border-radius: 16px;
199
+ border: 1px solid var(--border);
200
+ }
201
+
202
+ .shortcut-combo {
203
+ font-family: var(--mono);
204
+ font-size: 0.85rem;
205
+ color: var(--accent);
206
+ }
207
+
208
+ .shortcut-action {
209
+ font-size: 0.85rem;
210
+ color: var(--text-muted);
211
+ }
212
+
213
+ .message-group {
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 10px;
217
+ }
218
+
219
+ .message-group.user {
220
+ align-items: flex-end;
221
+ }
222
+
223
+ .message-group.assistant {
224
+ align-items: flex-start;
225
+ }
226
+
227
+ .message-meta {
228
+ font-size: 0.75rem;
229
+ text-transform: uppercase;
230
+ letter-spacing: 0.1em;
231
+ color: var(--text-muted);
232
+ }
233
+
234
+ .message-stack {
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 10px;
238
+ }
239
+
240
+ .message-bubble {
241
+ padding: 14px 18px;
242
+ border-radius: 18px;
243
+ max-width: 520px;
244
+ line-height: 1.5;
245
+ background: var(--bg-muted);
246
+ border: 1px solid var(--border);
247
+ }
248
+
249
+ .message-group.user .message-bubble {
250
+ background: linear-gradient(135deg, var(--accent), #f4b36c);
251
+ color: #1b1a19;
252
+ border: none;
253
+ }
254
+
255
+ .typing {
256
+ display: flex;
257
+ gap: 6px;
258
+ align-items: center;
259
+ padding-left: 8px;
260
+ }
261
+
262
+ .typing span {
263
+ width: 8px;
264
+ height: 8px;
265
+ background: var(--accent);
266
+ border-radius: 50%;
267
+ animation: bounce 1.2s infinite ease-in-out;
268
+ }
269
+
270
+ .typing span:nth-child(2) {
271
+ animation-delay: 0.2s;
272
+ }
273
+
274
+ .typing span:nth-child(3) {
275
+ animation-delay: 0.4s;
276
+ }
277
+
278
+ @keyframes bounce {
279
+ 0%, 80%, 100% {
280
+ transform: translateY(0);
281
+ }
282
+ 40% {
283
+ transform: translateY(-6px);
284
+ }
285
+ }
286
+
287
+ .chat-input {
288
+ border-top: 1px solid var(--border);
289
+ padding: 18px 24px 24px;
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 12px;
293
+ }
294
+
295
+ .chat-input textarea {
296
+ width: 100%;
297
+ border: 1px solid var(--border);
298
+ border-radius: 16px;
299
+ padding: 14px;
300
+ font-family: var(--sans);
301
+ font-size: 1rem;
302
+ background: var(--bg);
303
+ color: var(--text);
304
+ resize: none;
305
+ min-height: 74px;
306
+ }
307
+
308
+ .input-actions {
309
+ display: flex;
310
+ justify-content: space-between;
311
+ align-items: center;
312
+ }
313
+
314
+ .input-hint {
315
+ font-family: var(--mono);
316
+ font-size: 0.75rem;
317
+ color: var(--text-muted);
318
+ }
319
+
320
+ button {
321
+ font-family: var(--sans);
322
+ border: none;
323
+ border-radius: 12px;
324
+ padding: 10px 18px;
325
+ cursor: pointer;
326
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
327
+ }
328
+
329
+ button:disabled {
330
+ opacity: 0.6;
331
+ cursor: not-allowed;
332
+ }
333
+
334
+ button.primary {
335
+ background: var(--accent);
336
+ color: #121212;
337
+ font-weight: 600;
338
+ box-shadow: 0 14px 30px rgba(246, 139, 31, 0.3);
339
+ }
340
+
341
+ button.primary:hover:not(:disabled) {
342
+ transform: translateY(-1px);
343
+ }
344
+
345
+ button.ghost {
346
+ background: transparent;
347
+ border: 1px solid var(--border);
348
+ color: var(--text);
349
+ }
350
+
351
+ button.ghost.full {
352
+ width: 100%;
353
+ }
354
+
355
+ .sidebar {
356
+ display: flex;
357
+ flex-direction: column;
358
+ gap: 18px;
359
+ }
360
+
361
+ .card {
362
+ background: var(--panel);
363
+ border-radius: 24px;
364
+ padding: 20px;
365
+ border: 1px solid var(--border);
366
+ box-shadow: var(--shadow);
367
+ }
368
+
369
+ .card h3 {
370
+ margin-top: 0;
371
+ }
372
+
373
+ .card p {
374
+ color: var(--text-muted);
375
+ }
376
+
377
+ .metric {
378
+ display: flex;
379
+ justify-content: space-between;
380
+ margin-top: 12px;
381
+ font-size: 0.9rem;
382
+ }
383
+
384
+ .badge-row {
385
+ display: flex;
386
+ flex-wrap: wrap;
387
+ gap: 8px;
388
+ }
389
+
390
+ .badge-row span {
391
+ font-size: 0.75rem;
392
+ padding: 4px 10px;
393
+ border-radius: 999px;
394
+ border: 1px solid var(--border);
395
+ background: var(--bg-muted);
396
+ }
397
+
398
+ @media (max-width: 1024px) {
399
+ .layout {
400
+ grid-template-columns: 1fr;
401
+ }
402
+
403
+ .sidebar {
404
+ order: -1;
405
+ }
406
+ }
407
+
408
+ @media (max-width: 720px) {
409
+ .topbar {
410
+ flex-direction: column;
411
+ align-items: flex-start;
412
+ gap: 12px;
413
+ }
414
+
415
+ .layout {
416
+ padding: 20px;
417
+ }
418
+
419
+ .chat {
420
+ border-radius: 20px;
421
+ }
422
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "Bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true
15
+ },
16
+ "include": ["src"]
17
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ "/api": "http://localhost:8080"
10
+ }
11
+ },
12
+ preview: {
13
+ host: "0.0.0.0",
14
+ port: 4173
15
+ }
16
+ });
game/index.html ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Star Courier</title>
7
+ <link rel="stylesheet" href="styles.css" />
8
+ </head>
9
+ <body>
10
+ <div class="app">
11
+ <header>
12
+ <div class="brand">
13
+ <span class="logo"></span>
14
+ <div>
15
+ <h1>Star Courier</h1>
16
+ <p>Thread the asteroid field, grab energy cells, and survive the surge.</p>
17
+ </div>
18
+ </div>
19
+ <div class="hud">
20
+ <div><span>Score</span><strong id="score">0</strong></div>
21
+ <div><span>Shield</span><strong id="shield">100</strong></div>
22
+ <div><span>Wave</span><strong id="wave">1</strong></div>
23
+ </div>
24
+ </header>
25
+
26
+ <main>
27
+ <div class="panel">
28
+ <canvas id="game" width="960" height="560"></canvas>
29
+ <div class="overlay" id="overlay">
30
+ <div class="card">
31
+ <h2 id="title">Ready?</h2>
32
+ <p id="subtitle">Use WASD or arrow keys to fly. Space = boost. Collect orbs, dodge rocks.</p>
33
+ <input id="pilot" placeholder="Pilot name" maxlength="14" />
34
+ <div class="row">
35
+ <button id="start">Start Run</button>
36
+ <button id="spectate" class="ghost">Spectate</button>
37
+ </div>
38
+ <small id="ws-status">Offline</small>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ <aside>
43
+ <h3>Squad Roster</h3>
44
+ <div class="roster" id="roster"></div>
45
+ <h3>Controls</h3>
46
+ <ul>
47
+ <li><strong>Move</strong> WASD / Arrow keys</li>
48
+ <li><strong>Boost</strong> Space (drains shield)</li>
49
+ <li><strong>Pause</strong> P</li>
50
+ </ul>
51
+ <h3>Objective</h3>
52
+ <p>Survive as long as you can. Every 30 seconds, the field intensifies.</p>
53
+ <h3>Tips</h3>
54
+ <p>Boost through narrow gaps. Grab energy orbs to restore shield.</p>
55
+ </aside>
56
+ </main>
57
+ <footer>
58
+ Built for speed. No fluff.
59
+ </footer>
60
+ </div>
61
+ <script src="main.js"></script>
62
+ </body>
63
+ </html>
game/main.js ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const canvas = document.getElementById("game");
2
+ const ctx = canvas.getContext("2d");
3
+
4
+ const scoreEl = document.getElementById("score");
5
+ const shieldEl = document.getElementById("shield");
6
+ const waveEl = document.getElementById("wave");
7
+ const overlay = document.getElementById("overlay");
8
+ const startBtn = document.getElementById("start");
9
+ const spectateBtn = document.getElementById("spectate");
10
+ const titleEl = document.getElementById("title");
11
+ const subtitleEl = document.getElementById("subtitle");
12
+ const pilotInput = document.getElementById("pilot");
13
+ const rosterEl = document.getElementById("roster");
14
+ const wsStatus = document.getElementById("ws-status");
15
+
16
+ const state = {
17
+ running: false,
18
+ paused: false,
19
+ time: 0,
20
+ score: 0,
21
+ shield: 100,
22
+ wave: 1,
23
+ boost: false,
24
+ spectate: false,
25
+ player: {
26
+ x: canvas.width * 0.2,
27
+ y: canvas.height * 0.5,
28
+ vx: 0,
29
+ vy: 0,
30
+ radius: 16,
31
+ color: "#4ed1ff",
32
+ name: "Pilot"
33
+ },
34
+ asteroids: [],
35
+ orbs: [],
36
+ stars: [],
37
+ remotePlayers: new Map(),
38
+ socket: null,
39
+ playerId: null,
40
+ lastSend: 0
41
+ };
42
+
43
+ const keys = new Set();
44
+
45
+ const connectSocket = () => {
46
+ const url = `ws://${window.location.host}`;
47
+ const socket = new WebSocket(url);
48
+ state.socket = socket;
49
+
50
+ socket.addEventListener("open", () => {
51
+ wsStatus.textContent = "Online";
52
+ socket.send(
53
+ JSON.stringify({
54
+ type: "join",
55
+ name: pilotInput.value.trim() || "Pilot"
56
+ })
57
+ );
58
+ });
59
+
60
+ socket.addEventListener("message", (event) => {
61
+ let message;
62
+ try {
63
+ message = JSON.parse(event.data);
64
+ } catch {
65
+ return;
66
+ }
67
+
68
+ if (message.type === "welcome") {
69
+ state.playerId = message.id;
70
+ state.player.color = message.color;
71
+ return;
72
+ }
73
+
74
+ if (message.type === "roster") {
75
+ state.remotePlayers.clear();
76
+ message.players.forEach((player) => {
77
+ if (player.id !== state.playerId) {
78
+ state.remotePlayers.set(player.id, player);
79
+ }
80
+ });
81
+ updateRoster(message.players);
82
+ }
83
+ });
84
+
85
+ socket.addEventListener("close", () => {
86
+ wsStatus.textContent = "Offline";
87
+ state.remotePlayers.clear();
88
+ });
89
+ };
90
+
91
+ const updateRoster = (players) => {
92
+ rosterEl.innerHTML = "";
93
+ players
94
+ .sort((a, b) => b.score - a.score)
95
+ .forEach((player) => {
96
+ const row = document.createElement("div");
97
+ row.className = "roster-item";
98
+ row.innerHTML = `
99
+ <div class="roster-left">
100
+ <span class="dot" style="background:${player.color}"></span>
101
+ <span>${player.name}</span>
102
+ </div>
103
+ <strong>${Math.floor(player.score)}</strong>
104
+ `;
105
+ rosterEl.appendChild(row);
106
+ });
107
+ };
108
+
109
+ const spawnStarField = () => {
110
+ state.stars = Array.from({ length: 120 }, () => ({
111
+ x: Math.random() * canvas.width,
112
+ y: Math.random() * canvas.height,
113
+ z: Math.random() * 1.8 + 0.2
114
+ }));
115
+ };
116
+
117
+ const resetGame = () => {
118
+ state.time = 0;
119
+ state.score = 0;
120
+ state.shield = 100;
121
+ state.wave = 1;
122
+ state.player.x = canvas.width * 0.2;
123
+ state.player.y = canvas.height * 0.5;
124
+ state.player.vx = 0;
125
+ state.player.vy = 0;
126
+ state.asteroids = [];
127
+ state.orbs = [];
128
+ spawnStarField();
129
+ updateHud();
130
+ };
131
+
132
+ const updateHud = () => {
133
+ scoreEl.textContent = Math.floor(state.score).toString();
134
+ shieldEl.textContent = Math.max(0, Math.floor(state.shield)).toString();
135
+ waveEl.textContent = state.wave.toString();
136
+ };
137
+
138
+ const spawnAsteroid = () => {
139
+ const size = 12 + Math.random() * 26;
140
+ state.asteroids.push({
141
+ x: canvas.width + size + Math.random() * 100,
142
+ y: Math.random() * canvas.height,
143
+ vx: -(2.8 + Math.random() * (1.4 + state.wave * 0.35)),
144
+ vy: (Math.random() - 0.5) * 1.5,
145
+ radius: size,
146
+ spin: Math.random() * 2
147
+ });
148
+ };
149
+
150
+ const spawnOrb = () => {
151
+ state.orbs.push({
152
+ x: canvas.width + 40 + Math.random() * 300,
153
+ y: 80 + Math.random() * (canvas.height - 160),
154
+ vx: -(2.2 + Math.random() * 1.2),
155
+ radius: 10 + Math.random() * 4
156
+ });
157
+ };
158
+
159
+ const applyInput = () => {
160
+ const accel = state.boost ? 0.5 : 0.28;
161
+ if (keys.has("ArrowUp") || keys.has("KeyW")) state.player.vy -= accel;
162
+ if (keys.has("ArrowDown") || keys.has("KeyS")) state.player.vy += accel;
163
+ if (keys.has("ArrowLeft") || keys.has("KeyA")) state.player.vx -= accel * 0.75;
164
+ if (keys.has("ArrowRight") || keys.has("KeyD")) state.player.vx += accel * 0.75;
165
+ };
166
+
167
+ const updatePlayer = () => {
168
+ state.player.vx *= 0.94;
169
+ state.player.vy *= 0.94;
170
+ state.player.x += state.player.vx;
171
+ state.player.y += state.player.vy;
172
+
173
+ if (state.player.x < 20) state.player.x = 20;
174
+ if (state.player.x > canvas.width - 20) state.player.x = canvas.width - 20;
175
+ if (state.player.y < 20) state.player.y = 20;
176
+ if (state.player.y > canvas.height - 20) state.player.y = canvas.height - 20;
177
+
178
+ if (state.boost) {
179
+ state.shield -= 0.2;
180
+ }
181
+ };
182
+
183
+ const updateEntities = () => {
184
+ state.asteroids.forEach((rock) => {
185
+ rock.x += rock.vx;
186
+ rock.y += rock.vy;
187
+ });
188
+ state.orbs.forEach((orb) => {
189
+ orb.x += orb.vx;
190
+ });
191
+
192
+ state.asteroids = state.asteroids.filter((rock) => rock.x > -80);
193
+ state.orbs = state.orbs.filter((orb) => orb.x > -40);
194
+ };
195
+
196
+ const handleCollisions = () => {
197
+ state.asteroids.forEach((rock) => {
198
+ const dx = rock.x - state.player.x;
199
+ const dy = rock.y - state.player.y;
200
+ const dist = Math.hypot(dx, dy);
201
+ if (dist < rock.radius + state.player.radius) {
202
+ state.shield -= 18 + Math.random() * 8;
203
+ rock.x += 120;
204
+ rock.y = Math.random() * canvas.height;
205
+ }
206
+ });
207
+
208
+ state.orbs = state.orbs.filter((orb) => {
209
+ const dx = orb.x - state.player.x;
210
+ const dy = orb.y - state.player.y;
211
+ const dist = Math.hypot(dx, dy);
212
+ if (dist < orb.radius + state.player.radius) {
213
+ state.shield = Math.min(100, state.shield + 20);
214
+ state.score += 120;
215
+ return false;
216
+ }
217
+ return true;
218
+ });
219
+ };
220
+
221
+ const updateWave = () => {
222
+ const nextWave = Math.floor(state.time / 30000) + 1;
223
+ if (nextWave !== state.wave) {
224
+ state.wave = nextWave;
225
+ }
226
+ };
227
+
228
+ const updateStars = () => {
229
+ state.stars.forEach((star) => {
230
+ star.x -= 0.5 + star.z * 1.2 + state.wave * 0.1;
231
+ if (star.x < -2) {
232
+ star.x = canvas.width + Math.random() * 60;
233
+ star.y = Math.random() * canvas.height;
234
+ }
235
+ });
236
+ };
237
+
238
+ const renderStars = () => {
239
+ ctx.fillStyle = "#06080f";
240
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
241
+
242
+ state.stars.forEach((star) => {
243
+ ctx.fillStyle = `rgba(180, 210, 255, ${0.4 + star.z * 0.3})`;
244
+ ctx.fillRect(star.x, star.y, 2 * star.z, 2 * star.z);
245
+ });
246
+ };
247
+
248
+ const renderPlayer = () => {
249
+ ctx.save();
250
+ ctx.translate(state.player.x, state.player.y);
251
+ ctx.rotate(state.player.vy * 0.02);
252
+ ctx.fillStyle = state.player.color;
253
+ ctx.beginPath();
254
+ ctx.moveTo(18, 0);
255
+ ctx.lineTo(-12, 10);
256
+ ctx.lineTo(-8, 0);
257
+ ctx.lineTo(-12, -10);
258
+ ctx.closePath();
259
+ ctx.fill();
260
+
261
+ ctx.strokeStyle = "rgba(255,255,255,0.6)";
262
+ ctx.lineWidth = 2;
263
+ ctx.beginPath();
264
+ ctx.arc(2, 0, 12, 0, Math.PI * 2);
265
+ ctx.stroke();
266
+ ctx.restore();
267
+ };
268
+
269
+ const renderRemotePlayers = () => {
270
+ state.remotePlayers.forEach((player) => {
271
+ ctx.save();
272
+ ctx.translate(player.x, player.y);
273
+ ctx.fillStyle = player.color;
274
+ ctx.beginPath();
275
+ ctx.arc(0, 0, 12, 0, Math.PI * 2);
276
+ ctx.fill();
277
+ ctx.restore();
278
+
279
+ ctx.fillStyle = "rgba(255,255,255,0.7)";
280
+ ctx.font = "12px Segoe UI";
281
+ ctx.fillText(player.name, player.x + 14, player.y - 14);
282
+ });
283
+ };
284
+
285
+ const renderAsteroids = () => {
286
+ ctx.strokeStyle = "rgba(255, 179, 71, 0.8)";
287
+ ctx.fillStyle = "rgba(255, 179, 71, 0.15)";
288
+ state.asteroids.forEach((rock) => {
289
+ ctx.beginPath();
290
+ ctx.arc(rock.x, rock.y, rock.radius, 0, Math.PI * 2);
291
+ ctx.fill();
292
+ ctx.stroke();
293
+ });
294
+ };
295
+
296
+ const renderOrbs = () => {
297
+ ctx.fillStyle = "rgba(78, 255, 179, 0.9)";
298
+ state.orbs.forEach((orb) => {
299
+ ctx.beginPath();
300
+ ctx.arc(orb.x, orb.y, orb.radius, 0, Math.PI * 2);
301
+ ctx.fill();
302
+ });
303
+ };
304
+
305
+ const renderBoostTrail = () => {
306
+ if (!state.boost) return;
307
+ ctx.strokeStyle = "rgba(78, 209, 255, 0.45)";
308
+ ctx.lineWidth = 2;
309
+ ctx.beginPath();
310
+ ctx.moveTo(state.player.x - 10, state.player.y);
311
+ ctx.lineTo(state.player.x - 40, state.player.y + Math.random() * 12 - 6);
312
+ ctx.stroke();
313
+ };
314
+
315
+ const renderShield = () => {
316
+ ctx.strokeStyle = `rgba(78, 209, 255, ${0.2 + state.shield / 220})`;
317
+ ctx.lineWidth = 3;
318
+ ctx.beginPath();
319
+ ctx.arc(state.player.x, state.player.y, state.player.radius + 6, 0, Math.PI * 2);
320
+ ctx.stroke();
321
+ };
322
+
323
+ const render = () => {
324
+ renderStars();
325
+ renderOrbs();
326
+ renderAsteroids();
327
+ renderBoostTrail();
328
+ renderShield();
329
+ renderPlayer();
330
+ renderRemotePlayers();
331
+ };
332
+
333
+ const sendState = (time) => {
334
+ if (!state.socket || state.socket.readyState !== 1) return;
335
+ if (time - state.lastSend < 60) return;
336
+ state.lastSend = time;
337
+
338
+ state.socket.send(
339
+ JSON.stringify({
340
+ type: "state",
341
+ x: state.player.x,
342
+ y: state.player.y,
343
+ score: state.score,
344
+ shield: state.shield,
345
+ wave: state.wave
346
+ })
347
+ );
348
+ };
349
+
350
+ const update = (delta, time) => {
351
+ if (!state.running || state.paused) return;
352
+
353
+ state.time += delta;
354
+ state.score += delta * 0.02 + state.wave * 0.15;
355
+ state.boost = !state.spectate && keys.has("Space") && state.shield > 5;
356
+
357
+ if (!state.spectate) {
358
+ applyInput();
359
+ updatePlayer();
360
+ handleCollisions();
361
+ }
362
+
363
+ updateEntities();
364
+ updateStars();
365
+ updateWave();
366
+
367
+ if (Math.random() < 0.03 + state.wave * 0.004) spawnAsteroid();
368
+ if (Math.random() < 0.008) spawnOrb();
369
+
370
+ if (state.shield <= 0 && !state.spectate) {
371
+ endGame();
372
+ }
373
+
374
+ updateHud();
375
+ sendState(time);
376
+ };
377
+
378
+ const endGame = () => {
379
+ state.running = false;
380
+ overlay.style.display = "grid";
381
+ titleEl.textContent = "Run Ended";
382
+ subtitleEl.textContent = `Final score: ${Math.floor(state.score)}. Hit Start to try again.`;
383
+ };
384
+
385
+ let lastFrame = performance.now();
386
+ const loop = (time) => {
387
+ const delta = time - lastFrame;
388
+ lastFrame = time;
389
+ update(delta, time);
390
+ render();
391
+ requestAnimationFrame(loop);
392
+ };
393
+
394
+ const beginRun = (spectate) => {
395
+ state.spectate = spectate;
396
+ state.player.name = pilotInput.value.trim() || "Pilot";
397
+ resetGame();
398
+ state.running = true;
399
+ state.paused = false;
400
+ overlay.style.display = "none";
401
+ };
402
+
403
+ startBtn.addEventListener("click", () => beginRun(false));
404
+ spectateBtn.addEventListener("click", () => beginRun(true));
405
+
406
+ window.addEventListener("keydown", (event) => {
407
+ if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Space"].includes(event.code)) {
408
+ event.preventDefault();
409
+ }
410
+ if (event.code === "KeyP") {
411
+ state.paused = !state.paused;
412
+ }
413
+ keys.add(event.code);
414
+ });
415
+
416
+ window.addEventListener("keyup", (event) => {
417
+ keys.delete(event.code);
418
+ });
419
+
420
+ spawnStarField();
421
+ connectSocket();
422
+ requestAnimationFrame(loop);
game/package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "star-courier-multiplayer",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "ws": "^8.17.1"
11
+ }
12
+ }
game/server.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { WebSocketServer } from "ws";
5
+
6
+ const PORT = 8787;
7
+ const ROOT = path.resolve(process.cwd());
8
+
9
+ const server = http.createServer((req, res) => {
10
+ const url = req.url === "/" ? "/index.html" : req.url;
11
+ const filePath = path.join(ROOT, url);
12
+
13
+ fs.readFile(filePath, (err, data) => {
14
+ if (err) {
15
+ res.writeHead(404, { "Content-Type": "text/plain" });
16
+ res.end("Not found");
17
+ return;
18
+ }
19
+
20
+ const ext = path.extname(filePath);
21
+ const typeMap = {
22
+ ".html": "text/html",
23
+ ".css": "text/css",
24
+ ".js": "text/javascript"
25
+ };
26
+ res.writeHead(200, { "Content-Type": typeMap[ext] ?? "text/plain" });
27
+ res.end(data);
28
+ });
29
+ });
30
+
31
+ const wss = new WebSocketServer({ server });
32
+
33
+ const palette = ["#4ed1ff", "#ffb347", "#7cff6b", "#ff6be5", "#9f8bff", "#ffd86b"];
34
+ let paletteIndex = 0;
35
+
36
+ const players = new Map();
37
+
38
+ const broadcast = (payload) => {
39
+ const message = JSON.stringify(payload);
40
+ for (const client of wss.clients) {
41
+ if (client.readyState === 1) {
42
+ client.send(message);
43
+ }
44
+ }
45
+ };
46
+
47
+ wss.on("connection", (socket) => {
48
+ let playerId = null;
49
+
50
+ socket.on("message", (data) => {
51
+ let message;
52
+ try {
53
+ message = JSON.parse(data.toString());
54
+ } catch {
55
+ return;
56
+ }
57
+
58
+ if (message.type === "join") {
59
+ playerId = `p_${Math.random().toString(36).slice(2, 9)}`;
60
+ const color = palette[paletteIndex % palette.length];
61
+ paletteIndex += 1;
62
+ const name = String(message.name || "Pilot").slice(0, 14);
63
+ players.set(playerId, {
64
+ id: playerId,
65
+ name,
66
+ color,
67
+ x: 0,
68
+ y: 0,
69
+ score: 0,
70
+ shield: 100,
71
+ wave: 1,
72
+ lastSeen: Date.now()
73
+ });
74
+ socket.send(JSON.stringify({ type: "welcome", id: playerId, color }));
75
+ return;
76
+ }
77
+
78
+ if (message.type === "state" && playerId && players.has(playerId)) {
79
+ const player = players.get(playerId);
80
+ player.x = message.x;
81
+ player.y = message.y;
82
+ player.score = message.score;
83
+ player.shield = message.shield;
84
+ player.wave = message.wave;
85
+ player.lastSeen = Date.now();
86
+ }
87
+ });
88
+
89
+ socket.on("close", () => {
90
+ if (playerId) {
91
+ players.delete(playerId);
92
+ }
93
+ });
94
+ });
95
+
96
+ setInterval(() => {
97
+ const now = Date.now();
98
+ for (const [id, player] of players.entries()) {
99
+ if (now - player.lastSeen > 8000) {
100
+ players.delete(id);
101
+ }
102
+ }
103
+ broadcast({ type: "roster", players: Array.from(players.values()) });
104
+ }, 120);
105
+
106
+ server.listen(PORT, () => {
107
+ console.log(`Star Courier running on http://localhost:${PORT}`);
108
+ });
game/styles.css ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0a0d14;
3
+ --panel: #121826;
4
+ --accent: #4ed1ff;
5
+ --accent-2: #ffb347;
6
+ --text: #f5f7ff;
7
+ --muted: #9ba7c7;
8
+ }
9
+
10
+ * {
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ margin: 0;
16
+ font-family: "Segoe UI", system-ui, sans-serif;
17
+ background: radial-gradient(circle at 20% 20%, rgba(78, 209, 255, 0.12), transparent 50%),
18
+ radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.1), transparent 45%),
19
+ var(--bg);
20
+ color: var(--text);
21
+ }
22
+
23
+ .app {
24
+ min-height: 100vh;
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: 20px;
28
+ padding: 24px 28px 32px;
29
+ }
30
+
31
+ header {
32
+ display: flex;
33
+ justify-content: space-between;
34
+ align-items: center;
35
+ gap: 24px;
36
+ }
37
+
38
+ .brand {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 16px;
42
+ }
43
+
44
+ .logo {
45
+ width: 48px;
46
+ height: 48px;
47
+ border-radius: 14px;
48
+ background: linear-gradient(135deg, var(--accent), #6af1ff);
49
+ box-shadow: 0 12px 30px rgba(78, 209, 255, 0.35);
50
+ }
51
+
52
+ .brand h1 {
53
+ margin: 0;
54
+ font-size: 1.5rem;
55
+ }
56
+
57
+ .brand p {
58
+ margin: 4px 0 0;
59
+ color: var(--muted);
60
+ }
61
+
62
+ .hud {
63
+ display: grid;
64
+ grid-template-columns: repeat(3, minmax(90px, 1fr));
65
+ gap: 16px;
66
+ }
67
+
68
+ .hud div {
69
+ background: var(--panel);
70
+ padding: 10px 14px;
71
+ border-radius: 12px;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 6px;
75
+ border: 1px solid rgba(255, 255, 255, 0.08);
76
+ }
77
+
78
+ .hud span {
79
+ font-size: 0.7rem;
80
+ text-transform: uppercase;
81
+ color: var(--muted);
82
+ letter-spacing: 0.08em;
83
+ }
84
+
85
+ .hud strong {
86
+ font-size: 1.1rem;
87
+ }
88
+
89
+ main {
90
+ display: grid;
91
+ grid-template-columns: 1fr 280px;
92
+ gap: 24px;
93
+ flex: 1;
94
+ }
95
+
96
+ .panel {
97
+ position: relative;
98
+ background: var(--panel);
99
+ border-radius: 24px;
100
+ padding: 16px;
101
+ border: 1px solid rgba(255, 255, 255, 0.08);
102
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
103
+ }
104
+
105
+ canvas {
106
+ width: 100%;
107
+ height: auto;
108
+ border-radius: 16px;
109
+ background: #06080f;
110
+ display: block;
111
+ }
112
+
113
+ .overlay {
114
+ position: absolute;
115
+ inset: 16px;
116
+ display: grid;
117
+ place-items: center;
118
+ background: rgba(6, 8, 15, 0.75);
119
+ border-radius: 16px;
120
+ }
121
+
122
+ .card {
123
+ background: rgba(18, 24, 38, 0.95);
124
+ padding: 24px 32px;
125
+ border-radius: 18px;
126
+ text-align: center;
127
+ border: 1px solid rgba(255, 255, 255, 0.08);
128
+ display: grid;
129
+ gap: 12px;
130
+ }
131
+
132
+ .card h2 {
133
+ margin: 0;
134
+ font-size: 1.6rem;
135
+ }
136
+
137
+ .card p {
138
+ margin: 0;
139
+ color: var(--muted);
140
+ }
141
+
142
+ .card input {
143
+ border: 1px solid rgba(255, 255, 255, 0.1);
144
+ border-radius: 10px;
145
+ background: rgba(9, 12, 20, 0.8);
146
+ padding: 10px 12px;
147
+ color: var(--text);
148
+ }
149
+
150
+ .card small {
151
+ color: var(--muted);
152
+ }
153
+
154
+ .row {
155
+ display: flex;
156
+ gap: 10px;
157
+ justify-content: center;
158
+ }
159
+
160
+ button {
161
+ border: none;
162
+ background: var(--accent);
163
+ color: #041018;
164
+ font-weight: 600;
165
+ padding: 10px 18px;
166
+ border-radius: 10px;
167
+ cursor: pointer;
168
+ }
169
+
170
+ button.ghost {
171
+ background: transparent;
172
+ border: 1px solid rgba(255, 255, 255, 0.12);
173
+ color: var(--text);
174
+ }
175
+
176
+ aside {
177
+ background: var(--panel);
178
+ padding: 18px 20px;
179
+ border-radius: 20px;
180
+ border: 1px solid rgba(255, 255, 255, 0.08);
181
+ color: var(--muted);
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: 16px;
185
+ }
186
+
187
+ aside h3 {
188
+ color: var(--text);
189
+ margin: 0;
190
+ }
191
+
192
+ aside ul {
193
+ list-style: none;
194
+ padding: 0;
195
+ margin: 0;
196
+ display: flex;
197
+ flex-direction: column;
198
+ gap: 8px;
199
+ }
200
+
201
+ aside li strong {
202
+ color: var(--text);
203
+ margin-right: 6px;
204
+ }
205
+
206
+ .roster {
207
+ display: grid;
208
+ gap: 8px;
209
+ }
210
+
211
+ .roster-item {
212
+ display: flex;
213
+ justify-content: space-between;
214
+ align-items: center;
215
+ padding: 8px 10px;
216
+ border-radius: 10px;
217
+ background: rgba(255, 255, 255, 0.05);
218
+ border: 1px solid rgba(255, 255, 255, 0.08);
219
+ }
220
+
221
+ .roster-left {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 8px;
225
+ }
226
+
227
+ .dot {
228
+ width: 10px;
229
+ height: 10px;
230
+ border-radius: 50%;
231
+ }
232
+
233
+ footer {
234
+ color: var(--muted);
235
+ font-size: 0.85rem;
236
+ }
237
+
238
+ @media (max-width: 960px) {
239
+ main {
240
+ grid-template-columns: 1fr;
241
+ }
242
+ }
upload_and_test.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from huggingface_hub import login, upload_folder
3
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
4
+
5
+ # -------------------------------
6
+ # 1️⃣ Login safely
7
+ # -------------------------------
8
+ # Use environment variable for your token
9
+ token = os.environ.get("HF_TOKEN")
10
+ if not token:
11
+ token = input("Enter your Hugging Face token (won't be saved): ")
12
+ login(token=token)
13
+
14
+ # -------------------------------
15
+ # 2️⃣ Upload the model folder
16
+ # -------------------------------
17
+ # This folder should contain pytorch_model.bin, config.json, tokenizer.json
18
+ upload_folder(
19
+ folder_path=".", # current folder
20
+ repo_id="picklefried706/NEON", # your HF repo
21
+ repo_type="model"
22
+ )
23
+ print("✅ Upload complete!")
24
+
25
+ # -------------------------------
26
+ # 3️⃣ Test your model
27
+ # -------------------------------
28
+ model_name = "picklefried706/NEON"
29
+
30
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
31
+ model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
32
+
33
+ chat = pipeline("text-generation", model=model, tokenizer=tokenizer)
34
+
35
+ prompt = "User: Hello! How are you?\nAssistant:"
36
+ response = chat(prompt, max_new_tokens=150)
37
+ print("\n--- Model Response ---")
38
+ print(response[0]['generated_text'])