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/desktop/chat.jsx
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// Yana AI — Chat (orchestration-centric, not a chatbot clone)
|
|
2
|
+
|
|
3
|
+
// ── Rule 68 — Confidential Mode ───────────────────────────────────────────────
|
|
4
|
+
// Mirror of the canonical marker tables in src/route.rs / yamtam-core.
|
|
5
|
+
// confidential → never persisted, no about-context attached
|
|
6
|
+
// sovereign → additionally: local model (Ollama) only — text never
|
|
7
|
+
// leaves the machine
|
|
8
|
+
const SENS_SOVEREIGN = [
|
|
9
|
+
"chỉ mình anh biết", "chỉ anh biết", "chỉ riêng anh", "không ai được biết",
|
|
10
|
+
"sovereign only", "for my eyes only", "local model only", "chỉ model local",
|
|
11
|
+
"#sovereign",
|
|
12
|
+
];
|
|
13
|
+
const SENS_CONFIDENTIAL = [
|
|
14
|
+
"bí mật", "tuyệt mật", "confidential", "đừng ghi lại", "đừng lưu",
|
|
15
|
+
"không lưu lại", "không ghi lại", "không được lưu", "giữ kín",
|
|
16
|
+
"off the record", "do not log", "don't log", "do not save", "don't save",
|
|
17
|
+
"do not persist", "#mật", "#confidential", "#private",
|
|
18
|
+
];
|
|
19
|
+
const SENS_SMELLS = [
|
|
20
|
+
"mua công ty", "bán công ty", "thương vụ", "sáp nhập", "đàm phán",
|
|
21
|
+
"acquisition", "merger", "negotiation position", "lương của", "salary of",
|
|
22
|
+
"chẩn đoán", "diagnosis", "bệnh án", "health record", "kiện tụng", "lawsuit",
|
|
23
|
+
"chưa công bố", "chưa công khai", "unannounced",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function detectSensitivity(text) {
|
|
27
|
+
const lower = (text || "").toLowerCase();
|
|
28
|
+
if (SENS_SOVEREIGN.some((m) => lower.includes(m))) return "sovereign";
|
|
29
|
+
if (SENS_CONFIDENTIAL.some((m) => lower.includes(m))) return "confidential";
|
|
30
|
+
if (SENS_SMELLS.some((m) => lower.includes(m))) return "confidential";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Providers usable without an API key (loopback/on-device)
|
|
35
|
+
const KEYLESS_PROVIDERS = new Set(["ollama"]);
|
|
36
|
+
function providerAvailable(id) {
|
|
37
|
+
return KEYLESS_PROVIDERS.has(id) || YanaVault.hasKey(id);
|
|
38
|
+
}
|
|
39
|
+
window.providerAvailable = providerAvailable;
|
|
40
|
+
window.KEYLESS_PROVIDERS = KEYLESS_PROVIDERS;
|
|
41
|
+
|
|
42
|
+
function ConfidentialBadge({ tier }) {
|
|
43
|
+
return (
|
|
44
|
+
<span className="chip neutral" style={{ fontSize: 10.5, marginTop: 5, display: "inline-flex", alignItems: "center", gap: 4 }}
|
|
45
|
+
title={L("Rule 68 — this message is never saved to history, memory, or missions.",
|
|
46
|
+
"Rule 68 — tin nhắn này không bao giờ được lưu vào lịch sử, ký ức hay mission.")}>
|
|
47
|
+
🔒 {tier === "sovereign"
|
|
48
|
+
? L("Sovereign · local only · not saved", "Sovereign · chỉ local · không lưu")
|
|
49
|
+
: L("Confidential · not saved", "Mật · không lưu")}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function RouteChip({ route }) {
|
|
55
|
+
return (
|
|
56
|
+
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 7 }}>
|
|
57
|
+
<YanaMark size={20} />
|
|
58
|
+
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
|
59
|
+
via <b style={{ fontWeight: 500, color: "var(--ink-2)" }}>{route.agent}</b> · {route.model}
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Message({ msg }) {
|
|
66
|
+
if (msg.who === "user") {
|
|
67
|
+
return (
|
|
68
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
|
69
|
+
<div style={{
|
|
70
|
+
maxWidth: "72%", padding: "10px 15px", borderRadius: "16px 16px 4px 16px",
|
|
71
|
+
background: "var(--primary)", color: "rgba(255,255,255,.96)",
|
|
72
|
+
fontSize: 13.8, lineHeight: 1.55,
|
|
73
|
+
boxShadow: "0 4px 14px color-mix(in oklab, var(--primary) 25%, transparent)",
|
|
74
|
+
...(msg.confidential ? { border: "1px dashed rgba(255,255,255,.55)" } : {}),
|
|
75
|
+
}}>{msg.text}</div>
|
|
76
|
+
{msg.confidential && <ConfidentialBadge tier={msg.tier} />}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<div style={{ display: "flex", justifyContent: "flex-start" }}>
|
|
82
|
+
<div style={{ maxWidth: "82%" }}>
|
|
83
|
+
{msg.route && <RouteChip route={msg.route} />}
|
|
84
|
+
<div className="glass" style={{ padding: "12px 16px", borderRadius: "4px 16px 16px 16px", fontSize: 13.8, lineHeight: 1.6, color: "var(--ink)" }}>
|
|
85
|
+
{msg.text}
|
|
86
|
+
{msg.action && (
|
|
87
|
+
<div style={{
|
|
88
|
+
marginTop: 11, padding: "9px 12px", borderRadius: "var(--r-sm)",
|
|
89
|
+
background: "var(--primary-soft)", display: "flex", alignItems: "center", gap: 9,
|
|
90
|
+
}}>
|
|
91
|
+
<span style={{ color: "var(--primary)" }}>{Icons.safety(15)}</span>
|
|
92
|
+
<div style={{ lineHeight: 1.3 }}>
|
|
93
|
+
<div style={{ fontSize: 12.5, fontWeight: 500, color: "var(--primary)" }}>{msg.action.label}</div>
|
|
94
|
+
<div style={{ fontSize: 11.5, color: "var(--ink-3)" }}>{msg.action.state}</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
{msg.refs && (
|
|
100
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 7 }}>
|
|
101
|
+
{msg.refs.map((r) => <span key={r} className="chip neutral" style={{ fontSize: 11 }}>{r}</span>)}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default model per provider — mirrors PROVIDERS defaults in server.js
|
|
110
|
+
const CHAT_MODELS = {
|
|
111
|
+
claude: "claude-sonnet-4-6",
|
|
112
|
+
openai: "gpt-4o-mini",
|
|
113
|
+
gemini: "gemini-2.0-flash",
|
|
114
|
+
groq: "llama-3.3-70b-versatile",
|
|
115
|
+
deepseek: "deepseek-chat",
|
|
116
|
+
openrouter: "google/gemma-3-27b-it",
|
|
117
|
+
"9router": "kr/claude-sonnet-4.5",
|
|
118
|
+
ollama: "llama3.2",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Curated model choices per provider — providers in CHAT_LIVE_MODELS get the
|
|
122
|
+
// real list fetched from /api/models when a key is available.
|
|
123
|
+
const MODEL_CHOICES = {
|
|
124
|
+
claude: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"],
|
|
125
|
+
openai: ["gpt-4o-mini", "gpt-4o"],
|
|
126
|
+
gemini: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
|
127
|
+
deepseek: ["deepseek-chat", "deepseek-reasoner"],
|
|
128
|
+
groq: ["llama-3.3-70b-versatile"],
|
|
129
|
+
openrouter: ["google/gemma-3-27b-it"],
|
|
130
|
+
"9router": ["kr/claude-sonnet-4.5"],
|
|
131
|
+
ollama: ["llama3.2"],
|
|
132
|
+
};
|
|
133
|
+
const CHAT_LIVE_MODELS = new Set(["groq", "openrouter", "9router", "ollama"]);
|
|
134
|
+
|
|
135
|
+
const MODEL_STORE = "yana.chat.models"; // { providerId: modelId } — persisted
|
|
136
|
+
|
|
137
|
+
function loadModelChoices() {
|
|
138
|
+
try {
|
|
139
|
+
const saved = JSON.parse(localStorage.getItem(MODEL_STORE));
|
|
140
|
+
if (saved && typeof saved === "object") return saved;
|
|
141
|
+
} catch (_) {}
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ContextPanel() {
|
|
146
|
+
const D = window.YANA;
|
|
147
|
+
const [facts, setFacts] = React.useState(null);
|
|
148
|
+
|
|
149
|
+
React.useEffect(() => {
|
|
150
|
+
fetch("/api/dashboard")
|
|
151
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
152
|
+
.then((d) => { if (d) setFacts(d.memories.recent); })
|
|
153
|
+
.catch(() => {});
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Real routing: providers that actually have a key, in send order
|
|
157
|
+
const keyed = D.providers.filter((p) => YanaVault.hasKey(p.id));
|
|
158
|
+
const primary = keyed[0];
|
|
159
|
+
const fallback = keyed[1];
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<aside className="yana-chat-aside" style={{ display: "flex", flexDirection: "column", gap: "var(--gap)", overflowY: "auto" }}>
|
|
163
|
+
<Card title={L("Routing", "Định tuyến")}>
|
|
164
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
|
165
|
+
{[
|
|
166
|
+
[L("Provider", "Nhà cung cấp"), primary ? primary.name : L("None — add a key", "Chưa có key")],
|
|
167
|
+
[L("Model", "Mô hình"), primary ? (loadModelChoices()[primary.id] || CHAT_MODELS[primary.id] || "—") : "—"],
|
|
168
|
+
[L("Fallback", "Dự phòng"), fallback ? fallback.name : "—"],
|
|
169
|
+
[L("Connected", "Đã kết nối"), keyed.length + " / " + D.providers.length],
|
|
170
|
+
].map(([k, v]) => (
|
|
171
|
+
<div key={k} style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}>
|
|
172
|
+
<span style={{ color: "var(--ink-3)", flex: "none" }}>{k}</span>
|
|
173
|
+
<span style={{ fontWeight: 500, textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{v}</span>
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</Card>
|
|
178
|
+
<Card title={L("Context in use", "Ngữ cảnh đang dùng")}>
|
|
179
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
180
|
+
{facts && facts.length
|
|
181
|
+
? facts.map((m, i) => (
|
|
182
|
+
<div key={i} style={{ fontSize: 12, color: "var(--ink-2)", lineHeight: 1.45, display: "flex", gap: 7 }}>
|
|
183
|
+
<span style={{ color: "var(--pink)", flex: "none", marginTop: 1 }}>{Icons.memory(13)}</span>
|
|
184
|
+
{m.text}
|
|
185
|
+
</div>
|
|
186
|
+
))
|
|
187
|
+
: <span style={{ fontSize: 12, color: "var(--ink-3)" }}>{L("No memories yet.", "Chưa có ký ức nào.")}</span>}
|
|
188
|
+
</div>
|
|
189
|
+
</Card>
|
|
190
|
+
<Card title={L("Safety", "An toàn")}>
|
|
191
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
192
|
+
<span className="dot on"></span>
|
|
193
|
+
<span style={{ fontSize: 12.5, color: "var(--ink-2)" }}>{L("Sentinel reviewing all actions", "Sentinel đang giám sát mọi hành động")}</span>
|
|
194
|
+
</div>
|
|
195
|
+
</Card>
|
|
196
|
+
</aside>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Find the first provider that has a stored API key in the encrypted vault
|
|
201
|
+
function getProviderConfig(preferred) {
|
|
202
|
+
const order = ["claude", "openai", "gemini", "groq", "deepseek", "openrouter"];
|
|
203
|
+
if (preferred && KEYLESS_PROVIDERS.has(preferred)) {
|
|
204
|
+
return { provider: preferred, apiKey: "" };
|
|
205
|
+
}
|
|
206
|
+
if (preferred && YanaVault.hasKey(preferred)) {
|
|
207
|
+
return { provider: preferred, apiKey: YanaVault.getKey(preferred) };
|
|
208
|
+
}
|
|
209
|
+
for (const id of order) {
|
|
210
|
+
const key = YanaVault.getKey(id);
|
|
211
|
+
if (key) return { provider: id, apiKey: key };
|
|
212
|
+
}
|
|
213
|
+
return { provider: "claude", apiKey: "" };
|
|
214
|
+
}
|
|
215
|
+
window.getProviderConfig = getProviderConfig;
|
|
216
|
+
|
|
217
|
+
// "About you" from Settings — sent with every chat so Yana knows the user
|
|
218
|
+
function aboutContext() {
|
|
219
|
+
const parts = [];
|
|
220
|
+
for (const [id, label] of [["who", "Who"], ["strengths", "Strengths"], ["weaknesses", "Weak spots"], ["style", "Response style"]]) {
|
|
221
|
+
const v = localStorage.getItem("yana.about." + id);
|
|
222
|
+
if (v && v.trim()) parts.push(label + ": " + v.trim());
|
|
223
|
+
}
|
|
224
|
+
return parts.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const CHAT_STORE = "yana.chat";
|
|
228
|
+
|
|
229
|
+
function loadChatHistory() {
|
|
230
|
+
try {
|
|
231
|
+
const saved = JSON.parse(localStorage.getItem(CHAT_STORE));
|
|
232
|
+
if (Array.isArray(saved)) return saved;
|
|
233
|
+
} catch (_) {}
|
|
234
|
+
return window.YANA.chat;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function Chat({ t }) {
|
|
238
|
+
const D = window.YANA;
|
|
239
|
+
const [msgs, setMsgs] = React.useState(loadChatHistory);
|
|
240
|
+
const [draft, setDraft] = React.useState("");
|
|
241
|
+
const [thinking, setThinking] = React.useState(false);
|
|
242
|
+
const [providerSel, setProviderSel] = React.useState(() => localStorage.getItem("yana.chat.provider") || "");
|
|
243
|
+
// Rule 68 — manual Confidential Mode: everything sent while on is treated
|
|
244
|
+
// as confidential even without a marker. Never persisted itself.
|
|
245
|
+
const [confMode, setConfMode] = React.useState(false);
|
|
246
|
+
// Model per provider — persisted; live lists fetched for CHAT_LIVE_MODELS
|
|
247
|
+
const [modelSel, setModelSel] = React.useState(loadModelChoices);
|
|
248
|
+
const [liveModels, setLiveModels] = React.useState({}); // providerId -> [ids]
|
|
249
|
+
const logRef = React.useRef(null);
|
|
250
|
+
const readerRef = React.useRef(null);
|
|
251
|
+
|
|
252
|
+
const activeProvider = providerSel || getProviderConfig().provider;
|
|
253
|
+
const modelOptions = liveModels[activeProvider] || MODEL_CHOICES[activeProvider] || [];
|
|
254
|
+
const activeModel = modelSel[activeProvider] || CHAT_MODELS[activeProvider] || (modelOptions[0] || "");
|
|
255
|
+
|
|
256
|
+
function pickModel(v) {
|
|
257
|
+
setModelSel((prev) => {
|
|
258
|
+
const next = { ...prev, [activeProvider]: v };
|
|
259
|
+
try { localStorage.setItem(MODEL_STORE, JSON.stringify(next)); } catch (_) {}
|
|
260
|
+
return next;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fetch the real model list when the provider supports it and is usable
|
|
265
|
+
React.useEffect(() => {
|
|
266
|
+
const id = activeProvider;
|
|
267
|
+
if (!CHAT_LIVE_MODELS.has(id) || !providerAvailable(id) || liveModels[id]) return;
|
|
268
|
+
fetch("/api/models", {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: { "Content-Type": "application/json" },
|
|
271
|
+
body: JSON.stringify({ provider: id, key: YanaVault.getKey(id) || "" }),
|
|
272
|
+
})
|
|
273
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
274
|
+
.then((d) => {
|
|
275
|
+
if (d && Array.isArray(d.models) && d.models.length) {
|
|
276
|
+
setLiveModels((m) => ({ ...m, [id]: d.models.slice(0, 60).map((x) => x.id) }));
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
.catch(() => {});
|
|
280
|
+
}, [activeProvider]);
|
|
281
|
+
|
|
282
|
+
React.useEffect(() => {
|
|
283
|
+
const el = logRef.current;
|
|
284
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
285
|
+
}, [msgs, thinking]);
|
|
286
|
+
|
|
287
|
+
// Persist the real conversation: across navigation AND reloads (last 60 turns).
|
|
288
|
+
// Confidential turns live in memory only (rule 68) — they survive navigation
|
|
289
|
+
// within this session via D.chat but are never written to localStorage.
|
|
290
|
+
React.useEffect(() => {
|
|
291
|
+
D.chat = msgs;
|
|
292
|
+
try {
|
|
293
|
+
localStorage.setItem(CHAT_STORE, JSON.stringify(msgs.filter((m) => !m.confidential).slice(-60)));
|
|
294
|
+
} catch (_) {}
|
|
295
|
+
}, [msgs]);
|
|
296
|
+
React.useEffect(() => { localStorage.setItem("yana.chat.provider", providerSel); }, [providerSel]);
|
|
297
|
+
|
|
298
|
+
// Cancel any in-flight stream on unmount
|
|
299
|
+
React.useEffect(() => {
|
|
300
|
+
return () => { if (readerRef.current) readerRef.current.cancel(); };
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
async function send() {
|
|
304
|
+
const text = draft.trim();
|
|
305
|
+
if (!text || thinking) return;
|
|
306
|
+
|
|
307
|
+
// Rule 68 — classify before the first byte leaves this page
|
|
308
|
+
const detected = detectSensitivity(text);
|
|
309
|
+
const tier = detected === "sovereign" ? "sovereign"
|
|
310
|
+
: (detected || confMode) ? "confidential"
|
|
311
|
+
: null;
|
|
312
|
+
|
|
313
|
+
setMsgs((m) => [...m, { who: "user", text, confidential: !!tier, tier }]);
|
|
314
|
+
setDraft("");
|
|
315
|
+
setThinking(true);
|
|
316
|
+
|
|
317
|
+
// Sovereign: local model only — never a cloud provider
|
|
318
|
+
let { provider, apiKey } = getProviderConfig(providerSel);
|
|
319
|
+
if (tier === "sovereign") { provider = "ollama"; apiKey = ""; }
|
|
320
|
+
const model = modelSel[provider] || CHAT_MODELS[provider] || "";
|
|
321
|
+
|
|
322
|
+
// Real routing: classify the task so complex requests pick up a skill.
|
|
323
|
+
// Skipped for confidential turns — need-to-know, no extra processing.
|
|
324
|
+
let skill = null;
|
|
325
|
+
if (!tier) {
|
|
326
|
+
try {
|
|
327
|
+
const rr = await fetch("/api/route", {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: { "Content-Type": "application/json" },
|
|
330
|
+
body: JSON.stringify({ task: text }),
|
|
331
|
+
});
|
|
332
|
+
if (rr.ok) { const d = await rr.json(); skill = d.suggested_skill || null; }
|
|
333
|
+
} catch (_) {}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch("/api/chat", {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "Content-Type": "application/json" },
|
|
340
|
+
body: JSON.stringify(tier
|
|
341
|
+
? { task: text, apiKey, provider, model, sensitivity: tier }
|
|
342
|
+
: { task: text, apiKey, provider, model, skill, about: aboutContext() }),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!res.ok || !res.body) {
|
|
346
|
+
throw new Error("HTTP " + res.status);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const reader = res.body.getReader();
|
|
350
|
+
readerRef.current = reader;
|
|
351
|
+
const decoder = new TextDecoder();
|
|
352
|
+
let buf = "";
|
|
353
|
+
let accumulated = "";
|
|
354
|
+
const msgId = Date.now();
|
|
355
|
+
|
|
356
|
+
// Insert placeholder Yana message — route shows the real provider/model/skill
|
|
357
|
+
setMsgs((m) => [...m, {
|
|
358
|
+
who: "yana",
|
|
359
|
+
route: {
|
|
360
|
+
agent: provider,
|
|
361
|
+
model: (model || provider)
|
|
362
|
+
+ (skill ? " · " + skill : "")
|
|
363
|
+
+ (tier ? " · 🔒 " + (tier === "sovereign" ? "local-only" : "no-persist") : ""),
|
|
364
|
+
},
|
|
365
|
+
text: "",
|
|
366
|
+
confidential: !!tier,
|
|
367
|
+
tier,
|
|
368
|
+
_id: msgId,
|
|
369
|
+
}]);
|
|
370
|
+
setThinking(false);
|
|
371
|
+
|
|
372
|
+
while (true) {
|
|
373
|
+
const { done, value } = await reader.read();
|
|
374
|
+
if (done) break;
|
|
375
|
+
buf += decoder.decode(value, { stream: true });
|
|
376
|
+
const lines = buf.split("\n");
|
|
377
|
+
buf = lines.pop();
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
if (!line.startsWith("data: ")) continue;
|
|
380
|
+
const payload = line.slice(6).trim();
|
|
381
|
+
if (payload === "[DONE]") break;
|
|
382
|
+
try {
|
|
383
|
+
const obj = JSON.parse(payload);
|
|
384
|
+
if (obj.error) {
|
|
385
|
+
accumulated += "\n[Error: " + obj.error + "]";
|
|
386
|
+
} else if (obj.text) {
|
|
387
|
+
accumulated += obj.text;
|
|
388
|
+
}
|
|
389
|
+
const snap = accumulated;
|
|
390
|
+
setMsgs((m) => m.map((msg) =>
|
|
391
|
+
msg._id === msgId ? { ...msg, text: snap } : msg
|
|
392
|
+
));
|
|
393
|
+
} catch (_) {}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ChatGPT-style memory: the model nominates a durable fact with a
|
|
398
|
+
// trailing "MEMORY: …" line — strip it from the display and persist
|
|
399
|
+
// it server-side. Confidential turns never reach this branch with a
|
|
400
|
+
// marker (the server attaches no memory instruction), but gate anyway.
|
|
401
|
+
if (!tier) {
|
|
402
|
+
const mm = accumulated.match(/(?:^|\n)\s*MEMORY:\s*(.+?)\s*$/);
|
|
403
|
+
if (mm && mm[1]) {
|
|
404
|
+
const fact = mm[1];
|
|
405
|
+
const shown = accumulated.slice(0, mm.index).trimEnd();
|
|
406
|
+
setMsgs((m) => m.map((msg) =>
|
|
407
|
+
msg._id === msgId
|
|
408
|
+
? { ...msg, text: shown || msg.text, refs: [...(msg.refs || []), L("🌱 Remembered: ", "🌱 Đã nhớ: ") + fact] }
|
|
409
|
+
: msg
|
|
410
|
+
));
|
|
411
|
+
fetch("/api/memory", {
|
|
412
|
+
method: "POST",
|
|
413
|
+
headers: { "Content-Type": "application/json" },
|
|
414
|
+
body: JSON.stringify({ text: fact }),
|
|
415
|
+
}).catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
setThinking(false);
|
|
420
|
+
setMsgs((m) => [...m, {
|
|
421
|
+
who: "yana",
|
|
422
|
+
route: { agent: provider, model: model || provider },
|
|
423
|
+
confidential: !!tier,
|
|
424
|
+
tier,
|
|
425
|
+
text: tier === "sovereign"
|
|
426
|
+
? L("Could not reach the local model. SOVEREIGN content only goes to Ollama (127.0.0.1:11434) — start it with `ollama serve`.",
|
|
427
|
+
"Không kết nối được model local. Nội dung SOVEREIGN chỉ đi đến Ollama (127.0.0.1:11434) — chạy `ollama serve` trước.")
|
|
428
|
+
: L("Could not reach the server. Check that Yana is running and a provider key is set.",
|
|
429
|
+
"Không kết nối được máy chủ. Kiểm tra Yana đang chạy và đã đặt API key."),
|
|
430
|
+
}]);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div data-screen-label="Chat" style={{ display: "flex", gap: "var(--gap)", height: "100%", minHeight: 0 }}>
|
|
436
|
+
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
|
437
|
+
<PageHeader title={L("Conversation", "Trò chuyện")} sub={L("One conversation, many hands — Yana routes each request to the right agent.", "Một cuộc trò chuyện, nhiều bàn tay — Yana chuyển mỗi yêu cầu đến đúng tác nhân.")} />
|
|
438
|
+
<div ref={logRef} style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: "calc(16px * var(--sp))", padding: "4px 4px 16px", minHeight: 0 }}>
|
|
439
|
+
{msgs.length === 0 && !thinking && (
|
|
440
|
+
<div style={{ margin: "auto", textAlign: "center", color: "var(--ink-3)", maxWidth: 380 }}>
|
|
441
|
+
<YanaMark size={34} />
|
|
442
|
+
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--ink-2)", marginTop: 12 }}>
|
|
443
|
+
{L("Start a conversation", "Bắt đầu trò chuyện")}
|
|
444
|
+
</div>
|
|
445
|
+
<div style={{ fontSize: 12.5, lineHeight: 1.55, marginTop: 6 }}>
|
|
446
|
+
{getProviderConfig().apiKey
|
|
447
|
+
? L("Yana routes your request to the connected provider and streams the answer here.",
|
|
448
|
+
"Yana chuyển yêu cầu của bạn đến nhà cung cấp đã kết nối và trả lời tại đây.")
|
|
449
|
+
: L("No provider key set — add one in Providers first.",
|
|
450
|
+
"Chưa có API key — thêm key ở mục Nhà cung cấp trước.")}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
{msgs.map((m, i) => <Message key={m._id || i} msg={m} />)}
|
|
455
|
+
{thinking && (
|
|
456
|
+
<div style={{ display: "flex", alignItems: "center", gap: 9, color: "var(--ink-3)", fontSize: 12.5 }}>
|
|
457
|
+
<YanaMark size={20} /> {L("Navigator is thinking…", "Navigator đang suy nghĩ…")}
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
<div className="glass-strong" style={{ borderRadius: "var(--r-lg)", padding: "10px 10px 10px 16px", display: "flex", alignItems: "center", gap: 12 }}>
|
|
462
|
+
<input
|
|
463
|
+
value={draft}
|
|
464
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
465
|
+
onKeyDown={(e) => { if (e.key === "Enter") send(); }}
|
|
466
|
+
placeholder={L("Ask Yana, or give the system a direction…", "Hỏi Yana, hoặc giao cho hệ thống một hướng đi…")}
|
|
467
|
+
style={{ flex: 1, border: "none", outline: "none", background: "transparent", fontSize: 14, fontFamily: "inherit", color: "var(--ink)" }}
|
|
468
|
+
/>
|
|
469
|
+
<button
|
|
470
|
+
onClick={() => setConfMode((v) => !v)}
|
|
471
|
+
aria-pressed={confMode}
|
|
472
|
+
title={confMode
|
|
473
|
+
? L("Confidential Mode ON — messages are not saved and carry no personal context (rule 68). Click to turn off.",
|
|
474
|
+
"Chế độ Mật BẬT — tin nhắn không được lưu, không kèm ngữ cảnh cá nhân (rule 68). Bấm để tắt.")
|
|
475
|
+
: L("Turn on Confidential Mode — nothing you send is saved to history, memory, or missions.",
|
|
476
|
+
"Bật chế độ Mật — mọi thứ anh gửi sẽ không được lưu vào lịch sử, ký ức hay mission.")}
|
|
477
|
+
style={{
|
|
478
|
+
border: "1px solid " + (confMode ? "var(--primary)" : "var(--border)"),
|
|
479
|
+
borderRadius: 99, padding: "5px 10px", cursor: "pointer", fontSize: 11.5,
|
|
480
|
+
fontFamily: "inherit",
|
|
481
|
+
background: confMode ? "var(--primary-soft)" : "transparent",
|
|
482
|
+
color: confMode ? "var(--primary)" : "var(--ink-3)",
|
|
483
|
+
}}>
|
|
484
|
+
🔒{confMode ? " " + L("Confidential", "Mật") : ""}
|
|
485
|
+
</button>
|
|
486
|
+
<select value={providerSel || getProviderConfig().provider}
|
|
487
|
+
onChange={(e) => setProviderSel(e.target.value)}
|
|
488
|
+
title={L("Provider for this conversation", "Nhà cung cấp cho cuộc trò chuyện")}
|
|
489
|
+
style={{
|
|
490
|
+
border: "1px solid var(--border)", borderRadius: 99, padding: "5px 9px",
|
|
491
|
+
background: "transparent", color: "var(--ink-2)", fontSize: 11.5,
|
|
492
|
+
fontFamily: "inherit", cursor: "pointer", maxWidth: 120,
|
|
493
|
+
}}>
|
|
494
|
+
{D.providers.map((p) => (
|
|
495
|
+
<option key={p.id} value={p.id} disabled={!providerAvailable(p.id)}>
|
|
496
|
+
{p.name}{providerAvailable(p.id) ? "" : " 🔒"}
|
|
497
|
+
</option>
|
|
498
|
+
))}
|
|
499
|
+
</select>
|
|
500
|
+
<select value={activeModel} onChange={(e) => pickModel(e.target.value)}
|
|
501
|
+
title={L("Model for this provider — choice is remembered", "Model cho nhà cung cấp này — lựa chọn được ghi nhớ")}
|
|
502
|
+
style={{
|
|
503
|
+
border: "1px solid var(--border)", borderRadius: 99, padding: "5px 9px",
|
|
504
|
+
background: "transparent", color: "var(--ink-2)", fontSize: 11.5,
|
|
505
|
+
fontFamily: "inherit", cursor: "pointer", maxWidth: 150,
|
|
506
|
+
}}>
|
|
507
|
+
{(modelOptions.includes(activeModel) ? modelOptions : [activeModel, ...modelOptions]).map((m) => (
|
|
508
|
+
<option key={m} value={m}>{m}</option>
|
|
509
|
+
))}
|
|
510
|
+
</select>
|
|
511
|
+
<span className="chip neutral" style={{ fontSize: 11.5 }}>{Icons.safety(12)} {L("Sentinel on", "Sentinel bật")}</span>
|
|
512
|
+
<button onClick={send} aria-label="Send" style={{
|
|
513
|
+
width: 36, height: 36, borderRadius: 11, border: "none", cursor: "pointer",
|
|
514
|
+
background: "var(--primary)", color: "white", display: "grid", placeItems: "center",
|
|
515
|
+
boxShadow: "0 4px 12px color-mix(in oklab, var(--primary) 30%, transparent)",
|
|
516
|
+
}}>{Icons.send(16)}</button>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
<ContextPanel />
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
window.Chat = Chat;
|