@fluxy-chat/create-fluxy-chat 0.3.0 → 0.5.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/CHANGELOG.md +16 -0
- package/dist/index.js +105 -29
- package/package.json +4 -3
- package/readme.md +22 -16
- package/templates/full/.env.example +10 -0
- package/templates/full/README.md +66 -0
- package/templates/full/index.html +12 -0
- package/templates/full/package.json +29 -0
- package/templates/full/scripts/fluxy-dev.mjs +169 -0
- package/templates/full/scripts/fluxy-doctor.mjs +122 -0
- package/templates/full/scripts/fluxy-setup.mjs +323 -0
- package/templates/full/src/App.tsx +223 -0
- package/templates/full/src/index.css +242 -0
- package/templates/full/src/main.tsx +10 -0
- package/templates/full/src/vite-env.d.ts +16 -0
- package/templates/full/tsconfig.json +21 -0
- package/templates/full/vite.config.ts +7 -0
- package/templates/hr-feedback/.env.example +9 -0
- package/templates/hr-feedback/README.md +45 -0
- package/templates/hr-feedback/package.json +19 -0
- package/templates/hr-feedback/src/feedback.ts +71 -0
- package/templates/hr-feedback/src/index.ts +22 -0
- package/templates/hr-feedback/tsconfig.json +18 -0
- package/templates/hr-feedback/wrangler.toml +7 -0
- package/templates/minimal/README.md +1 -1
- package/templates/react/.env.example +4 -3
- package/templates/react/README.md +17 -7
- package/templates/react/src/App.tsx +116 -23
- package/templates/react/src/index.css +10 -0
- package/templates/react/src/vite-env.d.ts +3 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Verify .env, worker health, demo/local readiness, and agent config.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const envPath = join(root, ".env");
|
|
11
|
+
|
|
12
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
13
|
+
const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
14
|
+
const green = (s) => c("32", s);
|
|
15
|
+
const red = (s) => c("31", s);
|
|
16
|
+
const yellow = (s) => c("33", s);
|
|
17
|
+
const bold = (s) => c("1", s);
|
|
18
|
+
|
|
19
|
+
let failures = 0;
|
|
20
|
+
let warnings = 0;
|
|
21
|
+
|
|
22
|
+
function pass(msg) {
|
|
23
|
+
console.log(` ${green("✓")} ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
function warn(msg) {
|
|
26
|
+
warnings += 1;
|
|
27
|
+
console.log(` ${yellow("!")} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
function fail(msg) {
|
|
30
|
+
failures += 1;
|
|
31
|
+
console.log(` ${red("✗")} ${msg}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseEnvFile(path) {
|
|
35
|
+
if (!existsSync(path)) return {};
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
40
|
+
const eq = trimmed.indexOf("=");
|
|
41
|
+
if (eq <= 0) continue;
|
|
42
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchOk(url, init = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
|
|
50
|
+
return { ok: r.ok, status: r.status, json: r.headers.get("content-type")?.includes("json") ? await r.json().catch(() => null) : null };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
console.log(bold("\nFluxyChat doctor\n"));
|
|
58
|
+
|
|
59
|
+
const env = parseEnvFile(envPath);
|
|
60
|
+
if (!existsSync(envPath)) {
|
|
61
|
+
fail(".env missing — run: pnpm setup");
|
|
62
|
+
} else {
|
|
63
|
+
pass(".env exists");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const workerUrl = env.VITE_FLUXYCHAT_WORKER_URL || process.env.FLUXY_WORKER_URL;
|
|
67
|
+
const jwt = env.VITE_FLUXYCHAT_MEMBER_JWT;
|
|
68
|
+
const roomId = env.VITE_FLUXYCHAT_ROOM_ID;
|
|
69
|
+
const agentId = env.VITE_FLUXYCHAT_AGENT_ID;
|
|
70
|
+
|
|
71
|
+
if (!workerUrl) fail("VITE_FLUXYCHAT_WORKER_URL unset");
|
|
72
|
+
else pass(`worker URL: ${workerUrl}`);
|
|
73
|
+
|
|
74
|
+
if (!jwt) fail("VITE_FLUXYCHAT_MEMBER_JWT unset");
|
|
75
|
+
else pass(`JWT present (${jwt.length} chars)`);
|
|
76
|
+
|
|
77
|
+
if (!roomId) warn("VITE_FLUXYCHAT_ROOM_ID unset");
|
|
78
|
+
else pass(`room: ${roomId}`);
|
|
79
|
+
|
|
80
|
+
if (!agentId) warn("VITE_FLUXYCHAT_AGENT_ID unset — agent invoke disabled");
|
|
81
|
+
else pass(`agent: ${agentId}`);
|
|
82
|
+
|
|
83
|
+
if (workerUrl) {
|
|
84
|
+
const health = await fetchOk(`${workerUrl}/health`);
|
|
85
|
+
if (health.ok) pass("worker /health OK");
|
|
86
|
+
else fail(`worker /health failed${health.error ? `: ${health.error}` : ` (${health.status})`}`);
|
|
87
|
+
|
|
88
|
+
const demoStatus = await fetchOk(`${workerUrl}/demo/status`);
|
|
89
|
+
if (demoStatus.ok && demoStatus.json?.ready) {
|
|
90
|
+
pass("public demo available (/demo/status)");
|
|
91
|
+
} else if (workerUrl.includes("127.0.0.1") || workerUrl.includes("localhost")) {
|
|
92
|
+
pass("local worker (demo status optional)");
|
|
93
|
+
} else {
|
|
94
|
+
warn("public demo not ready on this worker");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (workerUrl && jwt && roomId) {
|
|
99
|
+
const rooms = await fetchOk(`${workerUrl}/rooms`, {
|
|
100
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
101
|
+
});
|
|
102
|
+
if (rooms.ok) {
|
|
103
|
+
const list = Array.isArray(rooms.json?.rooms) ? rooms.json.rooms : [];
|
|
104
|
+
if (list.some((r) => r?.id === roomId)) pass(`room membership OK (${roomId})`);
|
|
105
|
+
else warn(`room ${roomId} not listed for this JWT`);
|
|
106
|
+
} else {
|
|
107
|
+
warn(`GET /rooms failed (${rooms.status ?? rooms.error})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log("");
|
|
112
|
+
if (failures > 0) {
|
|
113
|
+
console.log(red(bold(`${failures} check(s) failed`)) + (warnings ? ` · ${warnings} warning(s)` : ""));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
console.log(green(bold("All checks passed")) + (warnings ? ` · ${warnings} warning(s)` : ""));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
console.error(err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Provision credentials and write .env for the full template.
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* local (default) — POST /dev/provision on local worker (ALLOW_DEV_PROVISION=true)
|
|
7
|
+
* hosted — GET /demo/session on fluxychat.com (no wrangler required)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* pnpm setup
|
|
11
|
+
* pnpm setup -- --mode hosted
|
|
12
|
+
* FLUXY_SETUP_MODE=hosted pnpm setup
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const envPath = join(root, ".env");
|
|
20
|
+
const metaPath = join(root, ".fluxy", "setup.json");
|
|
21
|
+
const modePath = join(root, ".fluxy", "mode");
|
|
22
|
+
|
|
23
|
+
const HOSTED_WORKER_DEFAULT = "https://api.fluxychat.com";
|
|
24
|
+
const HOSTED_CONSOLE_DEFAULT = "https://fluxychat.com";
|
|
25
|
+
const LOCAL_WORKER_DEFAULT = "http://127.0.0.1:8787";
|
|
26
|
+
const LOCAL_CONSOLE_DEFAULT = "http://localhost:3000";
|
|
27
|
+
const POST_TIMEOUT_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
30
|
+
const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
31
|
+
const green = (s) => c("32", s);
|
|
32
|
+
const red = (s) => c("31", s);
|
|
33
|
+
const dim = (s) => c("2", s);
|
|
34
|
+
const bold = (s) => c("1", s);
|
|
35
|
+
|
|
36
|
+
function ok(msg) {
|
|
37
|
+
console.log(` ${green("✓")} ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
function fail(msg) {
|
|
40
|
+
console.error(`\n${red("✗")} ${red(bold(msg))}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readDefaultMode() {
|
|
45
|
+
if (existsSync(modePath)) {
|
|
46
|
+
const m = readFileSync(modePath, "utf8").trim().toLowerCase();
|
|
47
|
+
if (m === "hosted" || m === "local") return m;
|
|
48
|
+
}
|
|
49
|
+
return "local";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveMode() {
|
|
53
|
+
const argv = process.argv.slice(2);
|
|
54
|
+
const flagIdx = argv.indexOf("--mode");
|
|
55
|
+
if (flagIdx >= 0 && argv[flagIdx + 1]) {
|
|
56
|
+
const m = String(argv[flagIdx + 1]).trim().toLowerCase();
|
|
57
|
+
if (m === "hosted" || m === "local") return m;
|
|
58
|
+
fail(`Unknown mode "${argv[flagIdx + 1]}". Use: local | hosted`);
|
|
59
|
+
}
|
|
60
|
+
const fromEnv = String(process.env.FLUXY_SETUP_MODE || "").trim().toLowerCase();
|
|
61
|
+
if (fromEnv === "hosted" || fromEnv === "local") return fromEnv;
|
|
62
|
+
return readDefaultMode();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fetchWithTimeout(url, init = {}, timeoutMs = POST_TIMEOUT_MS) {
|
|
66
|
+
const ctrl = new AbortController();
|
|
67
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
68
|
+
return fetch(url, { ...init, signal: ctrl.signal }).finally(() => clearTimeout(t));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function isWorkerUp(workerUrl) {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetchWithTimeout(`${workerUrl}/health`, { method: "GET" }, 2000);
|
|
74
|
+
return r.ok;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function postJson(workerUrl, path, body, headers = {}) {
|
|
81
|
+
const res = await fetchWithTimeout(`${workerUrl}${path}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
let json = null;
|
|
88
|
+
try {
|
|
89
|
+
json = text ? JSON.parse(text) : null;
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const detail = json?.error || text || res.statusText;
|
|
95
|
+
throw new Error(`${path} → HTTP ${res.status}: ${detail}`);
|
|
96
|
+
}
|
|
97
|
+
return json ?? {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function getJson(workerUrl, path, headers = {}) {
|
|
101
|
+
const res = await fetchWithTimeout(`${workerUrl}${path}`, { method: "GET", headers });
|
|
102
|
+
const text = await res.text();
|
|
103
|
+
let json = null;
|
|
104
|
+
try {
|
|
105
|
+
json = text ? JSON.parse(text) : null;
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const detail = json?.error || text || res.statusText;
|
|
111
|
+
throw new Error(`${path} → HTTP ${res.status}: ${detail}`);
|
|
112
|
+
}
|
|
113
|
+
return json ?? {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function quickstartRoomId(projectId) {
|
|
117
|
+
return `${projectId}-general`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function ensureRoom(workerUrl, token, projectId) {
|
|
121
|
+
const roomId = quickstartRoomId(projectId);
|
|
122
|
+
const listed = await getJson(workerUrl, "/rooms", { Authorization: `Bearer ${token}` });
|
|
123
|
+
const rooms = Array.isArray(listed?.rooms) ? listed.rooms : [];
|
|
124
|
+
if (rooms.some((room) => room?.id === roomId)) {
|
|
125
|
+
ok(`room ${roomId} ready`);
|
|
126
|
+
return roomId;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await postJson(
|
|
130
|
+
workerUrl,
|
|
131
|
+
"/rooms",
|
|
132
|
+
{ id: roomId, type: "public", name: "General" },
|
|
133
|
+
{ Authorization: `Bearer ${token}` },
|
|
134
|
+
);
|
|
135
|
+
ok(`room ${roomId} created`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
+
if (msg.includes("409") || msg.includes("room_id_already_exists")) {
|
|
139
|
+
ok(`room ${roomId} exists`);
|
|
140
|
+
} else {
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return roomId;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolveAssistantAgent(workerUrl, token) {
|
|
148
|
+
const data = await getJson(workerUrl, "/agents", { Authorization: `Bearer ${token}` });
|
|
149
|
+
const agents = Array.isArray(data?.agents) ? data.agents : Array.isArray(data) ? data : [];
|
|
150
|
+
const assistant =
|
|
151
|
+
agents.find((a) => a?.handle === "@assistant") ??
|
|
152
|
+
agents.find((a) => String(a?.handle ?? "").includes("assistant")) ??
|
|
153
|
+
agents[0];
|
|
154
|
+
if (!assistant?.id) {
|
|
155
|
+
fail("No agents found. Ensure worker migrations ran (built-in @assistant seed).");
|
|
156
|
+
}
|
|
157
|
+
ok(`agent ${assistant.handle ?? assistant.id}`);
|
|
158
|
+
return { id: String(assistant.id), handle: String(assistant.handle ?? "@assistant") };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeEnv(vars) {
|
|
162
|
+
const lines = [
|
|
163
|
+
"# Auto-generated by pnpm setup — do not commit secrets",
|
|
164
|
+
`VITE_FLUXYCHAT_WORKER_URL=${vars.workerUrl}`,
|
|
165
|
+
`VITE_FLUXYCHAT_MEMBER_JWT=${vars.token}`,
|
|
166
|
+
`VITE_FLUXYCHAT_ROOM_ID=${vars.roomId}`,
|
|
167
|
+
`VITE_FLUXYCHAT_AGENT_ID=${vars.agentId}`,
|
|
168
|
+
`VITE_FLUXYCHAT_AGENT_HANDLE=${vars.agentHandle}`,
|
|
169
|
+
`VITE_FLUXYCHAT_PROJECT_ID=${vars.projectId}`,
|
|
170
|
+
`VITE_FLUXYCHAT_USER_ID=${vars.userId}`,
|
|
171
|
+
`VITE_FLUXYCHAT_CONSOLE_URL=${vars.consoleUrl}`,
|
|
172
|
+
"",
|
|
173
|
+
];
|
|
174
|
+
writeFileSync(envPath, lines.join("\n"));
|
|
175
|
+
ok(`wrote ${envPath}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeMeta(meta) {
|
|
179
|
+
mkdirSync(dirname(metaPath), { recursive: true });
|
|
180
|
+
writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function setupHosted() {
|
|
184
|
+
const workerUrl =
|
|
185
|
+
process.env.FLUXY_HOSTED_WORKER_URL ||
|
|
186
|
+
process.env.FLUXY_WORKER_URL ||
|
|
187
|
+
HOSTED_WORKER_DEFAULT;
|
|
188
|
+
const consoleUrl = process.env.FLUXY_CONSOLE_URL || HOSTED_CONSOLE_DEFAULT;
|
|
189
|
+
|
|
190
|
+
console.log(dim(` mode: hosted · worker: ${workerUrl}`));
|
|
191
|
+
|
|
192
|
+
const status = await getJson(workerUrl, "/demo/status").catch(() => null);
|
|
193
|
+
if (!status?.ready) {
|
|
194
|
+
fail(
|
|
195
|
+
`Public demo not available at ${workerUrl}\n` +
|
|
196
|
+
" Try: pnpm setup -- --mode local (with local worker)\n" +
|
|
197
|
+
" Or: https://fluxychat.com/demo",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
ok("public demo ready");
|
|
201
|
+
|
|
202
|
+
const session = await getJson(workerUrl, "/demo/session");
|
|
203
|
+
if (!session?.token || !session?.roomId) {
|
|
204
|
+
fail("/demo/session did not return token + roomId");
|
|
205
|
+
}
|
|
206
|
+
ok(`guest session · room ${session.roomId}`);
|
|
207
|
+
|
|
208
|
+
writeEnv({
|
|
209
|
+
workerUrl,
|
|
210
|
+
token: session.token,
|
|
211
|
+
roomId: session.roomId,
|
|
212
|
+
agentId: session.agentId ?? "",
|
|
213
|
+
agentHandle: session.agentHandle ?? "@assistant",
|
|
214
|
+
projectId: "hosted-demo",
|
|
215
|
+
userId: session.userId ?? "demo-guest",
|
|
216
|
+
consoleUrl,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
writeMeta({
|
|
220
|
+
mode: "hosted",
|
|
221
|
+
workerUrl,
|
|
222
|
+
projectId: "hosted-demo",
|
|
223
|
+
roomId: session.roomId,
|
|
224
|
+
agentId: session.agentId ?? null,
|
|
225
|
+
agentHandle: session.agentHandle ?? "@assistant",
|
|
226
|
+
expiresIn: session.expiresIn ?? null,
|
|
227
|
+
setupAt: new Date().toISOString(),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
console.log(dim(" Run: pnpm dev"));
|
|
231
|
+
console.log(dim(` Keep this project: ${consoleUrl}/onboarding?from=cli`));
|
|
232
|
+
console.log(dim(" Paste your .env in the console to continue onboarding."));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function setupLocal() {
|
|
236
|
+
const workerUrl =
|
|
237
|
+
process.env.FLUXY_WORKER_URL || process.env.FLUXYCHAT_WORKER_URL || LOCAL_WORKER_DEFAULT;
|
|
238
|
+
const consoleUrl = process.env.FLUXY_CONSOLE_URL || LOCAL_CONSOLE_DEFAULT;
|
|
239
|
+
|
|
240
|
+
console.log(dim(` mode: local · worker: ${workerUrl}`));
|
|
241
|
+
|
|
242
|
+
if (!(await isWorkerUp(workerUrl))) {
|
|
243
|
+
fail(
|
|
244
|
+
`Worker not reachable at ${workerUrl}\n` +
|
|
245
|
+
" Start it from the FluxyChat monorepo:\n" +
|
|
246
|
+
" pnpm --filter @fluxy-chat/worker dev\n" +
|
|
247
|
+
" Or use hosted mode:\n" +
|
|
248
|
+
" pnpm setup -- --mode hosted",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
ok(`worker healthy at ${workerUrl}`);
|
|
252
|
+
|
|
253
|
+
const provision = await postJson(workerUrl, "/dev/provision", {});
|
|
254
|
+
if (!provision.projectId) {
|
|
255
|
+
fail("/dev/provision did not return projectId (is ALLOW_DEV_PROVISION=true?)");
|
|
256
|
+
}
|
|
257
|
+
ok(`projectId = ${provision.projectId}`);
|
|
258
|
+
|
|
259
|
+
let apiKey = provision.apiKey;
|
|
260
|
+
if (!apiKey && provision.reused) {
|
|
261
|
+
const fromEnv = process.env.FLUXY_CONSOLE_API_KEY || process.env.FIRST_MESSAGE_API_KEY;
|
|
262
|
+
if (fromEnv?.startsWith("fc_")) apiKey = fromEnv;
|
|
263
|
+
if (!apiKey && existsSync(join(root, "..", "..", "apps", "worker", ".dev.vars"))) {
|
|
264
|
+
const devVars = readFileSync(join(root, "..", "..", "apps", "worker", ".dev.vars"), "utf8");
|
|
265
|
+
const m = devVars.match(/^FLUXY_CONSOLE_API_KEY\s*=\s*(.+)$/m);
|
|
266
|
+
if (m?.[1]?.startsWith("fc_")) apiKey = m[1].trim();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!apiKey?.startsWith("fc_")) {
|
|
270
|
+
fail("No API key from /dev/provision. Re-run or set FLUXY_CONSOLE_API_KEY=fc_…");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const auth = await postJson(
|
|
274
|
+
workerUrl,
|
|
275
|
+
"/auth/token",
|
|
276
|
+
{ userId: "demo-user", roles: ["owner", "admin"], ttlSeconds: 86400 },
|
|
277
|
+
{ "X-Fluxy-Api-Key": apiKey },
|
|
278
|
+
);
|
|
279
|
+
if (!auth.token) fail("/auth/token did not return a JWT");
|
|
280
|
+
|
|
281
|
+
const roomId = await ensureRoom(workerUrl, auth.token, provision.projectId);
|
|
282
|
+
const agent = await resolveAssistantAgent(workerUrl, auth.token);
|
|
283
|
+
|
|
284
|
+
writeEnv({
|
|
285
|
+
workerUrl,
|
|
286
|
+
token: auth.token,
|
|
287
|
+
roomId,
|
|
288
|
+
agentId: agent.id,
|
|
289
|
+
agentHandle: agent.handle,
|
|
290
|
+
projectId: provision.projectId,
|
|
291
|
+
userId: "demo-user",
|
|
292
|
+
consoleUrl,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
writeMeta({
|
|
296
|
+
mode: "local",
|
|
297
|
+
workerUrl,
|
|
298
|
+
projectId: provision.projectId,
|
|
299
|
+
roomId,
|
|
300
|
+
agentId: agent.id,
|
|
301
|
+
agentHandle: agent.handle,
|
|
302
|
+
setupAt: new Date().toISOString(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
console.log(`\n${green(bold("Local setup complete."))}`);
|
|
306
|
+
console.log(dim(" Run: pnpm dev"));
|
|
307
|
+
console.log(dim(` Keep this project: ${consoleUrl}/onboarding?from=cli`));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function main() {
|
|
311
|
+
const mode = resolveMode();
|
|
312
|
+
console.log(bold(`\nFluxyChat setup (${mode})\n`));
|
|
313
|
+
|
|
314
|
+
if (mode === "hosted") {
|
|
315
|
+
await setupHosted();
|
|
316
|
+
} else {
|
|
317
|
+
await setupLocal();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main().catch((err) => {
|
|
322
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
323
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { FluxyChatClient } from "@fluxy-chat/sdk";
|
|
3
|
+
import { FluxyRealtimeProvider, useChat } from "@fluxy-chat/react";
|
|
4
|
+
|
|
5
|
+
const workerUrl = import.meta.env.VITE_FLUXYCHAT_WORKER_URL?.trim();
|
|
6
|
+
const memberJwt = import.meta.env.VITE_FLUXYCHAT_MEMBER_JWT?.trim();
|
|
7
|
+
const roomId = import.meta.env.VITE_FLUXYCHAT_ROOM_ID?.trim() || "general";
|
|
8
|
+
const agentId = import.meta.env.VITE_FLUXYCHAT_AGENT_ID?.trim() || "";
|
|
9
|
+
const agentHandle = import.meta.env.VITE_FLUXYCHAT_AGENT_HANDLE?.trim() || "@assistant";
|
|
10
|
+
const projectId = import.meta.env.VITE_FLUXYCHAT_PROJECT_ID?.trim() || "";
|
|
11
|
+
const consoleUrl = import.meta.env.VITE_FLUXYCHAT_CONSOLE_URL?.trim() || "http://localhost:3000";
|
|
12
|
+
const memberUserId =
|
|
13
|
+
import.meta.env.VITE_FLUXYCHAT_USER_ID?.trim() || "demo-user";
|
|
14
|
+
|
|
15
|
+
function ChatRoom() {
|
|
16
|
+
const {
|
|
17
|
+
messages,
|
|
18
|
+
sendMessage,
|
|
19
|
+
invokeAgent,
|
|
20
|
+
connectionState,
|
|
21
|
+
agentTyping,
|
|
22
|
+
toolThreadEvents,
|
|
23
|
+
lastAgentRun,
|
|
24
|
+
} = useChat({
|
|
25
|
+
roomId,
|
|
26
|
+
agentId: agentId || undefined,
|
|
27
|
+
markReadLatest: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const [draft, setDraft] = useState("");
|
|
31
|
+
const [invokeError, setInvokeError] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
async function handleSendMessage(text: string) {
|
|
34
|
+
setInvokeError(null);
|
|
35
|
+
await sendMessage(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function handleInvokeAgent(text: string) {
|
|
39
|
+
if (!agentId) {
|
|
40
|
+
setInvokeError("Set VITE_FLUXYCHAT_AGENT_ID in .env (run pnpm setup).");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setInvokeError(null);
|
|
44
|
+
try {
|
|
45
|
+
await invokeAgent(text, { agentId });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setInvokeError(err instanceof Error ? err.message : "Agent invoke failed");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section className="chat-panel">
|
|
53
|
+
<header className="chat-header">
|
|
54
|
+
<strong>{roomId}</strong>
|
|
55
|
+
<span className="status">{connectionState.status}</span>
|
|
56
|
+
</header>
|
|
57
|
+
|
|
58
|
+
<ul className="messages">
|
|
59
|
+
{messages.map((m) => {
|
|
60
|
+
const isSelf = m.userId === memberUserId || m.userId === "first-message-user";
|
|
61
|
+
const isAgent = m.userId?.includes("agent") || m.userId === "assistant";
|
|
62
|
+
return (
|
|
63
|
+
<li
|
|
64
|
+
key={m.id ?? `${m.createdAt}-${m.userId}`}
|
|
65
|
+
className={`message${isSelf ? " self" : ""}${isAgent ? " agent" : ""}`}
|
|
66
|
+
>
|
|
67
|
+
<span className="author">{m.userId}</span>
|
|
68
|
+
<span>{m.content}</span>
|
|
69
|
+
</li>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</ul>
|
|
73
|
+
|
|
74
|
+
{agentTyping ? <p className="typing">{agentHandle} is thinking…</p> : null}
|
|
75
|
+
|
|
76
|
+
{(toolThreadEvents.length > 0 || lastAgentRun?.toolCalls?.length) ? (
|
|
77
|
+
<div className="tools">
|
|
78
|
+
<h3>Agent tools</h3>
|
|
79
|
+
<ul>
|
|
80
|
+
{toolThreadEvents.map((ev) => (
|
|
81
|
+
<li key={ev.key}>
|
|
82
|
+
{String(ev.kind ?? "tool")}: {String(ev.title ?? ev.toolName ?? ev.key)}
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
{(lastAgentRun?.toolCalls ?? []).map((tc) => (
|
|
86
|
+
<li key={tc.id}>
|
|
87
|
+
{tc.name}: {tc.status ?? "done"}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
|
|
94
|
+
{invokeError ? <p className="hint" style={{ color: "#b91c1c", padding: "0 1rem" }}>{invokeError}</p> : null}
|
|
95
|
+
|
|
96
|
+
<form
|
|
97
|
+
className="composer"
|
|
98
|
+
onSubmit={(e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
const text = draft.trim();
|
|
101
|
+
if (!text) return;
|
|
102
|
+
void handleSendMessage(text);
|
|
103
|
+
setDraft("");
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="composer-row">
|
|
107
|
+
<input
|
|
108
|
+
value={draft}
|
|
109
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
110
|
+
placeholder="Message or ask @assistant…"
|
|
111
|
+
aria-label="Message"
|
|
112
|
+
/>
|
|
113
|
+
<button type="submit">Send</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className="secondary"
|
|
117
|
+
disabled={!draft.trim()}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
const text = draft.trim();
|
|
120
|
+
if (!text) return;
|
|
121
|
+
const payload = text.startsWith("@") ? text : `${agentHandle} ${text}`;
|
|
122
|
+
void handleInvokeAgent(payload);
|
|
123
|
+
setDraft("");
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
Ask agent
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
<p className="hint">
|
|
130
|
+
Realtime via WebSocket · Agent replies stream in-room · Tool calls appear above
|
|
131
|
+
</p>
|
|
132
|
+
</form>
|
|
133
|
+
</section>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function App() {
|
|
138
|
+
const client = useMemo(() => {
|
|
139
|
+
if (!workerUrl || !memberJwt) return null;
|
|
140
|
+
return new FluxyChatClient({
|
|
141
|
+
baseUrl: workerUrl,
|
|
142
|
+
userId: memberUserId,
|
|
143
|
+
token: memberJwt,
|
|
144
|
+
});
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
if (!workerUrl || !memberJwt) {
|
|
148
|
+
return (
|
|
149
|
+
<main className="shell">
|
|
150
|
+
<div className="error-box">
|
|
151
|
+
<p>
|
|
152
|
+
<strong>Setup required.</strong> Run provisioning against a local FluxyChat worker:
|
|
153
|
+
</p>
|
|
154
|
+
<pre>
|
|
155
|
+
<code>{`# Terminal 1 — from FluxyChat monorepo
|
|
156
|
+
pnpm --filter @fluxy-chat/worker dev
|
|
157
|
+
|
|
158
|
+
# Terminal 2 — in this project
|
|
159
|
+
pnpm setup
|
|
160
|
+
pnpm dev`}</code>
|
|
161
|
+
</pre>
|
|
162
|
+
<p>
|
|
163
|
+
Or copy <code>.env.example</code> → <code>.env</code> with credentials from{" "}
|
|
164
|
+
<a href="https://fluxychat.com/onboarding" target="_blank" rel="noreferrer">
|
|
165
|
+
fluxychat.com/onboarding
|
|
166
|
+
</a>
|
|
167
|
+
.
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
</main>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!client) return null;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<main className="shell">
|
|
178
|
+
<div className="hero">
|
|
179
|
+
<h1>FluxyChat — full stack starter</h1>
|
|
180
|
+
<p>
|
|
181
|
+
Your app · Worker {workerUrl} ·{" "}
|
|
182
|
+
<a href={`${consoleUrl.replace(/\/$/, "")}/onboarding?from=cli`} target="_blank" rel="noreferrer">
|
|
183
|
+
Keep this project
|
|
184
|
+
</a>
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div className="layout">
|
|
189
|
+
<FluxyRealtimeProvider client={client}>
|
|
190
|
+
<ChatRoom />
|
|
191
|
+
</FluxyRealtimeProvider>
|
|
192
|
+
|
|
193
|
+
<aside className="side">
|
|
194
|
+
<div className="card">
|
|
195
|
+
<h2>Project</h2>
|
|
196
|
+
<dl>
|
|
197
|
+
<div>
|
|
198
|
+
<dt>Project ID</dt>
|
|
199
|
+
<dd>{projectId || "—"}</dd>
|
|
200
|
+
</div>
|
|
201
|
+
<div>
|
|
202
|
+
<dt>Room</dt>
|
|
203
|
+
<dd>{roomId}</dd>
|
|
204
|
+
</div>
|
|
205
|
+
<div>
|
|
206
|
+
<dt>Agent</dt>
|
|
207
|
+
<dd>{agentHandle}{agentId ? ` (${agentId.slice(0, 12)}…)` : ""}</dd>
|
|
208
|
+
</div>
|
|
209
|
+
</dl>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="card">
|
|
212
|
+
<h2>Try next</h2>
|
|
213
|
+
<ul style={{ margin: 0, paddingLeft: "1.1rem" }}>
|
|
214
|
+
<li>Open a second tab — same URL — for realtime</li>
|
|
215
|
+
<li>Ask the agent about FluxyChat architecture</li>
|
|
216
|
+
<li>Manage rooms & agents in the console</li>
|
|
217
|
+
</ul>
|
|
218
|
+
</div>
|
|
219
|
+
</aside>
|
|
220
|
+
</div>
|
|
221
|
+
</main>
|
|
222
|
+
);
|
|
223
|
+
}
|