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
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// Yana AI — Providers + Settings
|
|
2
|
+
function fmtTokens(n) {
|
|
3
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
4
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
5
|
+
return String(n);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const LIVE_MODEL_PROVIDERS = new Set(["openrouter", "groq", "9router", "ollama"]);
|
|
9
|
+
|
|
10
|
+
function ProviderCard({ p, usage, onKeyChange }) {
|
|
11
|
+
const keyless = KEYLESS_PROVIDERS.has(p.id);
|
|
12
|
+
const [hasKey, setHasKey] = React.useState(() => YanaVault.hasKey(p.id));
|
|
13
|
+
const connected = hasKey || keyless; // a stored API key — or an on-device provider
|
|
14
|
+
const [liveModels, setLiveModels] = React.useState(null);
|
|
15
|
+
const [checking, setChecking] = React.useState(false);
|
|
16
|
+
|
|
17
|
+
async function fetchLiveModels(key) {
|
|
18
|
+
if (!LIVE_MODEL_PROVIDERS.has(p.id)) return;
|
|
19
|
+
setChecking(true);
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetch("/api/models", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ provider: p.id, key: key || "" }),
|
|
25
|
+
});
|
|
26
|
+
if (r.ok) {
|
|
27
|
+
const { models } = await r.json();
|
|
28
|
+
setLiveModels(models.slice(0, 6).map((m) => m.name || m.id));
|
|
29
|
+
}
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
setChecking(false);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// On-device providers answer /api/models without a key — probe on mount
|
|
35
|
+
React.useEffect(() => { if (keyless) fetchLiveModels(""); }, []);
|
|
36
|
+
|
|
37
|
+
async function promptKey() {
|
|
38
|
+
const current = YanaVault.getKey(p.id) || "";
|
|
39
|
+
const raw = window.prompt(L("API key for ", "API key cho ") + p.name + L(" (leave blank to clear):", " (để trống để xóa):"), current);
|
|
40
|
+
if (raw === null) return;
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
if (trimmed) {
|
|
43
|
+
await YanaVault.setKey(p.id, trimmed);
|
|
44
|
+
setHasKey(true);
|
|
45
|
+
fetchLiveModels(trimmed);
|
|
46
|
+
} else {
|
|
47
|
+
YanaVault.removeKey(p.id);
|
|
48
|
+
setHasKey(false);
|
|
49
|
+
setLiveModels(null);
|
|
50
|
+
}
|
|
51
|
+
if (onKeyChange) onKeyChange();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const keyDisplay = hasKey
|
|
55
|
+
? YanaVault.getKey(p.id).slice(0, 8) + "····"
|
|
56
|
+
: "—";
|
|
57
|
+
|
|
58
|
+
const u = usage && usage[p.id];
|
|
59
|
+
const usageDisplay = u ? "~" + fmtTokens(u.est_tokens) + L(" tokens", " tokens") : L("Not used yet", "Chưa dùng");
|
|
60
|
+
const latencyDisplay = u && u.avg_latency_ms ? (u.avg_latency_ms / 1000).toFixed(1) + "s" : "—";
|
|
61
|
+
|
|
62
|
+
const displayModels = liveModels || p.models;
|
|
63
|
+
const modelLabel = liveModels
|
|
64
|
+
? L("Live models", "Model thực tế")
|
|
65
|
+
: L("Models", "Mô hình");
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="glass" style={{ borderRadius: "var(--r-lg)", padding: "var(--pad-card)", display: "flex", flexDirection: "column", gap: 11 }}>
|
|
69
|
+
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
|
|
70
|
+
<div style={{
|
|
71
|
+
width: 38, height: 38, borderRadius: 13, flex: "none", display: "grid", placeItems: "center",
|
|
72
|
+
fontSize: 15, fontWeight: 500, color: "var(--primary)",
|
|
73
|
+
background: "var(--primary-soft)", boxShadow: "inset 0 1px 0 rgba(255,255,255,.5)",
|
|
74
|
+
}}>{p.name[0]}</div>
|
|
75
|
+
<div style={{ lineHeight: 1.25, minWidth: 0 }}>
|
|
76
|
+
<div style={{ fontSize: 14.5, fontWeight: 500 }}>{p.name}</div>
|
|
77
|
+
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>{p.company}</div>
|
|
78
|
+
</div>
|
|
79
|
+
<span className={"chip " + (connected ? "" : "gold")} style={{ marginLeft: "auto", fontSize: 11.5 }}>
|
|
80
|
+
<span className={"dot " + (connected ? "on" : "idle")} style={{ width: 6, height: 6, boxShadow: "none" }}></span>
|
|
81
|
+
{keyless ? L("On-device", "Trên máy") : connected ? L("Connected", "Kết nối") : L("Standby", "Dự phòng")}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div style={{ fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.5 }}>{p.role}</div>
|
|
86
|
+
|
|
87
|
+
<div>
|
|
88
|
+
<div style={{ fontSize: 11, color: "var(--ink-3)", marginBottom: 5 }}>
|
|
89
|
+
{checking ? L("Fetching live models…", "Đang tải model thực tế…") : modelLabel}
|
|
90
|
+
</div>
|
|
91
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
92
|
+
{displayModels.map((m) => <span key={m} className="chip neutral" style={{ fontSize: 11 }}>{m}</span>)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, paddingTop: 10, borderTop: "1px solid var(--border)" }}>
|
|
97
|
+
{[[L("Usage", "Sử dụng"), usageDisplay], [L("Latency", "Độ trễ"), latencyDisplay]].map(([k, v]) => (
|
|
98
|
+
<div key={k} style={{ lineHeight: 1.35, minWidth: 0 }}>
|
|
99
|
+
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>{k}</div>
|
|
100
|
+
<div style={{ fontSize: 12, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{v}</div>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
<div style={{ lineHeight: 1.35, minWidth: 0 }}>
|
|
104
|
+
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>{L("Key", "Khóa")}</div>
|
|
105
|
+
{keyless ? (
|
|
106
|
+
<span title={L("On-device provider — no API key needed", "Provider trên máy — không cần API key")}
|
|
107
|
+
style={{ fontSize: 12, fontWeight: 500, color: "var(--good)" }}>
|
|
108
|
+
{L("keyless", "không cần")}
|
|
109
|
+
</span>
|
|
110
|
+
) : (
|
|
111
|
+
<button onClick={promptKey} title={L("Click to set API key", "Nhấn để đặt API key")} style={{
|
|
112
|
+
background: "none", border: "none", padding: 0, cursor: "pointer",
|
|
113
|
+
fontSize: 12, fontWeight: 500, color: hasKey ? "var(--good)" : "var(--primary)",
|
|
114
|
+
display: "flex", alignItems: "center", gap: 5, fontFamily: "inherit",
|
|
115
|
+
}}>
|
|
116
|
+
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 90 }}>
|
|
117
|
+
{keyDisplay}
|
|
118
|
+
</span>
|
|
119
|
+
<span style={{ fontSize: 10, opacity: .6 }}>✎</span>
|
|
120
|
+
</button>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Providers() {
|
|
129
|
+
const D = window.YANA;
|
|
130
|
+
const [usage, setUsage] = React.useState(null);
|
|
131
|
+
const [, bump] = React.useReducer((x) => x + 1, 0);
|
|
132
|
+
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
fetch("/api/usage")
|
|
135
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
136
|
+
.then((d) => { if (d) setUsage(d.usage); })
|
|
137
|
+
.catch(() => {});
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const connected = D.providers.filter((p) => providerAvailable(p.id)).length;
|
|
141
|
+
|
|
142
|
+
// Connect provider: open the key prompt for the first provider without a key
|
|
143
|
+
async function connectNext() {
|
|
144
|
+
const next = D.providers.find((p) => !KEYLESS_PROVIDERS.has(p.id) && !YanaVault.hasKey(p.id));
|
|
145
|
+
if (!next) { alert(L("All providers are connected.", "Tất cả nhà cung cấp đã kết nối.")); return; }
|
|
146
|
+
const raw = window.prompt(L("API key for ", "API key cho ") + next.name + ":");
|
|
147
|
+
if (raw === null || !raw.trim()) return;
|
|
148
|
+
await YanaVault.setKey(next.id, raw.trim());
|
|
149
|
+
bump();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div data-screen-label="Providers">
|
|
154
|
+
<PageHeader
|
|
155
|
+
title={L("Providers", "Nhà cung cấp")}
|
|
156
|
+
sub={connected + L(" of ", " trong ") + D.providers.length + L(" providers connected · Groq routes, YAMTAM supervises every call", " nhà cung cấp đã kết nối · Groq định tuyến, YAMTAM giám sát mọi lệnh gọi")}>
|
|
157
|
+
<button onClick={connectNext} style={{
|
|
158
|
+
display: "flex", alignItems: "center", gap: 7, padding: "8px 15px", borderRadius: 99,
|
|
159
|
+
border: "none", cursor: "pointer", background: "var(--primary)", color: "white",
|
|
160
|
+
fontSize: 13, fontWeight: 500, boxShadow: "0 4px 12px color-mix(in oklab, var(--primary) 30%, transparent)",
|
|
161
|
+
}}>{Icons.plus(15)} {L("Connect provider", "Kết nối nhà cung cấp")}</button>
|
|
162
|
+
</PageHeader>
|
|
163
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "var(--gap)" }}>
|
|
164
|
+
{D.providers.map((p) => (
|
|
165
|
+
<ProviderCard key={p.id + (YanaVault.hasKey(p.id) ? ":on" : ":off")} p={p} usage={usage} onKeyChange={bump} />
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ---------- Settings ---------- */
|
|
173
|
+
function SettingRow({ label, desc, value }) {
|
|
174
|
+
return (
|
|
175
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "calc(11px * var(--sp)) 0", borderBottom: "1px solid var(--border)" }}>
|
|
176
|
+
<div style={{ lineHeight: 1.35 }}>
|
|
177
|
+
<div style={{ fontSize: 13.5, fontWeight: 500 }}>{label}</div>
|
|
178
|
+
{desc && <div style={{ fontSize: 12, color: "var(--ink-3)" }}>{desc}</div>}
|
|
179
|
+
</div>
|
|
180
|
+
<span className="chip neutral" style={{ flex: "none" }}>{value}</span>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ---------- Settings: Appearance (Apple-style) ---------- */
|
|
186
|
+
const THEME_PREVIEWS = [
|
|
187
|
+
{ label: "Lotus Dawn 🌸", accent: "#b96b80", sky: "linear-gradient(160deg, #faf5f3 30%, #f2dfdc 100%)", wash: "rgba(236,196,134,.45)" },
|
|
188
|
+
{ label: "Jade Lake 🌿", accent: "#2f7e6e", sky: "linear-gradient(160deg, #f6faf7 30%, #ddeee7 100%)", wash: "rgba(122,184,168,.40)" },
|
|
189
|
+
{ label: "Morning Mist ☁️", accent: "#4a7a6a", sky: "linear-gradient(160deg, #f8f7f4 30%, #ecebe5 100%)", wash: "rgba(214,222,214,.55)" },
|
|
190
|
+
{ label: "Glass Silver ✨", accent: "#3a7ca5", sky: "linear-gradient(160deg, #f3f6fa 30%, #dde6ef 100%)", wash: "rgba(168,199,224,.45)" },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
function ThemeCard({ p, active, onPick }) {
|
|
194
|
+
return (
|
|
195
|
+
<button onClick={onPick} style={{ background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "center", color: "inherit" }}>
|
|
196
|
+
<div style={{
|
|
197
|
+
width: 118, height: 72, borderRadius: 12, background: p.sky, position: "relative", overflow: "hidden",
|
|
198
|
+
boxShadow: active
|
|
199
|
+
? "0 0 0 2px var(--bg-base), 0 0 0 4px " + p.accent
|
|
200
|
+
: "inset 0 0 0 1px var(--border)",
|
|
201
|
+
transition: "box-shadow .15s",
|
|
202
|
+
}}>
|
|
203
|
+
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(80px 40px at 80% 100%, " + p.wash + ", transparent 70%)" }}></div>
|
|
204
|
+
<div style={{ position: "absolute", left: 8, top: 8, bottom: 8, width: 26, borderRadius: 6, background: "rgba(255,255,255,.65)", boxShadow: "inset 0 0 0 .5px rgba(255,255,255,.8)" }}></div>
|
|
205
|
+
<div style={{ position: "absolute", left: 40, top: 8, right: 8, height: 22, borderRadius: 6, background: "rgba(255,255,255,.6)" }}></div>
|
|
206
|
+
<div style={{ position: "absolute", left: 40, top: 34, width: 34, height: 30, borderRadius: 6, background: "rgba(255,255,255,.5)" }}></div>
|
|
207
|
+
<div style={{ position: "absolute", left: 80, top: 34, right: 8, height: 30, borderRadius: 6, background: "rgba(255,255,255,.5)" }}></div>
|
|
208
|
+
<div style={{ position: "absolute", left: 13, top: 13, width: 10, height: 10, borderRadius: 4, background: p.accent, opacity: .9 }}></div>
|
|
209
|
+
</div>
|
|
210
|
+
<div style={{ fontSize: 12, marginTop: 7, fontWeight: active ? 500 : 400, color: active ? "var(--ink)" : "var(--ink-2)" }}>{p.label}</div>
|
|
211
|
+
</button>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function YSwitch({ value, onChange }) {
|
|
216
|
+
return (
|
|
217
|
+
<button onClick={() => onChange(!value)} aria-pressed={value} style={{
|
|
218
|
+
width: 40, height: 24, borderRadius: 99, border: "none", cursor: "pointer", flex: "none",
|
|
219
|
+
background: value ? "var(--primary)" : "rgba(var(--shadow-rgb), .15)",
|
|
220
|
+
position: "relative", transition: "background .18s",
|
|
221
|
+
}}>
|
|
222
|
+
<span style={{
|
|
223
|
+
position: "absolute", top: 2, left: value ? 18 : 2, width: 20, height: 20, borderRadius: "50%",
|
|
224
|
+
background: "white", boxShadow: "0 1px 3px rgba(0,0,0,.25)", transition: "left .18s",
|
|
225
|
+
}}></span>
|
|
226
|
+
</button>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function YSeg({ options, value, onChange }) {
|
|
231
|
+
return (
|
|
232
|
+
<div style={{ display: "inline-flex", gap: 2, padding: 3, borderRadius: 10, background: "rgba(var(--shadow-rgb), .07)" }}>
|
|
233
|
+
{options.map((o) => (
|
|
234
|
+
<button key={o} onClick={() => onChange(o)} style={{
|
|
235
|
+
padding: "5px 14px", borderRadius: 8, border: "none", cursor: "pointer", fontSize: 12.5,
|
|
236
|
+
fontWeight: value === o ? 500 : 400,
|
|
237
|
+
background: value === o ? "rgba(var(--surface-rgb), .95)" : "transparent",
|
|
238
|
+
boxShadow: value === o ? "0 1px 3px rgba(var(--shadow-rgb), .15)" : "none",
|
|
239
|
+
color: "var(--ink)", transition: "background .15s",
|
|
240
|
+
}}>{o}</button>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function SliderRow({ label, value, onChange }) {
|
|
247
|
+
return (
|
|
248
|
+
<div style={{ display: "grid", gridTemplateColumns: "110px 1fr 42px", alignItems: "center", gap: 14, padding: "7px 0" }}>
|
|
249
|
+
<span style={{ fontSize: 13 }}>{label}</span>
|
|
250
|
+
<input type="range" min="0" max="100" value={value}
|
|
251
|
+
onChange={(e) => onChange(+e.target.value)}
|
|
252
|
+
style={{ width: "100%", accentColor: "var(--primary)", height: 4 }} />
|
|
253
|
+
<span style={{ fontSize: 12, color: "var(--ink-3)", textAlign: "right" }}>{value}%</span>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ACCENTS = ["#2f7e6e", "#56949f", "#3a7ca5", "#7d6aa8", "#b96b80", "#b07a4f", "#b78f3d", "#6f8f5a", "#5b7282"];
|
|
259
|
+
|
|
260
|
+
function AppearanceCard({ t, setTweak }) {
|
|
261
|
+
const toggleLabels = [
|
|
262
|
+
[L("Show agents on Lake", "Hiện tác nhân trên Mặt hồ"), "showAgents"],
|
|
263
|
+
[L("Show missions on Lake", "Hiện nhiệm vụ trên Mặt hồ"), "showMissions"],
|
|
264
|
+
[L("Show Memory Garden", "Hiện Vườn ký ức"), "showMemory"],
|
|
265
|
+
[L("Show system status", "Hiện trạng thái hệ thống"), "showSystem"],
|
|
266
|
+
];
|
|
267
|
+
return (
|
|
268
|
+
<Card title={L("Appearance", "Giao diện")} style={{ gridColumn: "1 / -1" }}>
|
|
269
|
+
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginBottom: 18 }}>
|
|
270
|
+
{THEME_PREVIEWS.map((p) => (
|
|
271
|
+
<ThemeCard key={p.label} p={p} active={t.theme === p.label} onPick={() => setTweak("theme", p.label)} />
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 0", borderTop: "1px solid var(--border)" }}>
|
|
276
|
+
<span style={{ fontSize: 13, width: 110, flex: "none" }}>{L("Accent colour", "Màu nhấn")}</span>
|
|
277
|
+
<div style={{ display: "flex", gap: 9, alignItems: "center" }}>
|
|
278
|
+
<button onClick={() => setTweak("accent", "")} title={L("Theme default", "Màu mặc định theme")} style={{
|
|
279
|
+
width: 22, height: 22, borderRadius: "50%", cursor: "pointer", padding: 0,
|
|
280
|
+
background: "conic-gradient(#2f7e6e, #3a7ca5, #b96b80, #b78f3d, #2f7e6e)",
|
|
281
|
+
border: "none",
|
|
282
|
+
boxShadow: !t.accent ? "0 0 0 2px var(--bg-base), 0 0 0 4px var(--ink-3)" : "inset 0 0 0 1px rgba(0,0,0,.08)",
|
|
283
|
+
}}></button>
|
|
284
|
+
{ACCENTS.map((c) => (
|
|
285
|
+
<button key={c} onClick={() => setTweak("accent", c)} style={{
|
|
286
|
+
width: 22, height: 22, borderRadius: "50%", cursor: "pointer", padding: 0,
|
|
287
|
+
background: c, border: "none",
|
|
288
|
+
boxShadow: t.accent === c ? "0 0 0 2px var(--bg-base), 0 0 0 4px " + c : "inset 0 0 0 1px rgba(0,0,0,.08)",
|
|
289
|
+
}}></button>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
<span style={{ fontSize: 12, color: "var(--ink-3)", marginLeft: "auto" }}>
|
|
293
|
+
{t.accent ? t.accent : L("Theme default", "Màu mặc định")}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 0", borderTop: "1px solid var(--border)" }}>
|
|
298
|
+
<span style={{ fontSize: 13, width: 110, flex: "none" }}>{L("Density", "Mật độ")}</span>
|
|
299
|
+
<YSeg options={["Compact", "Regular", "Spacious"]} value={t.layout} onChange={(v) => setTweak("layout", v)} />
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div style={{ padding: "8px 0 0", borderTop: "1px solid var(--border)" }}>
|
|
303
|
+
<SliderRow label={L("Blur", "Mờ")} value={t.blur} onChange={(v) => setTweak("blur", v)} />
|
|
304
|
+
<SliderRow label={L("Transparency", "Trong suốt")} value={t.transparency} onChange={(v) => setTweak("transparency", v)} />
|
|
305
|
+
<SliderRow label={L("Reflection", "Phản chiếu")} value={t.reflection} onChange={(v) => setTweak("reflection", v)} />
|
|
306
|
+
<SliderRow label={L("Depth", "Độ sâu")} value={t.depth} onChange={(v) => setTweak("depth", v)} />
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "4px 24px", paddingTop: 10, borderTop: "1px solid var(--border)" }}>
|
|
310
|
+
{toggleLabels.map(([label, key]) => (
|
|
311
|
+
<div key={key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "7px 0" }}>
|
|
312
|
+
<span style={{ fontSize: 13 }}>{label}</span>
|
|
313
|
+
<YSwitch value={t[key]} onChange={(v) => setTweak(key, v)} />
|
|
314
|
+
</div>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
</Card>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ---------- About you: personal context for Yana ---------- */
|
|
322
|
+
function AboutField({ id, label, hint, placeholder, rows }) {
|
|
323
|
+
const key = "yana.about." + id;
|
|
324
|
+
const [v, setV] = React.useState(() => localStorage.getItem(key) || "");
|
|
325
|
+
const [saved, setSaved] = React.useState(false);
|
|
326
|
+
const timer = React.useRef(null);
|
|
327
|
+
|
|
328
|
+
function onChange(e) {
|
|
329
|
+
const val = e.target.value;
|
|
330
|
+
setV(val);
|
|
331
|
+
localStorage.setItem(key, val);
|
|
332
|
+
setSaved(false);
|
|
333
|
+
clearTimeout(timer.current);
|
|
334
|
+
timer.current = setTimeout(() => setSaved(true), 800);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
339
|
+
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 10 }}>
|
|
340
|
+
<label htmlFor={"about-" + id} style={{ fontSize: 13, fontWeight: 500 }}>{label}</label>
|
|
341
|
+
<span style={{ fontSize: 11, color: "var(--good)", opacity: saved ? 1 : 0, transition: "opacity .3s", display: "inline-flex", alignItems: "center", gap: 4 }}>
|
|
342
|
+
{Icons.check(11)} {L("Planted in Memory Garden", "Đã lưu vào Vườn ký ức")}
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
{hint && <div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: -3 }}>{hint}</div>}
|
|
346
|
+
<textarea id={"about-" + id} value={v} onChange={onChange} rows={rows || 3}
|
|
347
|
+
placeholder={placeholder}
|
|
348
|
+
style={{
|
|
349
|
+
width: "100%", resize: "vertical", padding: "10px 13px",
|
|
350
|
+
borderRadius: "var(--r-sm)", border: "1px solid var(--border)",
|
|
351
|
+
background: "rgba(var(--surface-rgb), .6)", color: "var(--ink)",
|
|
352
|
+
fontFamily: "inherit", fontSize: 13.5, lineHeight: 1.55, outline: "none",
|
|
353
|
+
}}
|
|
354
|
+
onFocus={(e) => e.target.style.borderColor = "var(--primary)"}
|
|
355
|
+
onBlur={(e) => e.target.style.borderColor = "var(--border)"}
|
|
356
|
+
></textarea>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function AboutYouCard() {
|
|
362
|
+
return (
|
|
363
|
+
<Card title={L("About you", "Về bạn")} style={{ gridColumn: "1 / -1" }}
|
|
364
|
+
aside={<span className="chip pink" style={{ fontSize: 11 }}>{Icons.memory(12)} {L("Pinned · never pruned", "Đã ghim · không bao giờ xóa")}</span>}>
|
|
365
|
+
<p style={{ margin: "0 0 14px", fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.55 }}>
|
|
366
|
+
{L(
|
|
367
|
+
"Yana reads this before every mission. The more honestly you describe yourself, the better she routes, plans, and phrases things for you.",
|
|
368
|
+
"Yana đọc phần này trước mỗi nhiệm vụ. Bạn mô tả bản thân càng thực tế, cô ấy càng định tuyến, lên kế hoạch và diễn đạt tốt hơn cho bạn."
|
|
369
|
+
)}
|
|
370
|
+
</p>
|
|
371
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 16 }}>
|
|
372
|
+
<AboutField id="who" label={L("Who you are", "Bạn là ai")}
|
|
373
|
+
hint={L("Role, what you're building, how you work", "Vai trò, bạn đang xây dựng gì, cách bạn làm việc")}
|
|
374
|
+
placeholder={L("e.g. I'm a system builder. I think in workflows, not code.", "e.g. Tôi xây hệ thống. Tôi nghĩ theo luồng công việc, không phải code.")} rows={4} />
|
|
375
|
+
<AboutField id="strengths" label={L("Strengths", "Điểm mạnh")}
|
|
376
|
+
hint={L("What Yana should lean on", "Điều Yana nên dựa vào")}
|
|
377
|
+
placeholder={L("e.g. Big-picture architecture, fast decisions.", "e.g. Kiến trúc tổng thể, ra quyết định nhanh.")} rows={4} />
|
|
378
|
+
<AboutField id="weaknesses" label={L("Weak spots", "Điểm yếu")}
|
|
379
|
+
hint={L("Where Yana should quietly cover for you", "Nơi Yana nên lặng lẽ hỗ trợ bạn")}
|
|
380
|
+
placeholder={L("e.g. I lose patience with long documents.", "e.g. Tôi mất kiên nhẫn với tài liệu dài.")} rows={4} />
|
|
381
|
+
<AboutField id="style" label={L("How Yana should respond", "Yana nên trả lời thế nào")}
|
|
382
|
+
hint={L("Tone, length, language", "Giọng điệu, độ dài, ngôn ngữ")}
|
|
383
|
+
placeholder={L("e.g. Calm and brief. Vietnamese is fine for casual notes.", "e.g. Bình tĩnh và ngắn gọn. Tiếng Việt được cho ghi chú thường ngày.")} rows={4} />
|
|
384
|
+
</div>
|
|
385
|
+
</Card>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* ---------- Settings: live data + editable rows (no display-only fakes) ---- */
|
|
390
|
+
|
|
391
|
+
// Editable text row — click ✎ to rename, persisted in localStorage
|
|
392
|
+
function EditableRow({ label, desc, storeKey, fallback }) {
|
|
393
|
+
const [v, setV] = React.useState(() => localStorage.getItem(storeKey) || fallback);
|
|
394
|
+
function edit() {
|
|
395
|
+
const raw = window.prompt(label + ":", v);
|
|
396
|
+
if (raw === null) return;
|
|
397
|
+
const next = raw.trim() || fallback;
|
|
398
|
+
setV(next);
|
|
399
|
+
localStorage.setItem(storeKey, next);
|
|
400
|
+
window.dispatchEvent(new CustomEvent("yana-setting", { detail: { key: storeKey, value: next } }));
|
|
401
|
+
}
|
|
402
|
+
return (
|
|
403
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "calc(11px * var(--sp)) 0", borderBottom: "1px solid var(--border)" }}>
|
|
404
|
+
<div style={{ lineHeight: 1.35 }}>
|
|
405
|
+
<div style={{ fontSize: 13.5, fontWeight: 500 }}>{label}</div>
|
|
406
|
+
{desc && <div style={{ fontSize: 12, color: "var(--ink-3)" }}>{desc}</div>}
|
|
407
|
+
</div>
|
|
408
|
+
<button onClick={edit} title={L("Click to edit", "Nhấn để sửa")} style={{
|
|
409
|
+
background: "none", border: "1px solid var(--border)", padding: "4px 12px",
|
|
410
|
+
borderRadius: 99, cursor: "pointer", fontSize: 12, color: "var(--primary)",
|
|
411
|
+
fontWeight: 500, fontFamily: "inherit", display: "flex", alignItems: "center", gap: 5,
|
|
412
|
+
}}>{v} <span style={{ fontSize: 10, opacity: .6 }}>✎</span></button>
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function detectTimezone() {
|
|
418
|
+
try {
|
|
419
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
420
|
+
const offMin = -new Date().getTimezoneOffset();
|
|
421
|
+
const sign = offMin >= 0 ? "+" : "−";
|
|
422
|
+
const hours = Math.floor(Math.abs(offMin) / 60);
|
|
423
|
+
const mins = Math.abs(offMin) % 60;
|
|
424
|
+
return "GMT" + sign + hours + (mins ? ":" + String(mins).padStart(2, "0") : "") + " · " + tz.split("/").pop().replace(/_/g, " ");
|
|
425
|
+
} catch (_) { return "UTC"; }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function Settings({ t, setTweak }) {
|
|
429
|
+
const D = window.YANA;
|
|
430
|
+
const langDisplay = t.language === "Tiếng Việt"
|
|
431
|
+
? L("English / Tiếng Việt ✓", "Tiếng Việt ✓ / English")
|
|
432
|
+
: L("English ✓ / Tiếng Việt", "English ✓ / Tiếng Việt");
|
|
433
|
+
function toggleLang() {
|
|
434
|
+
setTweak("language", t.language === "Tiếng Việt" ? "English" : "Tiếng Việt");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Live system state — same real sources the dashboard uses
|
|
438
|
+
const [dash, setDash] = React.useState(null);
|
|
439
|
+
React.useEffect(() => {
|
|
440
|
+
fetch("/api/dashboard")
|
|
441
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
442
|
+
.then((d) => { if (d) setDash(d); })
|
|
443
|
+
.catch(() => {});
|
|
444
|
+
}, []);
|
|
445
|
+
|
|
446
|
+
// Default chat provider — same key chat.jsx reads, so this is a real control
|
|
447
|
+
const [defProvider, setDefProvider] = React.useState(() => localStorage.getItem("yana.chat.provider") || "");
|
|
448
|
+
function pickProvider(v) {
|
|
449
|
+
setDefProvider(v);
|
|
450
|
+
localStorage.setItem("yana.chat.provider", v);
|
|
451
|
+
}
|
|
452
|
+
const available = D.providers.filter((p) => providerAvailable(p.id));
|
|
453
|
+
const chain = available.map((p) => p.name).join(" → ") || L("None — add a key in Providers", "Chưa có — thêm key ở Nhà cung cấp");
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<div data-screen-label="Settings">
|
|
457
|
+
<PageHeader
|
|
458
|
+
title={L("Settings", "Cài đặt")}
|
|
459
|
+
sub={L("Quiet defaults. Everything supervised by YAMTAM Core.", "Cài đặt mặc định. Mọi thứ được YAMTAM Core giám sát.")} />
|
|
460
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", gap: "var(--gap)", maxWidth: 900 }}>
|
|
461
|
+
<AppearanceCard t={t} setTweak={setTweak} />
|
|
462
|
+
<AboutYouCard />
|
|
463
|
+
<Card title={L("Workspace", "Không gian làm việc")}>
|
|
464
|
+
<EditableRow label={L("Workspace name", "Tên không gian")} storeKey="yana.workspace.name"
|
|
465
|
+
fallback={L("Yana's Lake", "Mặt hồ của Yana")} />
|
|
466
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "calc(11px * var(--sp)) 0", borderBottom: "1px solid var(--border)" }}>
|
|
467
|
+
<div style={{ lineHeight: 1.35 }}>
|
|
468
|
+
<div style={{ fontSize: 13.5, fontWeight: 500 }}>{L("Language", "Ngôn ngữ")}</div>
|
|
469
|
+
</div>
|
|
470
|
+
<button onClick={toggleLang} style={{
|
|
471
|
+
background: "none", border: "1px solid var(--border)", padding: "4px 12px",
|
|
472
|
+
borderRadius: 99, cursor: "pointer", fontSize: 12, color: "var(--primary)",
|
|
473
|
+
fontWeight: 500, fontFamily: "inherit",
|
|
474
|
+
}}>{langDisplay}</button>
|
|
475
|
+
</div>
|
|
476
|
+
<SettingRow label={L("Timezone", "Múi giờ")}
|
|
477
|
+
desc={L("Detected from this browser", "Phát hiện từ trình duyệt này")}
|
|
478
|
+
value={detectTimezone()} />
|
|
479
|
+
</Card>
|
|
480
|
+
<Card title={L("Orchestration", "Điều phối")}>
|
|
481
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "calc(11px * var(--sp)) 0", borderBottom: "1px solid var(--border)" }}>
|
|
482
|
+
<div style={{ lineHeight: 1.35 }}>
|
|
483
|
+
<div style={{ fontSize: 13.5, fontWeight: 500 }}>{L("Default provider", "Nhà cung cấp mặc định")}</div>
|
|
484
|
+
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>{L("Used by Chat unless overridden", "Chat dùng mặc định này trừ khi chọn khác")}</div>
|
|
485
|
+
</div>
|
|
486
|
+
<select value={defProvider} onChange={(e) => pickProvider(e.target.value)} style={{
|
|
487
|
+
border: "1px solid var(--border)", borderRadius: 99, padding: "5px 10px",
|
|
488
|
+
background: "transparent", color: "var(--primary)", fontSize: 12,
|
|
489
|
+
fontWeight: 500, fontFamily: "inherit", cursor: "pointer", maxWidth: 150,
|
|
490
|
+
}}>
|
|
491
|
+
<option value="">{L("Auto (first connected)", "Tự động (kết nối đầu tiên)")}</option>
|
|
492
|
+
{D.providers.map((p) => (
|
|
493
|
+
<option key={p.id} value={p.id} disabled={!providerAvailable(p.id)}>
|
|
494
|
+
{p.name}{providerAvailable(p.id) ? "" : " 🔒"}
|
|
495
|
+
</option>
|
|
496
|
+
))}
|
|
497
|
+
</select>
|
|
498
|
+
</div>
|
|
499
|
+
<SettingRow
|
|
500
|
+
label={L("Task routing", "Định tuyến tác vụ")}
|
|
501
|
+
desc={L("yamtam-rt classifier — local, before any provider call", "yamtam-rt classifier — chạy local, trước mọi lệnh gọi provider")}
|
|
502
|
+
value={L("simple · complex · external", "simple · complex · external")} />
|
|
503
|
+
<SettingRow label={L("Fallback chain", "Chuỗi dự phòng")}
|
|
504
|
+
desc={L("Connected providers, in order", "Các nhà cung cấp đã kết nối, theo thứ tự")}
|
|
505
|
+
value={chain} />
|
|
506
|
+
</Card>
|
|
507
|
+
<Card title={L("Safety", "Bảo mật")}>
|
|
508
|
+
<SettingRow
|
|
509
|
+
label={L("Gate mode", "Chế độ cổng")}
|
|
510
|
+
desc={L("Every agent action is reviewed", "Mọi hành động của tác nhân đều được xem xét")}
|
|
511
|
+
value={L("Strict · deny by default", "Nghiêm ngặt · từ chối mặc định")} />
|
|
512
|
+
<SettingRow
|
|
513
|
+
label={L("Audit events today", "Sự kiện audit hôm nay")}
|
|
514
|
+
desc={L("From the L0 hash-chained audit log", "Từ audit log băm chuỗi L0")}
|
|
515
|
+
value={dash ? String(dash.safety.events_today) : "…"} />
|
|
516
|
+
<SettingRow
|
|
517
|
+
label={L("Blocked today", "Đã chặn hôm nay")}
|
|
518
|
+
desc={dash && dash.safety.last_incident
|
|
519
|
+
? L("Last incident: ", "Sự cố gần nhất: ") + dash.safety.last_incident
|
|
520
|
+
: L("No incidents on record", "Chưa ghi nhận sự cố")}
|
|
521
|
+
value={dash ? String(dash.safety.blocked_today) : "…"} />
|
|
522
|
+
</Card>
|
|
523
|
+
<Card title={L("Memory", "Bộ nhớ")}>
|
|
524
|
+
<SettingRow
|
|
525
|
+
label={L("L1 atomic facts", "Fact L1")}
|
|
526
|
+
desc={L("Persisted in memory/L1_atomic", "Lưu tại memory/L1_atomic")}
|
|
527
|
+
value={dash ? String(dash.memories.total) : "…"} />
|
|
528
|
+
<SettingRow
|
|
529
|
+
label={L("Fresh today", "Mới hôm nay")}
|
|
530
|
+
value={dash ? String(dash.memories.today) : "…"} />
|
|
531
|
+
<SettingRow label={L("Storage", "Lưu trữ")}
|
|
532
|
+
desc={L("API keys AES-256-GCM encrypted at rest (rule 66)", "API key mã hóa AES-256-GCM khi lưu (rule 66)")}
|
|
533
|
+
value={L("Local · encrypted", "Cục bộ · mã hóa")} />
|
|
534
|
+
</Card>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
Object.assign(window, { Providers, Settings });
|