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/missions.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Yana Missions — file-backed mission store (.yana/missions.json).
|
|
3
|
+
//
|
|
4
|
+
// A mission is created from a goal, classified by the YAMTAM router
|
|
5
|
+
// (simple/complex/external → owner + starter tasks), then planned in detail
|
|
6
|
+
// by the LLM from the UI ("Plan with Yana" calls /api/chat and PATCHes the
|
|
7
|
+
// task list back here). Progress is always computed from task states —
|
|
8
|
+
// never stored, so it can't drift.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { classifySensitivity } = require('yamtam-core');
|
|
13
|
+
|
|
14
|
+
// Same persistent data dir as auth.js — YANA_DATA_DIR points at a mounted
|
|
15
|
+
// volume (e.g. /data on Railway) so missions survive redeploys.
|
|
16
|
+
const DATA_DIR = process.env.YANA_DATA_DIR || path.join(require('os').homedir(), '.yana');
|
|
17
|
+
const FILE = path.join(DATA_DIR, 'missions.json');
|
|
18
|
+
const MAX_MISSIONS = 200;
|
|
19
|
+
const MAX_TASKS = 30;
|
|
20
|
+
|
|
21
|
+
function load() {
|
|
22
|
+
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function save(missions) {
|
|
26
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
27
|
+
fs.writeFileSync(FILE, JSON.stringify(missions, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function json(res, status, obj) {
|
|
31
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
32
|
+
res.end(JSON.stringify(obj));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function progress(m) {
|
|
36
|
+
if (!m.tasks.length) return 0;
|
|
37
|
+
const done = m.tasks.filter(t => t.state === 'done').length;
|
|
38
|
+
return Math.round((done / m.tasks.length) * 100);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withProgress(m) {
|
|
42
|
+
return { ...m, progress: progress(m) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeTasks(tasks) {
|
|
46
|
+
if (!Array.isArray(tasks)) return null;
|
|
47
|
+
const STATES = new Set(['queued', 'active', 'done']);
|
|
48
|
+
return tasks.slice(0, MAX_TASKS).map(t => ({
|
|
49
|
+
name: String(t.name || '').slice(0, 200),
|
|
50
|
+
agent: String(t.agent || 'Navigator').slice(0, 60),
|
|
51
|
+
state: STATES.has(t.state) ? t.state : 'queued',
|
|
52
|
+
})).filter(t => t.name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Handlers (routeFn = yamtam-core route(), injected by server.js) ──────────
|
|
56
|
+
function handleList(req, res) {
|
|
57
|
+
json(res, 200, { missions: load().map(withProgress) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleCreate(req, res, body, routeFn) {
|
|
61
|
+
const name = body && typeof body.name === 'string' ? body.name.trim().slice(0, 200) : '';
|
|
62
|
+
if (!name) { json(res, 400, { error: 'Missing mission name' }); return; }
|
|
63
|
+
|
|
64
|
+
// Rule 68 — missions are persisted to disk; confidential content may not
|
|
65
|
+
// cross that boundary. Checked here directly (not via routeFn) so an old
|
|
66
|
+
// yamtam-rt binary without sensitivity fields can't sneak one through.
|
|
67
|
+
const sens = classifySensitivity(name);
|
|
68
|
+
if (sens.sensitivity === 'confidential' || sens.sensitivity === 'sovereign') {
|
|
69
|
+
json(res, 403, {
|
|
70
|
+
error: 'Confidential content cannot be stored in missions (rule 68). ' +
|
|
71
|
+
'Remove the confidential marker or keep this in chat with Confidential Mode on.',
|
|
72
|
+
sensitivity: sens.sensitivity,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const missions = load();
|
|
78
|
+
if (missions.length >= MAX_MISSIONS) { json(res, 409, { error: 'Mission limit reached' }); return; }
|
|
79
|
+
|
|
80
|
+
// Classify the goal so the mission starts with an honest route decision
|
|
81
|
+
let decision = null;
|
|
82
|
+
try { decision = await routeFn(name); } catch (_) {}
|
|
83
|
+
|
|
84
|
+
const mission = {
|
|
85
|
+
id: 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
86
|
+
name,
|
|
87
|
+
owner: 'Navigator',
|
|
88
|
+
status: 'planning',
|
|
89
|
+
route: decision ? decision.route : null,
|
|
90
|
+
skill: (decision && decision.suggested_skill) || null,
|
|
91
|
+
created: new Date().toISOString(),
|
|
92
|
+
tasks: [
|
|
93
|
+
{ name: 'Understand the goal', agent: 'Navigator', state: 'done' },
|
|
94
|
+
{ name: 'Plan tasks (use "Plan with Yana" or add your own)', agent: 'Navigator', state: 'active' },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
missions.unshift(mission);
|
|
98
|
+
save(missions);
|
|
99
|
+
json(res, 200, { mission: withProgress(mission) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleUpdate(req, res, body) {
|
|
103
|
+
const id = body && body.id;
|
|
104
|
+
if (!id) { json(res, 400, { error: 'Missing id' }); return; }
|
|
105
|
+
|
|
106
|
+
const missions = load();
|
|
107
|
+
const m = missions.find(x => x.id === id);
|
|
108
|
+
if (!m) { json(res, 404, { error: 'Mission not found' }); return; }
|
|
109
|
+
|
|
110
|
+
if (typeof body.name === 'string' && body.name.trim()) {
|
|
111
|
+
const sens = classifySensitivity(body.name);
|
|
112
|
+
if (sens.sensitivity === 'confidential' || sens.sensitivity === 'sovereign') {
|
|
113
|
+
json(res, 403, { error: 'Confidential content cannot be stored in missions (rule 68)', sensitivity: sens.sensitivity });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
m.name = body.name.trim().slice(0, 200);
|
|
117
|
+
}
|
|
118
|
+
if (['planning', 'active', 'done'].includes(body.status)) m.status = body.status;
|
|
119
|
+
const tasks = sanitizeTasks(body.tasks);
|
|
120
|
+
if (tasks && tasks.length) m.tasks = tasks;
|
|
121
|
+
|
|
122
|
+
// All tasks done → mission done; any active task on a done mission → active
|
|
123
|
+
if (m.tasks.length && m.tasks.every(t => t.state === 'done')) m.status = 'done';
|
|
124
|
+
else if (m.status === 'done') m.status = 'active';
|
|
125
|
+
|
|
126
|
+
save(missions);
|
|
127
|
+
json(res, 200, { mission: withProgress(m) });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleDelete(req, res, body) {
|
|
131
|
+
const id = body && body.id;
|
|
132
|
+
if (!id) { json(res, 400, { error: 'Missing id' }); return; }
|
|
133
|
+
const missions = load();
|
|
134
|
+
const next = missions.filter(x => x.id !== id);
|
|
135
|
+
if (next.length === missions.length) { json(res, 404, { error: 'Mission not found' }); return; }
|
|
136
|
+
save(next);
|
|
137
|
+
json(res, 200, { ok: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { handleList, handleCreate, handleUpdate, handleDelete };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="jade">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<link rel="icon" type="image/png" href="/logo.png" />
|
|
7
|
+
<title>Yana — Mobile</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
|
|
9
|
+
<link href="https://fonts.bunny.net/css?family=be-vietnam-pro:300,400,500,600&display=swap" rel="stylesheet" />
|
|
10
|
+
<link rel="stylesheet" href="themes.css" />
|
|
11
|
+
<link rel="stylesheet" href="mobile.css" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
16
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
17
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
18
|
+
<!-- Flag before data.js: enables the mobile hydration block (window.YANA fields) -->
|
|
19
|
+
<script>window.YANA_MOBILE = true;</script>
|
|
20
|
+
<script src="/shared/data.js"></script>
|
|
21
|
+
<!-- Encrypted vault — same module as desktop so API keys survive provider changes -->
|
|
22
|
+
<script src="/shared/crypto-store.js"></script>
|
|
23
|
+
<!-- Shared with desktop: tweaks persist to localStorage (yana.tweaks) -->
|
|
24
|
+
<script type="text/babel" src="/shared/tweaks-panel.jsx"></script>
|
|
25
|
+
<!-- Mobile shell + screens in dependency order -->
|
|
26
|
+
<script type="text/babel" src="m-shell.jsx"></script>
|
|
27
|
+
<script type="text/babel" src="m-lake.jsx"></script>
|
|
28
|
+
<script type="text/babel" src="m-missions.jsx"></script>
|
|
29
|
+
<script type="text/babel" src="m-chat.jsx"></script>
|
|
30
|
+
<script type="text/babel" src="m-garden.jsx"></script>
|
|
31
|
+
<script type="text/babel" src="m-system.jsx"></script>
|
|
32
|
+
<script type="text/babel" src="m-app.jsx"></script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
package/mobile/m-app.jsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Yana Mobile — app shell, routing, tweaks wiring, language toggle
|
|
2
|
+
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
3
|
+
"theme": "Jade Lake 🌿",
|
|
4
|
+
"language": "English",
|
|
5
|
+
"blur": 70,
|
|
6
|
+
"transparency": 60,
|
|
7
|
+
"reflection": 70,
|
|
8
|
+
"depth": 55,
|
|
9
|
+
"layout": "Regular",
|
|
10
|
+
"showAgents": true,
|
|
11
|
+
"showMissions": true,
|
|
12
|
+
"showMemory": true,
|
|
13
|
+
"showSystem": true,
|
|
14
|
+
"accent": ""
|
|
15
|
+
}/*EDITMODE-END*/;
|
|
16
|
+
|
|
17
|
+
const THEME_MAP = {
|
|
18
|
+
"Lotus Dawn 🌸": "dawn",
|
|
19
|
+
"Jade Lake 🌿": "jade",
|
|
20
|
+
"Morning Mist ☁️": "mist",
|
|
21
|
+
"Glass Silver ✨": "silver",
|
|
22
|
+
};
|
|
23
|
+
const DENSITY = { "Compact": 0.85, "Regular": 1, "Spacious": 1.18 };
|
|
24
|
+
|
|
25
|
+
function applyTweaks(t) {
|
|
26
|
+
const root = document.documentElement;
|
|
27
|
+
root.setAttribute("data-theme", THEME_MAP[t.theme] || "jade");
|
|
28
|
+
root.style.setProperty("--blur", t.blur / 100);
|
|
29
|
+
root.style.setProperty("--alpha", t.transparency / 100);
|
|
30
|
+
root.style.setProperty("--reflect", t.reflection / 100);
|
|
31
|
+
root.style.setProperty("--depth", t.depth / 100);
|
|
32
|
+
root.style.setProperty("--sp", DENSITY[t.layout] || 1);
|
|
33
|
+
if (t.accent) {
|
|
34
|
+
root.style.setProperty("--primary", t.accent);
|
|
35
|
+
root.style.setProperty("--primary-soft", `color-mix(in oklab, ${t.accent} 10%, transparent)`);
|
|
36
|
+
} else {
|
|
37
|
+
root.style.removeProperty("--primary");
|
|
38
|
+
root.style.removeProperty("--primary-soft");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function App() {
|
|
43
|
+
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
44
|
+
const [page, setPage] = React.useState(() => localStorage.getItem("yana.m.page") || "dashboard");
|
|
45
|
+
const [more, setMore] = React.useState(false);
|
|
46
|
+
const mainRef = React.useRef(null);
|
|
47
|
+
window.YANA_LANG = t.language === "Tiếng Việt" ? "vi" : "en";
|
|
48
|
+
|
|
49
|
+
// data.js hydrates window.YANA from the real APIs after mount — re-render
|
|
50
|
+
// when each batch lands so the screens pick up live values
|
|
51
|
+
const [, forceData] = React.useReducer((x) => x + 1, 0);
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
window.addEventListener("yana:data", forceData);
|
|
54
|
+
return () => window.removeEventListener("yana:data", forceData);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => applyTweaks(t), [t]);
|
|
58
|
+
React.useEffect(() => localStorage.setItem("yana.m.page", page), [page]);
|
|
59
|
+
// scroll the page region back to top on navigation
|
|
60
|
+
React.useEffect(() => { if (mainRef.current) mainRef.current.scrollTop = 0; }, [page]);
|
|
61
|
+
|
|
62
|
+
function nav(id) { setPage(id); }
|
|
63
|
+
function toggleLang() { setTweak("language", t.language === "Tiếng Việt" ? "English" : "Tiếng Việt"); }
|
|
64
|
+
|
|
65
|
+
const isChat = page === "chat";
|
|
66
|
+
const Page = {
|
|
67
|
+
dashboard: () => <MLake t={t} onNav={nav} />,
|
|
68
|
+
missions: () => <MMissions />,
|
|
69
|
+
chat: () => <MChat />,
|
|
70
|
+
agents: () => <MAgents />,
|
|
71
|
+
memory: () => <MMemoryGarden />,
|
|
72
|
+
skills: () => <MSkills />,
|
|
73
|
+
providers: () => <MProviders />,
|
|
74
|
+
settings: () => <MSettings t={t} setTweak={setTweak} />,
|
|
75
|
+
}[page] || (() => <MLake t={t} onNav={nav} />);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="app-stage">
|
|
79
|
+
<div className="app-mobile" key={t.language}>
|
|
80
|
+
<TopBar page={page} lang={window.YANA_LANG} onLang={toggleLang} onMore={() => setMore(true)} />
|
|
81
|
+
|
|
82
|
+
<main ref={mainRef} className={"mmain" + (isChat ? " flush" : "")}>
|
|
83
|
+
<Page />
|
|
84
|
+
</main>
|
|
85
|
+
|
|
86
|
+
<TabBar page={page} onNav={nav} onMore={() => setMore(true)} />
|
|
87
|
+
<MoreSheet open={more} page={page} onNav={nav} onClose={() => setMore(false)} />
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<TweaksPanel>
|
|
91
|
+
<TweakSection label="Theme" />
|
|
92
|
+
<TweakSelect label="Direction" value={t.theme}
|
|
93
|
+
options={Object.keys(THEME_MAP)}
|
|
94
|
+
onChange={(v) => setTweak("theme", v)} />
|
|
95
|
+
<TweakRadio label="Language" value={t.language}
|
|
96
|
+
options={["English", "Tiếng Việt"]}
|
|
97
|
+
onChange={(v) => setTweak("language", v)} />
|
|
98
|
+
|
|
99
|
+
<TweakSection label="Glass" />
|
|
100
|
+
<TweakSlider label="Blur strength" value={t.blur} min={0} max={100} unit="%" onChange={(v) => setTweak("blur", v)} />
|
|
101
|
+
<TweakSlider label="Transparency" value={t.transparency} min={0} max={100} unit="%" onChange={(v) => setTweak("transparency", v)} />
|
|
102
|
+
<TweakSlider label="Reflection" value={t.reflection} min={0} max={100} unit="%" onChange={(v) => setTweak("reflection", v)} />
|
|
103
|
+
<TweakSlider label="Depth" value={t.depth} min={0} max={100} unit="%" onChange={(v) => setTweak("depth", v)} />
|
|
104
|
+
|
|
105
|
+
<TweakSection label="Layout" />
|
|
106
|
+
<TweakRadio label="Density" value={t.layout} options={["Compact", "Regular", "Spacious"]} onChange={(v) => setTweak("layout", v)} />
|
|
107
|
+
|
|
108
|
+
<TweakSection label="Lake cards" />
|
|
109
|
+
<TweakToggle label="Show agents" value={t.showAgents} onChange={(v) => setTweak("showAgents", v)} />
|
|
110
|
+
<TweakToggle label="Show missions" value={t.showMissions} onChange={(v) => setTweak("showMissions", v)} />
|
|
111
|
+
<TweakToggle label="Show Memory Garden" value={t.showMemory} onChange={(v) => setTweak("showMemory", v)} />
|
|
112
|
+
<TweakToggle label="Show system status" value={t.showSystem} onChange={(v) => setTweak("showSystem", v)} />
|
|
113
|
+
|
|
114
|
+
<TweakSection label="Visual style" />
|
|
115
|
+
<TweakColor label="Accent" value={t.accent || "#2f7e6e"}
|
|
116
|
+
options={["#2f7e6e", "#56949f", "#3a7ca5", "#7d6aa8", "#b96b80", "#b07a4f", "#b78f3d", "#6f8f5a", "#5b7282"]}
|
|
117
|
+
onChange={(v) => setTweak("accent", v)} />
|
|
118
|
+
<TweakButton label="Use theme accent" onClick={() => setTweak("accent", "")} />
|
|
119
|
+
</TweaksPanel>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* life beneath the surface: slow drifting motes */
|
|
125
|
+
const MOTES = [
|
|
126
|
+
{ left: "12%", top: "78%", dur: "64s", delay: "0s", dx: "60px", dy: "-50px", peak: 0.20 },
|
|
127
|
+
{ left: "26%", top: "88%", dur: "82s", delay: "-20s", dx: "-45px", dy: "-70px", peak: 0.16 },
|
|
128
|
+
{ left: "44%", top: "72%", dur: "71s", delay: "-40s", dx: "50px", dy: "-40px", peak: 0.18 },
|
|
129
|
+
{ left: "58%", top: "84%", dur: "90s", delay: "-10s", dx: "-60px", dy: "-55px", peak: 0.22 },
|
|
130
|
+
{ left: "72%", top: "76%", dur: "76s", delay: "-55s", dx: "40px", dy: "-65px", peak: 0.16 },
|
|
131
|
+
{ left: "84%", top: "90%", dur: "68s", delay: "-30s", dx: "-50px", dy: "-45px", peak: 0.20 },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function Undercurrent() {
|
|
135
|
+
return (
|
|
136
|
+
<div className="scene">
|
|
137
|
+
{MOTES.map((m, i) => (
|
|
138
|
+
<span key={i} className="mote" style={{
|
|
139
|
+
left: m.left, top: m.top,
|
|
140
|
+
"--dur": m.dur, "--delay": m.delay, "--dx": m.dx, "--dy": m.dy, "--peak": m.peak,
|
|
141
|
+
}}></span>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
148
|
+
<>
|
|
149
|
+
<Undercurrent />
|
|
150
|
+
<App />
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Yana Mobile — Conversation. Calls /api/chat with SSE streaming — mirrors chat.jsx.
|
|
2
|
+
|
|
3
|
+
/* ── Mobile-local helpers (chat.jsx not loaded on mobile) ─────────────────── */
|
|
4
|
+
const M_CHAT_MODELS = {
|
|
5
|
+
claude: "claude-sonnet-4-6", openai: "gpt-4o-mini", gemini: "gemini-2.0-flash",
|
|
6
|
+
groq: "llama-3.3-70b-versatile", deepseek: "deepseek-chat",
|
|
7
|
+
openrouter: "google/gemma-3-27b-it", "9router": "kr/claude-sonnet-4.5", ollama: "llama3.2",
|
|
8
|
+
};
|
|
9
|
+
const M_KEYLESS = new Set(["ollama"]);
|
|
10
|
+
|
|
11
|
+
function mGetProviderConfig() {
|
|
12
|
+
if (typeof YanaVault === "undefined") return { provider: "claude", apiKey: "" };
|
|
13
|
+
const order = ["claude", "openai", "gemini", "groq", "deepseek", "openrouter", "9router"];
|
|
14
|
+
for (const id of order) {
|
|
15
|
+
if (M_KEYLESS.has(id)) continue;
|
|
16
|
+
const key = YanaVault.getKey(id);
|
|
17
|
+
if (key) return { provider: id, apiKey: key };
|
|
18
|
+
}
|
|
19
|
+
return { provider: "claude", apiKey: "" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mAboutContext() {
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const [id, label] of [["who","Who"],["strengths","Strengths"],["style","Response style"]]) {
|
|
25
|
+
const v = localStorage.getItem("yana.about." + id);
|
|
26
|
+
if (v && v.trim()) parts.push(label + ": " + v.trim());
|
|
27
|
+
}
|
|
28
|
+
return parts.join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const M_CONFIDENTIAL = ["bí mật","confidential","đừng lưu","don't save","#mật","#private","off the record"];
|
|
32
|
+
const M_SOVEREIGN = ["chỉ mình anh biết","sovereign only","#sovereign","local model only"];
|
|
33
|
+
function mDetectSensitivity(text) {
|
|
34
|
+
const lo = (text || "").toLowerCase();
|
|
35
|
+
if (M_SOVEREIGN.some((m) => lo.includes(m))) return "sovereign";
|
|
36
|
+
if (M_CONFIDENTIAL.some((m) => lo.includes(m))) return "confidential";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
function MRouteChip({ route }) {
|
|
42
|
+
return (
|
|
43
|
+
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
|
|
44
|
+
<YanaMark size={18} />
|
|
45
|
+
<span style={{ fontSize: 11.5, color: "var(--ink-3)" }}>
|
|
46
|
+
via <b style={{ fontWeight: 500, color: "var(--ink-2)" }}>{route.agent}</b> · {route.model}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function MMessage({ msg }) {
|
|
53
|
+
if (msg.who === "user") {
|
|
54
|
+
return (
|
|
55
|
+
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
|
56
|
+
<div style={{
|
|
57
|
+
maxWidth: "84%", padding: "10px 14px", borderRadius: "16px 16px 4px 16px",
|
|
58
|
+
background: "var(--primary)", color: "rgba(255,255,255,.96)",
|
|
59
|
+
fontSize: 14, lineHeight: 1.55,
|
|
60
|
+
boxShadow: "0 4px 14px color-mix(in oklab, var(--primary) 25%, transparent)",
|
|
61
|
+
}}>{msg.text}</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<div style={{ display: "flex", justifyContent: "flex-start" }}>
|
|
67
|
+
<div style={{ maxWidth: "90%" }}>
|
|
68
|
+
{msg.route && <MRouteChip route={msg.route} />}
|
|
69
|
+
<div className="glass" style={{ padding: "12px 15px", borderRadius: "4px 16px 16px 16px", fontSize: 14, lineHeight: 1.6, color: "var(--ink)" }}>
|
|
70
|
+
{msg.text}
|
|
71
|
+
{msg.action && (
|
|
72
|
+
<div style={{
|
|
73
|
+
marginTop: 11, padding: "9px 12px", borderRadius: "var(--r-sm)",
|
|
74
|
+
background: "var(--primary-soft)", display: "flex", alignItems: "center", gap: 9,
|
|
75
|
+
}}>
|
|
76
|
+
<span style={{ color: "var(--primary)", flex: "none" }}>{Icons.safety(15)}</span>
|
|
77
|
+
<div style={{ lineHeight: 1.3 }}>
|
|
78
|
+
<div style={{ fontSize: 12.5, fontWeight: 500, color: "var(--primary)" }}>{msg.action.label}</div>
|
|
79
|
+
<div style={{ fontSize: 11.5, color: "var(--ink-3)" }}>{msg.action.state}</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
{msg.refs && (
|
|
85
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 7 }}>
|
|
86
|
+
{msg.refs.map((r) => <span key={r} className="chip neutral" style={{ fontSize: 10.5 }}>{r}</span>)}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function MContextSheet({ open, onClose }) {
|
|
95
|
+
const D = window.YANA;
|
|
96
|
+
const { provider: _p } = mGetProviderConfig();
|
|
97
|
+
const _m = M_CHAT_MODELS[_p] || _p;
|
|
98
|
+
return (
|
|
99
|
+
<Sheet open={open} title={L("Routing & context", "Định tuyến & ngữ cảnh")} onClose={onClose}>
|
|
100
|
+
<MCard title={L("Routing", "Định tuyến")}>
|
|
101
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
|
102
|
+
{[[L("Orchestrator", "Điều phối"), "Navigator"], [L("Model", "Mô hình"), _m], [L("Provider", "Nhà cung cấp"), _p]].map(([k, v]) => (
|
|
103
|
+
<div key={k} style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
|
|
104
|
+
<span style={{ color: "var(--ink-3)" }}>{k}</span>
|
|
105
|
+
<span style={{ fontWeight: 500 }}>{v}</span>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</MCard>
|
|
110
|
+
<MCard title={L("Context in use", "Ngữ cảnh đang dùng")}>
|
|
111
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
112
|
+
{D.memories.filter((m) => m.pinned || m.fresh).slice(0, 3).map((m) => (
|
|
113
|
+
<div key={m.id} style={{ fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.45, display: "flex", gap: 8 }}>
|
|
114
|
+
<span style={{ color: m.pinned ? "var(--gold)" : "var(--pink)", flex: "none", marginTop: 1 }}>
|
|
115
|
+
{m.pinned ? Icons.pin(13) : Icons.memory(13)}
|
|
116
|
+
</span>
|
|
117
|
+
{m.text}
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</MCard>
|
|
122
|
+
<MCard title={L("Safety", "An toàn")}>
|
|
123
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
124
|
+
<span className="dot on"></span>
|
|
125
|
+
<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>
|
|
126
|
+
</div>
|
|
127
|
+
</MCard>
|
|
128
|
+
</Sheet>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function MChat() {
|
|
133
|
+
const D = window.YANA;
|
|
134
|
+
const [msgs, setMsgs] = React.useState(D.chat);
|
|
135
|
+
const [draft, setDraft] = React.useState("");
|
|
136
|
+
const [thinking, setThinking] = React.useState(false);
|
|
137
|
+
const [ctx, setCtx] = React.useState(false);
|
|
138
|
+
const logRef = React.useRef(null);
|
|
139
|
+
const readerRef = React.useRef(null);
|
|
140
|
+
// active provider for context bar — reflects the first key the user has set
|
|
141
|
+
const { provider: _activeProvider } = mGetProviderConfig();
|
|
142
|
+
const _activeModel = M_CHAT_MODELS[_activeProvider] || _activeProvider;
|
|
143
|
+
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
const el = logRef.current;
|
|
146
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
147
|
+
}, [msgs, thinking]);
|
|
148
|
+
|
|
149
|
+
// Persist chat history (non-confidential only)
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
D.chat = msgs;
|
|
152
|
+
try {
|
|
153
|
+
localStorage.setItem("yana.chat", JSON.stringify(
|
|
154
|
+
msgs.filter((m) => !m.confidential).slice(-60)
|
|
155
|
+
));
|
|
156
|
+
} catch (_) {}
|
|
157
|
+
}, [msgs]);
|
|
158
|
+
|
|
159
|
+
React.useEffect(() => { return () => { if (readerRef.current) readerRef.current.cancel(); }; }, []);
|
|
160
|
+
|
|
161
|
+
async function send() {
|
|
162
|
+
const text = draft.trim();
|
|
163
|
+
if (!text || thinking) return;
|
|
164
|
+
|
|
165
|
+
const tier = mDetectSensitivity(text);
|
|
166
|
+
setMsgs((m) => [...m, { who: "user", text, confidential: !!tier, tier }]);
|
|
167
|
+
setDraft("");
|
|
168
|
+
setThinking(true);
|
|
169
|
+
|
|
170
|
+
let { provider, apiKey } = mGetProviderConfig();
|
|
171
|
+
if (tier === "sovereign") { provider = "ollama"; apiKey = ""; }
|
|
172
|
+
const model = M_CHAT_MODELS[provider] || "";
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch("/api/chat", {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body: JSON.stringify(tier
|
|
179
|
+
? { task: text, apiKey, provider, model, sensitivity: tier }
|
|
180
|
+
: { task: text, apiKey, provider, model, about: mAboutContext() }),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
|
|
184
|
+
|
|
185
|
+
const reader = res.body.getReader();
|
|
186
|
+
readerRef.current = reader;
|
|
187
|
+
const decoder = new TextDecoder();
|
|
188
|
+
let buf = "";
|
|
189
|
+
let accumulated = "";
|
|
190
|
+
const msgId = Date.now();
|
|
191
|
+
|
|
192
|
+
setMsgs((m) => [...m, {
|
|
193
|
+
who: "yana",
|
|
194
|
+
route: { agent: provider, model: model + (tier ? " · 🔒" : "") },
|
|
195
|
+
text: "", confidential: !!tier, tier, _id: msgId,
|
|
196
|
+
}]);
|
|
197
|
+
setThinking(false);
|
|
198
|
+
|
|
199
|
+
while (true) {
|
|
200
|
+
const { done, value } = await reader.read();
|
|
201
|
+
if (done) break;
|
|
202
|
+
buf += decoder.decode(value, { stream: true });
|
|
203
|
+
const lines = buf.split("\n");
|
|
204
|
+
buf = lines.pop();
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (!line.startsWith("data: ")) continue;
|
|
207
|
+
const payload = line.slice(6).trim();
|
|
208
|
+
if (payload === "[DONE]") break;
|
|
209
|
+
try {
|
|
210
|
+
const obj = JSON.parse(payload);
|
|
211
|
+
if (obj.error) accumulated += "\n[Error: " + obj.error + "]";
|
|
212
|
+
else if (obj.text) accumulated += obj.text;
|
|
213
|
+
const snap = accumulated;
|
|
214
|
+
setMsgs((m) => m.map((msg) => msg._id === msgId ? { ...msg, text: snap } : msg));
|
|
215
|
+
} catch (_) {}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ChatGPT-style memory — strip MEMORY: line and persist it
|
|
220
|
+
if (!tier) {
|
|
221
|
+
const mm = accumulated.match(/(?:^|\n)\s*MEMORY:\s*(.+?)\s*$/);
|
|
222
|
+
if (mm && mm[1]) {
|
|
223
|
+
const fact = mm[1];
|
|
224
|
+
const shown = accumulated.slice(0, mm.index).trimEnd();
|
|
225
|
+
setMsgs((m) => m.map((msg) =>
|
|
226
|
+
msg._id === msgId
|
|
227
|
+
? { ...msg, text: shown || msg.text, refs: [...(msg.refs || []), L("🌱 Remembered: ", "🌱 Đã nhớ: ") + fact] }
|
|
228
|
+
: msg
|
|
229
|
+
));
|
|
230
|
+
fetch("/api/memory", {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({ text: fact }),
|
|
234
|
+
}).catch(() => {});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
setThinking(false);
|
|
239
|
+
setMsgs((m) => [...m, {
|
|
240
|
+
who: "yana",
|
|
241
|
+
route: { agent: provider, model },
|
|
242
|
+
confidential: !!tier, tier,
|
|
243
|
+
text: tier === "sovereign"
|
|
244
|
+
? L("Cannot reach local model. SOVEREIGN content only goes to Ollama (127.0.0.1:11434) — run `ollama serve`.",
|
|
245
|
+
"Không kết nối được model local. Nội dung SOVEREIGN chỉ đến Ollama (127.0.0.1:11434) — chạy `ollama serve`.")
|
|
246
|
+
: L("Server error. Check Yana is running and an API key is set in Providers.",
|
|
247
|
+
"Lỗi kết nối. Kiểm tra Yana đang chạy và đã thêm API key trong Providers."),
|
|
248
|
+
}]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div data-screen-label="Chat" style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
|
254
|
+
{/* slim context bar replaces the desktop right rail */}
|
|
255
|
+
<button onClick={() => setCtx(true)} style={{
|
|
256
|
+
flex: "none", display: "flex", alignItems: "center", gap: 9, margin: "12px 16px 8px",
|
|
257
|
+
padding: "9px 13px", borderRadius: 99, border: "1px solid var(--border)", cursor: "pointer",
|
|
258
|
+
background: "rgba(var(--surface-rgb), .5)", color: "var(--ink-2)", textAlign: "left",
|
|
259
|
+
}}>
|
|
260
|
+
<span style={{ color: "var(--primary)", display: "inline-flex" }}>{Icons.safety(15)}</span>
|
|
261
|
+
<span style={{ fontSize: 12.5, fontWeight: 500 }}>{_activeProvider} · {_activeModel}</span>
|
|
262
|
+
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "var(--ink-3)" }}>
|
|
263
|
+
<span className="dot on" style={{ width: 6, height: 6, boxShadow: "none" }}></span>{L("Context", "Ngữ cảnh")} {Icons.chevron(13)}
|
|
264
|
+
</span>
|
|
265
|
+
</button>
|
|
266
|
+
|
|
267
|
+
<div ref={logRef} style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: "calc(15px * var(--sp))", padding: "6px 16px 14px", minHeight: 0 }}>
|
|
268
|
+
{msgs.map((m, i) => <MMessage key={i} msg={m} />)}
|
|
269
|
+
{thinking && (
|
|
270
|
+
<div style={{ display: "flex", alignItems: "center", gap: 9, color: "var(--ink-3)", fontSize: 12.5 }}>
|
|
271
|
+
<YanaMark size={18} /> {L("Navigator is thinking…", "Navigator đang suy nghĩ…")}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div style={{ flex: "none", padding: "8px 12px calc(12px + env(safe-area-inset-bottom, 0px))" }}>
|
|
277
|
+
<div className="glass-strong" style={{ borderRadius: 18, padding: "7px 7px 7px 15px", display: "flex", alignItems: "center", gap: 9 }}>
|
|
278
|
+
<input
|
|
279
|
+
value={draft}
|
|
280
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
281
|
+
onKeyDown={(e) => { if (e.key === "Enter") send(); }}
|
|
282
|
+
placeholder={L("Give Yana a direction…", "Giao cho Yana một hướng đi…")}
|
|
283
|
+
style={{ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", fontSize: 14.5, fontFamily: "inherit", color: "var(--ink)" }}
|
|
284
|
+
/>
|
|
285
|
+
<button onClick={send} aria-label="Send" style={{
|
|
286
|
+
width: 38, height: 38, borderRadius: 12, border: "none", cursor: "pointer", flex: "none",
|
|
287
|
+
background: "var(--primary)", color: "white", display: "grid", placeItems: "center",
|
|
288
|
+
boxShadow: "0 4px 12px color-mix(in oklab, var(--primary) 30%, transparent)",
|
|
289
|
+
}}>{Icons.send(17)}</button>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<MContextSheet open={ctx} onClose={() => setCtx(false)} />
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
window.MChat = MChat;
|