Upload folder using huggingface_hub
Browse files- .vscode/launch.json +15 -0
- README.md +58 -23
- backend/Dockerfile +17 -0
- backend/package.json +24 -0
- backend/src/ai/context.ts +20 -0
- backend/src/ai/engine.ts +89 -0
- backend/src/ai/memory.ts +34 -0
- backend/src/ai/router.ts +47 -0
- backend/src/index.ts +93 -0
- backend/src/search/cache.ts +15 -0
- backend/src/search/duckduckgo.ts +34 -0
- backend/src/search/extract.ts +26 -0
- backend/src/search/fetch.ts +13 -0
- backend/src/search/rank.ts +31 -0
- backend/src/search/search.ts +51 -0
- backend/src/types.ts +37 -0
- backend/src/utils/stream.ts +63 -0
- backend/tsconfig.json +14 -0
- docker-compose.yml +23 -0
- frontend/Dockerfile +16 -0
- frontend/index.html +15 -0
- frontend/package.json +22 -0
- frontend/src/App.tsx +265 -0
- frontend/src/main.tsx +10 -0
- frontend/src/styles.css +422 -0
- frontend/tsconfig.json +17 -0
- frontend/vite.config.ts +16 -0
- game/index.html +63 -0
- game/main.js +422 -0
- game/package.json +12 -0
- game/server.js +108 -0
- game/styles.css +242 -0
- upload_and_test.py +38 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
-
|
| 16 |
-
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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>< 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'])
|