Skip to content

Commit 0d9fd94

Browse files
author
Khôi (Nexus upgrade)
committed
feat: Nexus UI/UX overhaul + backend streaming + full test suite
Frontend: - Complete UI redesign with amber/cognac Nexus design system (Sora + JetBrains Mono) - Sidebar with collapse, conversation history, new chat button - Provider switcher dropdown (Ollama / llama.cpp) - Streaming chat with typing animation and markdown rendering - Search panel with rich result cards - Notes panel with auto-save, word count, monospace editor - Status bar with live backend health polling - Suggestion chips on empty state - Add vite.config.js (was missing), fix index.html with proper DOCTYPE + head Backend: - chat route: SSE streaming support (stream: true) - inference service: native streaming for Ollama (NDJSON) and llama.cpp (OpenAI SSE) - chat model: add stream field and optional system prompt - health route: return uptime_seconds and version - search route: add GET /api/search?q=... endpoint - workspace route: add DELETE /api/workspace/notes/{name} - workspace service: add delete_note() - factory: add GZipMiddleware and global exception handler Tests: - Rewrite E2E tests to match new Nexus UI selectors (8 test cases) - Add test_backend.py: delete, round trip, API name mismatch, search GET 422 - Add pytest.ini Docker: - docker-compose.yml: was empty, now complete with all services + volumes + networks
1 parent 3c0a114 commit 0d9fd94

20 files changed

Lines changed: 1889 additions & 2011 deletions

apps/web/e2e/workspace.spec.js

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ test.beforeEach(async ({ page }) => {
55
await route.fulfill({
66
status: 200,
77
contentType: "application/json",
8-
body: JSON.stringify({
9-
provider: "ollama",
10-
model: "e2e-local",
11-
content: "Mock local model response.",
12-
}),
8+
body: JSON.stringify({ provider: "ollama", model: "e2e-local", content: "Mock local model response." }),
139
});
1410
});
1511

@@ -19,14 +15,7 @@ test.beforeEach(async ({ page }) => {
1915
contentType: "application/json",
2016
body: JSON.stringify({
2117
query: "local ai",
22-
results: [
23-
{
24-
title: "Self-hosted search result",
25-
url: "https://example.local/search",
26-
snippet: "A private search result returned by the e2e mock.",
27-
engine: "mock",
28-
},
29-
],
18+
results: [{ title: "Self-hosted search result", url: "https://example.local/search", snippet: "A private search result returned by the e2e mock.", engine: "mock" }],
3019
}),
3120
});
3221
});
@@ -35,42 +24,75 @@ test.beforeEach(async ({ page }) => {
3524
await route.fulfill({
3625
status: 200,
3726
contentType: "application/json",
38-
body: JSON.stringify({
39-
name: "session.md",
40-
content: "# Workspace notes\n\nSaved by e2e.",
41-
}),
27+
body: JSON.stringify({ name: "session.md", content: "# Workspace notes\n\nSaved by e2e." }),
4228
});
4329
});
30+
31+
await page.route("**/health", async (route) => {
32+
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok" }) });
33+
});
4434
});
4535

46-
test("renders the local AI workspace shell", async ({ page }) => {
36+
test("renders the Nexus AI workspace shell", async ({ page }) => {
4737
await page.goto("/");
48-
49-
await expect(page.getByText("Oddity AI")).toBeVisible();
50-
await expect(page.getByText("Local brain online. Ask me something strange but useful.")).toBeVisible();
51-
await expect(page.getByPlaceholder("Ask your local model...")).toBeVisible();
52-
await expect(page.getByRole("heading", { name: "Web search" })).toBeVisible();
53-
await expect(page.getByRole("heading", { name: "Local notes" })).toBeVisible();
38+
await expect(page.getByText("Nexus")).toBeVisible();
39+
await expect(page.getByText("Nexus is ready")).toBeVisible();
40+
await expect(page.getByPlaceholder(/Ask your local model/i)).toBeVisible();
5441
});
5542

56-
test("sends a chat message through the mocked API", async ({ page }) => {
43+
test("shows provider badge in header", async ({ page }) => {
5744
await page.goto("/");
45+
await expect(page.getByText("Ollama")).toBeVisible();
46+
});
5847

59-
await page.getByPlaceholder("Ask your local model...").fill("Summarize my private workspace.");
48+
test("sends a chat message and shows mock response", async ({ page }) => {
49+
await page.goto("/");
50+
const input = page.getByPlaceholder(/Ask your local model/i);
51+
await input.fill("Summarize my private workspace.");
6052
await page.getByTitle("Send").click();
61-
6253
await expect(page.getByText("Summarize my private workspace.")).toBeVisible();
63-
await expect(page.getByText("Mock local model response.")).toBeVisible();
54+
await expect(page.getByText("Mock local model response.")).toBeVisible({ timeout: 10_000 });
6455
});
6556

66-
test("runs search and saves local notes", async ({ page }) => {
57+
test("sends message with Enter key", async ({ page }) => {
6758
await page.goto("/");
59+
const input = page.getByPlaceholder(/Ask your local model/i);
60+
await input.fill("Hello from keyboard.");
61+
await input.press("Enter");
62+
await expect(page.getByText("Hello from keyboard.")).toBeVisible();
63+
await expect(page.getByText("Mock local model response.")).toBeVisible({ timeout: 10_000 });
64+
});
65+
66+
test("new chat button clears conversation", async ({ page }) => {
67+
await page.goto("/");
68+
const input = page.getByPlaceholder(/Ask your local model/i);
69+
await input.fill("Test message.");
70+
await page.getByTitle("Send").click();
71+
await expect(page.getByText("Test message.")).toBeVisible();
72+
await page.getByRole("button", { name: /new chat/i }).click();
73+
await expect(page.getByText("Nexus is ready")).toBeVisible();
74+
});
6875

69-
await page.getByPlaceholder("Search via SearXNG").fill("local ai");
70-
await page.getByTitle("Search").click();
76+
test("runs search and shows results", async ({ page }) => {
77+
await page.goto("/");
78+
await page.getByPlaceholder(/Search via SearXNG/i).fill("local ai");
79+
await page.getByRole("button", { name: /go/i }).click();
7180
await expect(page.getByText("Self-hosted search result")).toBeVisible();
81+
});
7282

73-
await page.getByRole("textbox").last().fill("# Workspace notes\n\nSaved by e2e.");
74-
await page.getByRole("button", { name: "Save note" }).click();
83+
test("switches to notes tab and saves note", async ({ page }) => {
84+
await page.goto("/");
85+
await page.getByRole("button", { name: /notes/i }).last().click();
86+
await expect(page.locator(".notes-textarea")).toBeVisible();
87+
await page.locator(".notes-textarea").fill("# Workspace notes\n\nSaved by e2e.");
88+
await page.getByRole("button", { name: /save/i }).last().click();
7589
});
7690

91+
test("sidebar collapses and expands", async ({ page }) => {
92+
await page.goto("/");
93+
const toggleBtn = page.locator(".sidebar-toggle");
94+
await toggleBtn.click();
95+
await expect(page.locator(".brand-name")).toBeHidden();
96+
await toggleBtn.click();
97+
await expect(page.locator(".brand-name")).toBeVisible();
98+
});

apps/web/index.html

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1-
<div id="root"></div>
2-
<script type="module" src="/src/main.jsx"></script>
3-
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+
<meta name="description" content="Nexus — self-hosted local-first AI workspace" />
7+
<title>Nexus · Local AI Workspace</title>
8+
<link rel="preconnect" href="https://fonts.googleapis.com" />
9+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23c47c16'/%3E%3Cpath d='M8 10l8-4 8 4-8 4-8-4zM8 22l8 4 8-4M8 16l8 4 8-4' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E" />
11+
</head>
12+
<body>
13+
<div id="root"></div>
14+
<script type="module" src="/src/main.jsx"></script>
15+
</body>
16+
</html>

apps/web/package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
{
2+
"name": "@misungtr/web",
3+
"private": true,
4+
"version": "0.2.0",
5+
"type": "module",
26
"scripts": {
37
"dev": "vite --host 0.0.0.0",
48
"build": "vite build",
5-
"test:e2e": "playwright test"
9+
"preview": "vite preview",
10+
"test:e2e": "playwright test",
11+
"test:e2e:ui": "playwright test --ui",
12+
"test:e2e:headed": "playwright test --headed"
613
},
714
"dependencies": {
815
"@vitejs/plugin-react": "latest",
916
"vite": "latest",
1017
"typescript": "latest",
1118
"react": "latest",
12-
"react-dom": "latest",
13-
"lucide-react": "latest"
19+
"react-dom": "latest"
1420
},
1521
"devDependencies": {
1622
"@playwright/test": "latest"

apps/web/playwright.config.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,22 @@ import { defineConfig, devices } from "@playwright/test";
33
export default defineConfig({
44
testDir: "./e2e",
55
timeout: 30_000,
6-
expect: {
7-
timeout: 5_000,
8-
},
6+
expect: { timeout: 8_000 },
97
fullyParallel: true,
10-
reporter: [["list"], ["html", { open: "never" }]],
8+
forbidOnly: !!process.env.CI,
9+
retries: process.env.CI ? 2 : 0,
10+
workers: process.env.CI ? 1 : undefined,
11+
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
1112
use: {
1213
baseURL: "http://127.0.0.1:5173",
1314
trace: "on-first-retry",
15+
screenshot: "only-on-failure",
1416
},
15-
projects: [
16-
{
17-
name: "chromium",
18-
use: { ...devices["Desktop Chrome"] },
19-
},
20-
],
17+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
2118
webServer: {
2219
command: "npm run dev -- --host 127.0.0.1 --port 5173",
2320
url: "http://127.0.0.1:5173",
24-
reuseExistingServer: true,
21+
reuseExistingServer: !process.env.CI,
2522
timeout: 60_000,
2623
},
2724
});
28-

0 commit comments

Comments
 (0)