yana-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISH.md +26 -0
- package/README.md +46 -0
- package/auth.js +204 -0
- package/bin/yana.js +20 -0
- package/desktop/app.jsx +240 -0
- package/desktop/chat.jsx +524 -0
- package/desktop/components.jsx +203 -0
- package/desktop/dashboard.jsx +304 -0
- package/desktop/index.html +29 -0
- package/desktop/login.html +474 -0
- package/desktop/spaces.jsx +248 -0
- package/desktop/system.jsx +540 -0
- package/desktop/themes.css +275 -0
- package/desktop/welcome.html +251 -0
- package/logo.png +0 -0
- package/memory.js +112 -0
- package/missions.js +140 -0
- package/mobile/index.html +34 -0
- package/mobile/m-app.jsx +152 -0
- package/mobile/m-chat.jsx +298 -0
- package/mobile/m-garden.jsx +72 -0
- package/mobile/m-lake.jsx +190 -0
- package/mobile/m-missions.jsx +81 -0
- package/mobile/m-shell.jsx +237 -0
- package/mobile/m-system.jsx +355 -0
- package/mobile/mobile.css +180 -0
- package/mobile/themes.css +228 -0
- package/package.json +29 -0
- package/server.js +977 -0
- package/shared/crypto-store.js +160 -0
- package/shared/data.js +154 -0
- package/shared/tweaks-panel.jsx +558 -0
package/PUBLISH.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Publish to npm
|
|
2
|
+
|
|
3
|
+
GitHub repo đã lên: https://github.com/phamlongh230-lgtm/yana-web
|
|
4
|
+
|
|
5
|
+
## Còn 2 bước npm — anh chạy khi về:
|
|
6
|
+
|
|
7
|
+
### 1. Publish yamtam-core (dependency)
|
|
8
|
+
```bash
|
|
9
|
+
cd ~/yamtam-engine/packages/yamtam-core
|
|
10
|
+
npm login
|
|
11
|
+
npm publish --access public
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### 2. Publish yana-web
|
|
15
|
+
```bash
|
|
16
|
+
cd ~/yana-web
|
|
17
|
+
npm publish --access public
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Sau khi publish, người dùng chạy:
|
|
21
|
+
```bash
|
|
22
|
+
npx yana
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Nếu muốn scoped package (@phamlongh230/yana):
|
|
26
|
+
Sửa name trong package.json thành "@phamlongh230/yana" trước khi publish.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Yana
|
|
2
|
+
|
|
3
|
+
Self-hosted AI workspace. One command to run.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx yana
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Opens at `http://localhost:8081`. Add your API key in **System → Providers**, start chatting.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Multi-provider** — Claude, OpenAI, Gemini, Groq, DeepSeek, OpenRouter, Ollama
|
|
16
|
+
- **Encrypted keys** — API keys stored with AES-256-GCM, never sent anywhere except the provider you choose
|
|
17
|
+
- **Memory** — Yana remembers context across conversations
|
|
18
|
+
- **Missions** — break goals into tracked tasks
|
|
19
|
+
- **Mobile + Desktop UI** — auto-detects device, separate optimised shells
|
|
20
|
+
- **Self-hosted** — runs on your machine, your data stays local
|
|
21
|
+
|
|
22
|
+
## Install globally
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g yana-web
|
|
26
|
+
yana
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Environment variables
|
|
30
|
+
|
|
31
|
+
| Variable | Default | Description |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `PORT` | `8081` | Server port |
|
|
34
|
+
| `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker/remote) |
|
|
35
|
+
| `YANA_DATA_DIR` | `~/.yana` | Where auth, memory and missions are stored |
|
|
36
|
+
| `YANA_ROOT` | `process.cwd()` | Optional: path to a YAMTAM engine checkout for skill routing |
|
|
37
|
+
|
|
38
|
+
## Docker
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
docker run -p 8081:8081 -e HOST=0.0.0.0 -v ~/.yana:/root/.yana ghcr.io/phamlongh230-lgtm/yana-web
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT
|
package/auth.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Yana Auth — single-user password gate for the local web UI.
|
|
3
|
+
//
|
|
4
|
+
// Password: scrypt hash (random salt, N=16384) in .yana/auth.json — never
|
|
5
|
+
// plaintext, never in env, never in a URL (rule 66 / api-security-gate API2).
|
|
6
|
+
// Sessions: random 256-bit tokens in an HttpOnly SameSite=Lax cookie,
|
|
7
|
+
// persisted to .yana/sessions.json so a server restart keeps you signed in.
|
|
8
|
+
// Login attempts are rate-limited per IP (5 per 15 min) — OWASP API6.
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// Persistent data dir. Default: dot-dir next to the server (static server never
|
|
15
|
+
// serves it). Override with YANA_DATA_DIR to point at a mounted volume
|
|
16
|
+
// (e.g. /data on Railway) so accounts survive redeploys.
|
|
17
|
+
const DATA_DIR = process.env.YANA_DATA_DIR || path.join(require('os').homedir(), '.yana');
|
|
18
|
+
const AUTH_FILE = path.join(DATA_DIR, 'auth.json');
|
|
19
|
+
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
|
20
|
+
const COOKIE = 'yana_sid';
|
|
21
|
+
const SESSION_TTL = 7 * 24 * 3600 * 1000; // 7 days (default)
|
|
22
|
+
const REMEMBER_TTL = 30 * 24 * 3600 * 1000; // 30 days ("remember me")
|
|
23
|
+
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
|
|
24
|
+
|
|
25
|
+
const LOGIN_RATE = { windowMs: 15 * 60_000, max: 5, hits: new Map() };
|
|
26
|
+
|
|
27
|
+
let sessions = loadJson(SESSIONS_FILE) || {};
|
|
28
|
+
|
|
29
|
+
function loadJson(file) {
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveJson(file, data) {
|
|
34
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
35
|
+
fs.writeFileSync(file, JSON.stringify(data), { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hashPassword(password) {
|
|
39
|
+
const salt = crypto.randomBytes(16);
|
|
40
|
+
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
|
41
|
+
return { salt: salt.toString('hex'), hash: hash.toString('hex') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function verifyPassword(password, rec) {
|
|
45
|
+
const expected = Buffer.from(rec.hash, 'hex');
|
|
46
|
+
const actual = crypto.scryptSync(password, Buffer.from(rec.salt, 'hex'), expected.length, SCRYPT);
|
|
47
|
+
return crypto.timingSafeEqual(actual, expected);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isSetUp() {
|
|
51
|
+
const rec = loadJson(AUTH_FILE);
|
|
52
|
+
return !!(rec && rec.salt && rec.hash);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Sessions ──────────────────────────────────────────────────────────────────
|
|
56
|
+
function createSession(remember) {
|
|
57
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
58
|
+
sessions[token] = { created: Date.now(), ttl: remember ? REMEMBER_TTL : SESSION_TTL };
|
|
59
|
+
pruneSessions();
|
|
60
|
+
saveJson(SESSIONS_FILE, sessions);
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pruneSessions() {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
for (const [t, s] of Object.entries(sessions)) {
|
|
67
|
+
if (now - s.created > (s.ttl || SESSION_TTL)) delete sessions[t];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sessionToken(req) {
|
|
72
|
+
const header = req.headers.cookie || '';
|
|
73
|
+
for (const part of header.split(';')) {
|
|
74
|
+
const [k, v] = part.trim().split('=');
|
|
75
|
+
if (k === COOKIE && v) return v;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isAuthed(req) {
|
|
81
|
+
const token = sessionToken(req);
|
|
82
|
+
if (!token || !sessions[token]) return false;
|
|
83
|
+
const s = sessions[token];
|
|
84
|
+
if (Date.now() - s.created > (s.ttl || SESSION_TTL)) {
|
|
85
|
+
delete sessions[token];
|
|
86
|
+
saveJson(SESSIONS_FILE, sessions);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// req.secure is resolved by server.js (X-Forwarded-Proto behind a trusted
|
|
93
|
+
// proxy) — the Secure flag keeps the session cookie off plain-HTTP hops.
|
|
94
|
+
function setCookie(req, res, token) {
|
|
95
|
+
const ttl = (sessions[token] && sessions[token].ttl) || SESSION_TTL;
|
|
96
|
+
const secure = req.secure ? '; Secure' : '';
|
|
97
|
+
res.setHeader('Set-Cookie',
|
|
98
|
+
`${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttl / 1000}${secure}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clearCookie(req, res) {
|
|
102
|
+
const secure = req.secure ? '; Secure' : '';
|
|
103
|
+
res.setHeader('Set-Cookie', `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Rate limit (login only — stricter than the global POST limiter) ──────────
|
|
107
|
+
function loginRateLimited(req) {
|
|
108
|
+
// req.clientIp is the proxy-aware address resolved by server.js — without it
|
|
109
|
+
// every visitor behind Railway's proxy would share one rate-limit bucket
|
|
110
|
+
const ip = req.clientIp || req.socket.remoteAddress || 'unknown';
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
let rec = LOGIN_RATE.hits.get(ip);
|
|
113
|
+
if (!rec || now - rec.start > LOGIN_RATE.windowMs) rec = { count: 0, start: now };
|
|
114
|
+
rec.count++;
|
|
115
|
+
LOGIN_RATE.hits.set(ip, rec);
|
|
116
|
+
return rec.count > LOGIN_RATE.max;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
120
|
+
function json(res, status, obj) {
|
|
121
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify(obj));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleStatus(req, res) {
|
|
126
|
+
const rec = loadJson(AUTH_FILE);
|
|
127
|
+
json(res, 200, {
|
|
128
|
+
setup: isSetUp(),
|
|
129
|
+
authed: isAuthed(req),
|
|
130
|
+
// Account name is shown on the login screen (single-user local app) —
|
|
131
|
+
// it is display data, not a secret.
|
|
132
|
+
username: (rec && rec.username) || null,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Account names are compared NFC-normalized and case-insensitive so that
|
|
137
|
+
// Vietnamese IME composition differences never lock the owner out.
|
|
138
|
+
function normalizeUsername(name) {
|
|
139
|
+
return String(name).normalize('NFC').trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validUsername(name) {
|
|
143
|
+
if (typeof name !== 'string') return false;
|
|
144
|
+
const n = normalizeUsername(name);
|
|
145
|
+
// 2–32 visible chars, no control characters
|
|
146
|
+
return n.length >= 2 && n.length <= 32 && !/[\u0000-\u001f\u007f]/.test(n);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// First run only: create the account (username + password), then sign in.
|
|
150
|
+
function handleSetup(req, res, body) {
|
|
151
|
+
if (isSetUp()) { json(res, 409, { error: 'Already set up' }); return; }
|
|
152
|
+
const username = body && body.username;
|
|
153
|
+
const password = body && body.password;
|
|
154
|
+
if (!validUsername(username)) {
|
|
155
|
+
json(res, 400, { error: 'Username must be 2-32 characters' }); return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof password !== 'string' || password.length < 6) {
|
|
158
|
+
json(res, 400, { error: 'Password must be at least 6 characters' }); return;
|
|
159
|
+
}
|
|
160
|
+
saveJson(AUTH_FILE, {
|
|
161
|
+
...hashPassword(password),
|
|
162
|
+
username: normalizeUsername(username),
|
|
163
|
+
created: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
setCookie(req, res, createSession(!!body.remember));
|
|
166
|
+
json(res, 200, { ok: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleLogin(req, res, body) {
|
|
170
|
+
if (loginRateLimited(req)) {
|
|
171
|
+
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '900' });
|
|
172
|
+
res.end(JSON.stringify({ error: 'Too many attempts — wait 15 minutes' }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const rec = loadJson(AUTH_FILE);
|
|
176
|
+
if (!rec) { json(res, 409, { error: 'Not set up yet' }); return; }
|
|
177
|
+
const password = body && body.password;
|
|
178
|
+
// Accounts created before usernames existed have no rec.username — skip the
|
|
179
|
+
// name check for them so the owner is never locked out by this upgrade.
|
|
180
|
+
if (rec.username) {
|
|
181
|
+
const given = body && body.username;
|
|
182
|
+
if (typeof given !== 'string' ||
|
|
183
|
+
normalizeUsername(given).toLowerCase() !== rec.username.toLowerCase()) {
|
|
184
|
+
json(res, 401, { error: 'Wrong username or password' }); return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (typeof password !== 'string' || !verifyPassword(password, rec)) {
|
|
188
|
+
json(res, 401, { error: 'Wrong username or password' }); return;
|
|
189
|
+
}
|
|
190
|
+
setCookie(req, res, createSession(!!body.remember));
|
|
191
|
+
json(res, 200, { ok: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleLogout(req, res) {
|
|
195
|
+
const token = sessionToken(req);
|
|
196
|
+
if (token && sessions[token]) {
|
|
197
|
+
delete sessions[token];
|
|
198
|
+
saveJson(SESSIONS_FILE, sessions);
|
|
199
|
+
}
|
|
200
|
+
clearCookie(req, res);
|
|
201
|
+
json(res, 200, { ok: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { isAuthed, isSetUp, handleStatus, handleSetup, handleLogin, handleLogout };
|
package/bin/yana.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Open browser automatically after server starts
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
// Forward to server.js — it exports the http.Server instance
|
|
8
|
+
const server = require('../server.js');
|
|
9
|
+
|
|
10
|
+
// Wait for server to be listening, then open browser
|
|
11
|
+
server.on('listening', () => {
|
|
12
|
+
const { port } = server.address();
|
|
13
|
+
const url = `http://localhost:${port}`;
|
|
14
|
+
try {
|
|
15
|
+
const cmd = process.platform === 'darwin' ? `open ${url}`
|
|
16
|
+
: process.platform === 'win32' ? `start ${url}`
|
|
17
|
+
: `xdg-open ${url}`;
|
|
18
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
19
|
+
} catch (_) {}
|
|
20
|
+
});
|
package/desktop/app.jsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Yana AI — app shell, routing, tweaks wiring
|
|
2
|
+
|
|
3
|
+
/* ---------- Memory Garden — real L1 atomic facts via /api/memories ---------- */
|
|
4
|
+
function MemoryGarden() {
|
|
5
|
+
const [data, setData] = React.useState(null);
|
|
6
|
+
const [filter, setFilter] = React.useState("all");
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
fetch("/api/memories")
|
|
10
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
11
|
+
.then((d) => { if (d) setData(d); })
|
|
12
|
+
.catch(() => {});
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
const memories = data ? data.memories : [];
|
|
16
|
+
const kinds = ["all", ...Array.from(new Set(memories.map((m) => m.kind)))];
|
|
17
|
+
const visible = filter === "all" ? memories : memories.filter((m) => m.kind === filter);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div data-screen-label="Memory Garden">
|
|
21
|
+
<PageHeader
|
|
22
|
+
title={L("Memory Garden", "Vườn ký ức")}
|
|
23
|
+
sub={data
|
|
24
|
+
? data.total + L(" L1 atomic facts · persisted in memory/L1_atomic", " fact L1 · lưu tại memory/L1_atomic")
|
|
25
|
+
: L("Loading memories…", "Đang tải ký ức…")}>
|
|
26
|
+
<div style={{ display: "flex", gap: 6 }}>
|
|
27
|
+
{kinds.map((k) => (
|
|
28
|
+
<button key={k} onClick={() => setFilter(k)} style={{
|
|
29
|
+
padding: "5px 13px", borderRadius: 99, border: "none", cursor: "pointer", fontSize: 12.5,
|
|
30
|
+
fontWeight: filter === k ? 500 : 400,
|
|
31
|
+
background: filter === k ? "var(--primary)" : "rgba(var(--shadow-rgb), .08)",
|
|
32
|
+
color: filter === k ? "white" : "var(--ink-2)",
|
|
33
|
+
transition: "background .15s",
|
|
34
|
+
}}>{k === "all" ? L("All", "Tất cả") : k}</button>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</PageHeader>
|
|
38
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--gap)", maxWidth: 800 }}>
|
|
39
|
+
{data && visible.length === 0 && (
|
|
40
|
+
<div style={{ color: "var(--ink-3)", fontSize: 13 }}>{L("No memories yet.", "Chưa có ký ức nào.")}</div>
|
|
41
|
+
)}
|
|
42
|
+
{visible.map((m) => (
|
|
43
|
+
<div key={m.id} className="glass" style={{ borderRadius: "var(--r-lg)", padding: "var(--pad-card)", display: "flex", gap: 14 }}>
|
|
44
|
+
<div style={{ flex: "none", paddingTop: 2 }}>
|
|
45
|
+
<span style={{ color: "var(--pink)" }}>{Icons.memory(16)}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
48
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
|
|
49
|
+
<span className="chip neutral" style={{ fontSize: 11 }}>{m.kind}</span>
|
|
50
|
+
{m.confidence && <span className="chip gold" style={{ fontSize: 10.5 }}>{m.confidence}</span>}
|
|
51
|
+
{m.fresh && <span className="chip" style={{ fontSize: 10.5, color: "var(--good)" }}>{L("Fresh", "Mới")}</span>}
|
|
52
|
+
</div>
|
|
53
|
+
<p style={{ margin: 0, fontSize: 13.5, lineHeight: 1.55, color: "var(--ink)" }}>{m.text}</p>
|
|
54
|
+
{m.source && <div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 7 }}>{m.source}</div>}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ---------- Skills — real counts via /api/skills (core/skills on disk) ---------- */
|
|
64
|
+
function Skills() {
|
|
65
|
+
const [data, setData] = React.useState(null);
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
fetch("/api/skills")
|
|
69
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
70
|
+
.then((d) => { if (d) setData(d); })
|
|
71
|
+
.catch(() => {});
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const groups = data
|
|
75
|
+
? [{ name: L("core (standalone)", "lõi (độc lập)"), count: data.standalone }, ...data.packs]
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div data-screen-label="Skills">
|
|
80
|
+
<PageHeader
|
|
81
|
+
title={L("Skills", "Kỹ năng")}
|
|
82
|
+
sub={data
|
|
83
|
+
? data.total.toLocaleString() + L(" skills on disk · " + data.pack_count + " imported packs", " kỹ năng trên đĩa · " + data.pack_count + " gói đã nhập")
|
|
84
|
+
: L("Counting skills…", "Đang đếm kỹ năng…")} />
|
|
85
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "var(--gap)" }}>
|
|
86
|
+
{groups.map((c) => (
|
|
87
|
+
<div key={c.name} className="glass" style={{ borderRadius: "var(--r-lg)", padding: "var(--pad-card)", display: "flex", flexDirection: "column", gap: 10 }}>
|
|
88
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
89
|
+
<span style={{ fontSize: 14.5, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</span>
|
|
90
|
+
<span className="chip neutral" style={{ fontSize: 11.5, flex: "none" }}>{c.count.toLocaleString()}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="bar" style={{ height: 4 }}>
|
|
93
|
+
<i style={{ width: Math.round(c.count / data.total * 100) + "%" }}></i>
|
|
94
|
+
</div>
|
|
95
|
+
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
96
|
+
{Math.round(c.count / data.total * 100)}% {L("of catalog", "danh mục")}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ---------- App shell ---------- */
|
|
106
|
+
const TWEAK_DEFAULTS = {
|
|
107
|
+
"theme": "Jade Lake 🌿",
|
|
108
|
+
"language": "English",
|
|
109
|
+
"blur": 70,
|
|
110
|
+
"transparency": 60,
|
|
111
|
+
"reflection": 70,
|
|
112
|
+
"depth": 55,
|
|
113
|
+
"layout": "Regular",
|
|
114
|
+
"showAgents": true,
|
|
115
|
+
"showMissions": true,
|
|
116
|
+
"showMemory": true,
|
|
117
|
+
"showSystem": true,
|
|
118
|
+
"accent": ""
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const THEME_MAP = {
|
|
122
|
+
"Lotus Dawn 🌸": "dawn",
|
|
123
|
+
"Jade Lake 🌿": "jade",
|
|
124
|
+
"Morning Mist ☁️": "mist",
|
|
125
|
+
"Glass Silver ✨": "silver",
|
|
126
|
+
};
|
|
127
|
+
const DENSITY = { "Compact": 0.85, "Regular": 1, "Spacious": 1.18 };
|
|
128
|
+
|
|
129
|
+
function applyTweaks(t) {
|
|
130
|
+
const root = document.documentElement;
|
|
131
|
+
root.setAttribute("data-theme", THEME_MAP[t.theme] || "jade");
|
|
132
|
+
root.style.setProperty("--blur", t.blur / 100);
|
|
133
|
+
root.style.setProperty("--alpha", t.transparency / 100);
|
|
134
|
+
root.style.setProperty("--reflect", t.reflection / 100);
|
|
135
|
+
root.style.setProperty("--depth", t.depth / 100);
|
|
136
|
+
root.style.setProperty("--sp", DENSITY[t.layout] || 1);
|
|
137
|
+
if (t.accent) {
|
|
138
|
+
root.style.setProperty("--primary", t.accent);
|
|
139
|
+
root.style.setProperty("--primary-soft", "color-mix(in oklab, " + t.accent + " 10%, transparent)");
|
|
140
|
+
} else {
|
|
141
|
+
root.style.removeProperty("--primary");
|
|
142
|
+
root.style.removeProperty("--primary-soft");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function App() {
|
|
147
|
+
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
148
|
+
const [page, setPage] = React.useState(() => localStorage.getItem("yana.page") || "dashboard");
|
|
149
|
+
window.YANA_LANG = t.language === "Tiếng Việt" ? "vi" : "en";
|
|
150
|
+
|
|
151
|
+
React.useEffect(() => applyTweaks(t), [t]);
|
|
152
|
+
React.useEffect(() => localStorage.setItem("yana.page", page), [page]);
|
|
153
|
+
|
|
154
|
+
const Page = {
|
|
155
|
+
dashboard: () => <Dashboard t={t} onNav={setPage} />,
|
|
156
|
+
chat: () => <Chat t={t} />,
|
|
157
|
+
agents: () => <AgentSpace />,
|
|
158
|
+
missions: () => <MissionCenter />,
|
|
159
|
+
memory: () => <MemoryGarden />,
|
|
160
|
+
skills: () => <Skills />,
|
|
161
|
+
providers: () => <Providers />,
|
|
162
|
+
settings: () => <Settings t={t} setTweak={setTweak} />,
|
|
163
|
+
}[page] || (() => <Dashboard t={t} onNav={setPage} />);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div key={t.language} className="yana-app" style={{ position: "relative", zIndex: 1, height: "100%", display: "flex", gap: "var(--gap)" }}>
|
|
167
|
+
<Sidebar page={page} onNav={setPage} />
|
|
168
|
+
<main className="yana-main" style={{ flex: 1, minWidth: 0, minHeight: 0, overflowY: page === "chat" ? "hidden" : "auto", display: "flex", flexDirection: "column" }}>
|
|
169
|
+
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
170
|
+
<Page />
|
|
171
|
+
</div>
|
|
172
|
+
</main>
|
|
173
|
+
|
|
174
|
+
<TweaksPanel>
|
|
175
|
+
<TweakSection label="Theme" />
|
|
176
|
+
<TweakSelect label="Direction" value={t.theme}
|
|
177
|
+
options={Object.keys(THEME_MAP)}
|
|
178
|
+
onChange={(v) => setTweak("theme", v)} />
|
|
179
|
+
<TweakRadio label="Language" value={t.language}
|
|
180
|
+
options={["English", "Tiếng Việt"]}
|
|
181
|
+
onChange={(v) => setTweak("language", v)} />
|
|
182
|
+
|
|
183
|
+
<TweakSection label="Glass" />
|
|
184
|
+
<TweakSlider label="Blur strength" value={t.blur} min={0} max={100} unit="%" onChange={(v) => setTweak("blur", v)} />
|
|
185
|
+
<TweakSlider label="Transparency" value={t.transparency} min={0} max={100} unit="%" onChange={(v) => setTweak("transparency", v)} />
|
|
186
|
+
<TweakSlider label="Reflection" value={t.reflection} min={0} max={100} unit="%" onChange={(v) => setTweak("reflection", v)} />
|
|
187
|
+
<TweakSlider label="Depth" value={t.depth} min={0} max={100} unit="%" onChange={(v) => setTweak("depth", v)} />
|
|
188
|
+
|
|
189
|
+
<TweakSection label="Layout" />
|
|
190
|
+
<TweakRadio label="Density" value={t.layout} options={["Compact", "Regular", "Spacious"]} onChange={(v) => setTweak("layout", v)} />
|
|
191
|
+
|
|
192
|
+
<TweakSection label="AI Control Center" />
|
|
193
|
+
<TweakToggle label="Show agents" value={t.showAgents} onChange={(v) => setTweak("showAgents", v)} />
|
|
194
|
+
<TweakToggle label="Show missions" value={t.showMissions} onChange={(v) => setTweak("showMissions", v)} />
|
|
195
|
+
<TweakToggle label="Show Memory Garden" value={t.showMemory} onChange={(v) => setTweak("showMemory", v)} />
|
|
196
|
+
<TweakToggle label="Show system status" value={t.showSystem} onChange={(v) => setTweak("showSystem", v)} />
|
|
197
|
+
|
|
198
|
+
<TweakSection label="Visual style" />
|
|
199
|
+
<TweakColor label="Accent" value={t.accent || "#2f7e6e"}
|
|
200
|
+
options={["#2f7e6e", "#56949f", "#3a7ca5", "#7d6aa8", "#b96b80", "#b07a4f", "#b78f3d", "#6f8f5a", "#5b7282"]}
|
|
201
|
+
onChange={(v) => setTweak("accent", v)} />
|
|
202
|
+
<TweakButton label="Use theme accent" onClick={() => setTweak("accent", "")} />
|
|
203
|
+
</TweaksPanel>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ---------- Undercurrent: slow drifting motes ---------- */
|
|
209
|
+
const MOTES = [
|
|
210
|
+
{ left: "12%", top: "78%", dur: "64s", delay: "0s", dx: "60px", dy: "-50px", peak: 0.20 },
|
|
211
|
+
{ left: "26%", top: "88%", dur: "82s", delay: "-20s", dx: "-45px", dy: "-70px", peak: 0.16 },
|
|
212
|
+
{ left: "44%", top: "72%", dur: "71s", delay: "-40s", dx: "50px", dy: "-40px", peak: 0.18 },
|
|
213
|
+
{ left: "58%", top: "84%", dur: "90s", delay: "-10s", dx: "-60px", dy: "-55px", peak: 0.22 },
|
|
214
|
+
{ left: "72%", top: "76%", dur: "76s", delay: "-55s", dx: "40px", dy: "-65px", peak: 0.16 },
|
|
215
|
+
{ left: "84%", top: "90%", dur: "68s", delay: "-30s", dx: "-50px", dy: "-45px", peak: 0.20 },
|
|
216
|
+
{ left: "35%", top: "94%", dur: "86s", delay: "-65s", dx: "55px", dy: "-60px", peak: 0.14 },
|
|
217
|
+
{ left: "92%", top: "68%", dur: "79s", delay: "-48s", dx: "-35px", dy: "-50px", peak: 0.14 },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
function Undercurrent() {
|
|
221
|
+
return (
|
|
222
|
+
<div className="scene">
|
|
223
|
+
{MOTES.map((m, i) => (
|
|
224
|
+
<span key={i} className="mote" style={{
|
|
225
|
+
left: m.left, top: m.top,
|
|
226
|
+
"--dur": m.dur, "--delay": m.delay, "--dx": m.dx, "--dy": m.dy, "--peak": m.peak,
|
|
227
|
+
}}></span>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Render only after the key vault has decrypted into its in-memory cache —
|
|
234
|
+
// otherwise ProviderCard/Chat would see an empty vault on first paint.
|
|
235
|
+
YanaVault.ready.then(() => ReactDOM.createRoot(document.getElementById("root")).render(
|
|
236
|
+
<>
|
|
237
|
+
<Undercurrent />
|
|
238
|
+
<App />
|
|
239
|
+
</>
|
|
240
|
+
));
|