fluxy-bot 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/bin/cli.js +469 -0
- package/client/index.html +13 -0
- package/client/public/fluxy.png +0 -0
- package/client/public/icons/claude.png +0 -0
- package/client/public/icons/codex.png +0 -0
- package/client/public/icons/openai.svg +15 -0
- package/client/src/App.tsx +81 -0
- package/client/src/components/Chat/ChatView.tsx +19 -0
- package/client/src/components/Chat/InputBar.tsx +242 -0
- package/client/src/components/Chat/MessageBubble.tsx +20 -0
- package/client/src/components/Chat/MessageList.tsx +39 -0
- package/client/src/components/Chat/TypingIndicator.tsx +10 -0
- package/client/src/components/Dashboard/ConversationAnalytics.tsx +84 -0
- package/client/src/components/Dashboard/DashboardPage.tsx +52 -0
- package/client/src/components/Dashboard/PromoCard.tsx +44 -0
- package/client/src/components/Dashboard/ReportCard.tsx +35 -0
- package/client/src/components/Dashboard/TodayStats.tsx +28 -0
- package/client/src/components/ErrorBoundary.tsx +23 -0
- package/client/src/components/FluxyFab.tsx +25 -0
- package/client/src/components/Layout/ConnectionStatus.tsx +8 -0
- package/client/src/components/Layout/DashboardHeader.tsx +90 -0
- package/client/src/components/Layout/DashboardLayout.tsx +24 -0
- package/client/src/components/Layout/Header.tsx +10 -0
- package/client/src/components/Layout/MobileNav.tsx +30 -0
- package/client/src/components/Layout/Sidebar.tsx +55 -0
- package/client/src/components/Onboard/OnboardWizard.tsx +763 -0
- package/client/src/components/ui/avatar.tsx +109 -0
- package/client/src/components/ui/badge.tsx +48 -0
- package/client/src/components/ui/button.tsx +64 -0
- package/client/src/components/ui/card.tsx +92 -0
- package/client/src/components/ui/dialog.tsx +156 -0
- package/client/src/components/ui/dropdown-menu.tsx +257 -0
- package/client/src/components/ui/input.tsx +21 -0
- package/client/src/components/ui/scroll-area.tsx +58 -0
- package/client/src/components/ui/select.tsx +190 -0
- package/client/src/components/ui/separator.tsx +28 -0
- package/client/src/components/ui/sheet.tsx +141 -0
- package/client/src/components/ui/skeleton.tsx +13 -0
- package/client/src/components/ui/switch.tsx +33 -0
- package/client/src/components/ui/tabs.tsx +89 -0
- package/client/src/components/ui/textarea.tsx +18 -0
- package/client/src/components/ui/tooltip.tsx +55 -0
- package/client/src/hooks/useChat.ts +69 -0
- package/client/src/hooks/useMobile.ts +16 -0
- package/client/src/hooks/useWebSocket.ts +24 -0
- package/client/src/lib/mock-data.ts +104 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/lib/ws-client.ts +52 -0
- package/client/src/main.tsx +10 -0
- package/client/src/styles/globals.css +55 -0
- package/components.json +20 -0
- package/dist/assets/index-BkNWpS06.css +1 -0
- package/dist/assets/index-CX3QeqQ8.js +64 -0
- package/dist/fluxy.png +0 -0
- package/dist/icons/claude.png +0 -0
- package/dist/icons/codex.png +0 -0
- package/dist/icons/openai.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/registerSW.js +1 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-8c29f6e4.js +1 -0
- package/package.json +82 -0
- package/postcss.config.js +5 -0
- package/shared/ai.ts +141 -0
- package/shared/config.ts +37 -0
- package/shared/logger.ts +13 -0
- package/shared/paths.ts +14 -0
- package/shared/relay.ts +101 -0
- package/supervisor/fluxy.html +94 -0
- package/supervisor/index.ts +173 -0
- package/supervisor/tunnel.ts +62 -0
- package/supervisor/worker.ts +55 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +38 -0
- package/worker/claude-auth.ts +224 -0
- package/worker/codex-auth.ts +199 -0
- package/worker/db.ts +75 -0
- package/worker/index.ts +169 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex OAuth PKCE flow for ChatGPT Plus/Pro subscription authentication.
|
|
3
|
+
* Adapted from CodeDeck's CodexOAuthService for server-side Node.js.
|
|
4
|
+
*
|
|
5
|
+
* Spins up a temporary HTTP server on port 1455 to capture the OAuth callback.
|
|
6
|
+
* Credentials are stored in ~/.codex/codedeck-auth.json.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { log } from '../shared/logger.js';
|
|
15
|
+
|
|
16
|
+
const OAUTH_CONFIG = {
|
|
17
|
+
AUTHORIZE_URL: 'https://auth.openai.com/oauth/authorize',
|
|
18
|
+
TOKEN_URL: 'https://auth.openai.com/oauth/token',
|
|
19
|
+
REDIRECT_URI: 'http://localhost:1455/auth/callback',
|
|
20
|
+
CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
|
21
|
+
SCOPES: 'openid profile email offline_access',
|
|
22
|
+
PORT: 1455,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const AUTH_DIR = path.join(os.homedir(), '.codex');
|
|
26
|
+
const AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json');
|
|
27
|
+
|
|
28
|
+
let codeVerifier: string | null = null;
|
|
29
|
+
let oauthState: string | null = null;
|
|
30
|
+
let callbackServer: http.Server | null = null;
|
|
31
|
+
|
|
32
|
+
/* ── Helpers ── */
|
|
33
|
+
|
|
34
|
+
function stopCallbackServer(): void {
|
|
35
|
+
if (callbackServer) {
|
|
36
|
+
try { callbackServer.close(); } catch {}
|
|
37
|
+
callbackServer = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function storeCredentials(tokens: any): void {
|
|
42
|
+
if (!fs.existsSync(AUTH_DIR)) {
|
|
43
|
+
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const credentials: Record<string, any> = {
|
|
47
|
+
access_token: tokens.access_token,
|
|
48
|
+
token_type: tokens.token_type || 'Bearer',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (tokens.refresh_token) credentials.refresh_token = tokens.refresh_token;
|
|
52
|
+
if (tokens.id_token) credentials.id_token = tokens.id_token;
|
|
53
|
+
if (tokens.expires_in) {
|
|
54
|
+
credentials.expires_at = Date.now() + (tokens.expires_in - 300) * 1000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Decode JWT claims for account info
|
|
58
|
+
try {
|
|
59
|
+
const payload = JSON.parse(Buffer.from(tokens.access_token.split('.')[1], 'base64url').toString());
|
|
60
|
+
const authClaims = payload['https://api.openai.com/auth'] || {};
|
|
61
|
+
if (authClaims.chatgpt_account_id) credentials.chatgpt_account_id = authClaims.chatgpt_account_id;
|
|
62
|
+
if (authClaims.chatgpt_plan_type) credentials.chatgpt_plan_type = authClaims.chatgpt_plan_type;
|
|
63
|
+
} catch {
|
|
64
|
+
log.warn('Codex: failed to decode JWT claims');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(AUTH_FILE, JSON.stringify(credentials, null, 2), 'utf-8');
|
|
68
|
+
try { fs.chmodSync(AUTH_FILE, 0o600); } catch {}
|
|
69
|
+
log.ok('Codex credentials stored');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function exchangeCode(code: string): Promise<{ success: boolean; error?: string }> {
|
|
73
|
+
if (!codeVerifier) {
|
|
74
|
+
return { success: false, error: 'No code verifier — OAuth flow was not started.' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const payload = new URLSearchParams({
|
|
79
|
+
grant_type: 'authorization_code',
|
|
80
|
+
client_id: OAUTH_CONFIG.CLIENT_ID,
|
|
81
|
+
code,
|
|
82
|
+
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
|
83
|
+
code_verifier: codeVerifier,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
89
|
+
body: payload.toString(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
return { success: false, error: `Authentication failed (${response.status}). Please try again.` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tokens = await response.json();
|
|
97
|
+
storeCredentials(tokens);
|
|
98
|
+
codeVerifier = null;
|
|
99
|
+
oauthState = null;
|
|
100
|
+
return { success: true };
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
return { success: false, error: err.message };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── Public API ── */
|
|
107
|
+
|
|
108
|
+
export function startCodexOAuth(): Promise<{ success: boolean; authUrl?: string; error?: string }> {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
stopCallbackServer();
|
|
111
|
+
|
|
112
|
+
// Generate PKCE
|
|
113
|
+
codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
114
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
115
|
+
oauthState = crypto.randomUUID();
|
|
116
|
+
|
|
117
|
+
// Start local callback server
|
|
118
|
+
callbackServer = http.createServer((req, res) => {
|
|
119
|
+
if (!req.url?.startsWith('/auth/callback')) {
|
|
120
|
+
res.writeHead(404);
|
|
121
|
+
res.end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.PORT}`);
|
|
126
|
+
const code = url.searchParams.get('code');
|
|
127
|
+
const returnedState = url.searchParams.get('state');
|
|
128
|
+
const error = url.searchParams.get('error');
|
|
129
|
+
|
|
130
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
131
|
+
res.end(`<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0a0a0a;color:#fff">
|
|
132
|
+
<h2>${error ? 'Authentication Failed' : 'Authenticated!'}</h2>
|
|
133
|
+
<p style="color:#888">${error || 'You can close this tab and return to Fluxy.'}</p>
|
|
134
|
+
</body></html>`);
|
|
135
|
+
|
|
136
|
+
stopCallbackServer();
|
|
137
|
+
|
|
138
|
+
if (error || !code || returnedState !== oauthState) return;
|
|
139
|
+
exchangeCode(code);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
callbackServer.listen(OAUTH_CONFIG.PORT, '127.0.0.1', () => {
|
|
143
|
+
log.ok(`Codex OAuth callback server on port ${OAUTH_CONFIG.PORT}`);
|
|
144
|
+
|
|
145
|
+
const params = new URLSearchParams({
|
|
146
|
+
response_type: 'code',
|
|
147
|
+
client_id: OAUTH_CONFIG.CLIENT_ID,
|
|
148
|
+
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
|
149
|
+
scope: OAUTH_CONFIG.SCOPES,
|
|
150
|
+
code_challenge: codeChallenge,
|
|
151
|
+
code_challenge_method: 'S256',
|
|
152
|
+
state: oauthState!,
|
|
153
|
+
id_token_add_organizations: 'true',
|
|
154
|
+
codex_cli_simplified_flow: 'true',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
resolve({ success: true, authUrl: `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
callbackServer.on('error', (err: any) => {
|
|
161
|
+
const error = err.code === 'EADDRINUSE'
|
|
162
|
+
? `Port ${OAUTH_CONFIG.PORT} is busy. Close other Codex instances.`
|
|
163
|
+
: err.message;
|
|
164
|
+
resolve({ success: false, error });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function cancelCodexOAuth(): void {
|
|
170
|
+
stopCallbackServer();
|
|
171
|
+
codeVerifier = null;
|
|
172
|
+
oauthState = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getCodexAuthStatus(): { authenticated: boolean; plan?: string; error?: string } {
|
|
176
|
+
try {
|
|
177
|
+
if (!fs.existsSync(AUTH_FILE)) return { authenticated: false };
|
|
178
|
+
const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
|
|
179
|
+
if (!creds.access_token) return { authenticated: false };
|
|
180
|
+
if (creds.expires_at && Date.now() >= creds.expires_at) {
|
|
181
|
+
return { authenticated: false, error: 'Token expired' };
|
|
182
|
+
}
|
|
183
|
+
return { authenticated: true, plan: creds.chatgpt_plan_type || 'plus' };
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
return { authenticated: false, error: err.message };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function readCodexAccessToken(): string | null {
|
|
190
|
+
try {
|
|
191
|
+
if (!fs.existsSync(AUTH_FILE)) return null;
|
|
192
|
+
const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
|
|
193
|
+
if (!creds.access_token) return null;
|
|
194
|
+
if (creds.expires_at && Date.now() >= creds.expires_at) return null;
|
|
195
|
+
return creds.access_token;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/worker/db.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { paths, DATA_DIR } from '../shared/paths.js';
|
|
4
|
+
|
|
5
|
+
const SCHEMA = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
7
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
8
|
+
title TEXT,
|
|
9
|
+
model TEXT,
|
|
10
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
11
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
12
|
+
);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
14
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
15
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
16
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
|
17
|
+
content TEXT NOT NULL,
|
|
18
|
+
tokens_in INTEGER,
|
|
19
|
+
tokens_out INTEGER,
|
|
20
|
+
model TEXT,
|
|
21
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id, created_at);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
25
|
+
key TEXT PRIMARY KEY,
|
|
26
|
+
value TEXT NOT NULL,
|
|
27
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
28
|
+
);
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
let db: Database.Database;
|
|
32
|
+
|
|
33
|
+
export function initDb(): void {
|
|
34
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
35
|
+
db = new Database(paths.db);
|
|
36
|
+
db.pragma('journal_mode = WAL');
|
|
37
|
+
db.pragma('foreign_keys = ON');
|
|
38
|
+
db.exec(SCHEMA);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function closeDb(): void { db?.close(); }
|
|
42
|
+
|
|
43
|
+
// Conversations
|
|
44
|
+
export function createConversation(title?: string, model?: string) {
|
|
45
|
+
return db.prepare('INSERT INTO conversations (title, model) VALUES (?, ?) RETURNING *').get(title ?? null, model ?? null) as any;
|
|
46
|
+
}
|
|
47
|
+
export function listConversations(limit = 50) {
|
|
48
|
+
return db.prepare('SELECT * FROM conversations ORDER BY updated_at DESC LIMIT ?').all(limit);
|
|
49
|
+
}
|
|
50
|
+
export function deleteConversation(id: string) {
|
|
51
|
+
db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Messages
|
|
55
|
+
export function addMessage(convId: string, role: string, content: string, meta?: { tokens_in?: number; tokens_out?: number; model?: string }) {
|
|
56
|
+
const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model) VALUES (?, ?, ?, ?, ?, ?) RETURNING *')
|
|
57
|
+
.get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null);
|
|
58
|
+
db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
|
|
59
|
+
return msg as any;
|
|
60
|
+
}
|
|
61
|
+
export function getMessages(convId: string) {
|
|
62
|
+
return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Settings
|
|
66
|
+
export function getSetting(key: string): string | undefined {
|
|
67
|
+
return (db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as any)?.value;
|
|
68
|
+
}
|
|
69
|
+
export function setSetting(key: string, value: string) {
|
|
70
|
+
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(key, value);
|
|
71
|
+
}
|
|
72
|
+
export function getAllSettings() {
|
|
73
|
+
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
|
74
|
+
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
75
|
+
}
|
package/worker/index.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
5
|
+
import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
|
|
6
|
+
import { paths } from '../shared/paths.js';
|
|
7
|
+
import { log } from '../shared/logger.js';
|
|
8
|
+
import { initDb, closeDb, createConversation, listConversations, deleteConversation, addMessage, getMessages, getSetting, getAllSettings, setSetting } from './db.js';
|
|
9
|
+
import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
|
|
10
|
+
import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
|
|
11
|
+
|
|
12
|
+
const port = parseInt(process.env.WORKER_PORT || '3001', 10);
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
|
|
15
|
+
// Database
|
|
16
|
+
initDb();
|
|
17
|
+
|
|
18
|
+
// AI provider (for chatbot feature)
|
|
19
|
+
let ai: AiProvider | null = null;
|
|
20
|
+
if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
|
|
21
|
+
ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
|
|
22
|
+
log.ok(`Worker AI: ${config.ai.provider} (${config.ai.model})`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Express
|
|
26
|
+
const app = express();
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
|
|
29
|
+
app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
|
|
30
|
+
app.get('/api/conversations', (_, res) => res.json(listConversations()));
|
|
31
|
+
app.get('/api/conversations/:id', (req, res) => {
|
|
32
|
+
const msgs = getMessages(req.params.id);
|
|
33
|
+
res.json({ id: req.params.id, messages: msgs });
|
|
34
|
+
});
|
|
35
|
+
app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
|
|
36
|
+
app.get('/api/settings', (_, res) => res.json(getAllSettings()));
|
|
37
|
+
app.put('/api/settings/:key', (req, res) => {
|
|
38
|
+
setSetting(req.params.key, req.body.value);
|
|
39
|
+
res.json({ ok: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Codex OAuth routes ──
|
|
43
|
+
|
|
44
|
+
app.post('/api/auth/codex/start', async (_req, res) => {
|
|
45
|
+
const result = await startCodexOAuth();
|
|
46
|
+
res.json(result);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
app.post('/api/auth/codex/cancel', (_req, res) => {
|
|
50
|
+
cancelCodexOAuth();
|
|
51
|
+
res.json({ ok: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
app.get('/api/auth/codex/status', (_req, res) => {
|
|
55
|
+
res.json(getCodexAuthStatus());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Claude OAuth routes ──
|
|
59
|
+
|
|
60
|
+
app.post('/api/auth/claude/start', (_req, res) => {
|
|
61
|
+
res.json(startClaudeOAuth());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
app.post('/api/auth/claude/exchange', async (req, res) => {
|
|
65
|
+
const { code } = req.body;
|
|
66
|
+
if (!code) { res.json({ success: false, error: 'No code provided' }); return; }
|
|
67
|
+
const result = await exchangeClaudeCode(code);
|
|
68
|
+
res.json(result);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.get('/api/auth/claude/status', (_req, res) => {
|
|
72
|
+
res.json(getClaudeAuthStatus());
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Onboarding ──
|
|
76
|
+
|
|
77
|
+
app.post('/api/onboard', (req, res) => {
|
|
78
|
+
const { userName, agentName, provider, model, apiKey, baseUrl } = req.body;
|
|
79
|
+
setSetting('user_name', userName || '');
|
|
80
|
+
setSetting('agent_name', agentName || 'Fluxy');
|
|
81
|
+
setSetting('onboard_complete', 'true');
|
|
82
|
+
|
|
83
|
+
config.ai.provider = provider || '';
|
|
84
|
+
config.ai.model = model || '';
|
|
85
|
+
config.ai.baseUrl = baseUrl || undefined;
|
|
86
|
+
|
|
87
|
+
// Read OAuth token if no API key provided
|
|
88
|
+
if (!apiKey && provider === 'openai') {
|
|
89
|
+
config.ai.apiKey = readCodexAccessToken() || '';
|
|
90
|
+
} else if (!apiKey && provider === 'anthropic') {
|
|
91
|
+
config.ai.apiKey = readClaudeAccessToken() || '';
|
|
92
|
+
} else {
|
|
93
|
+
config.ai.apiKey = apiKey || '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
saveConfig(config);
|
|
97
|
+
|
|
98
|
+
if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
|
|
99
|
+
ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
|
|
100
|
+
log.ok(`AI reconfigured: ${config.ai.provider} (${config.ai.model})`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
res.json({ ok: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Serve PWA
|
|
107
|
+
app.use(express.static(paths.dist));
|
|
108
|
+
app.get('/{*splat}', (_, res) => res.sendFile('index.html', { root: paths.dist }));
|
|
109
|
+
|
|
110
|
+
// HTTP + WebSocket
|
|
111
|
+
const server = createServer(app);
|
|
112
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
113
|
+
|
|
114
|
+
const activeStreams = new Map<string, AbortController>();
|
|
115
|
+
|
|
116
|
+
wss.on('connection', (ws: WebSocket) => {
|
|
117
|
+
ws.on('message', (raw) => {
|
|
118
|
+
const msg = JSON.parse(raw.toString());
|
|
119
|
+
|
|
120
|
+
if (msg.type === 'user:message') {
|
|
121
|
+
const { conversationId, content } = msg.data;
|
|
122
|
+
let convId = conversationId;
|
|
123
|
+
if (!convId) convId = createConversation(content.slice(0, 50), config.ai.model).id;
|
|
124
|
+
|
|
125
|
+
addMessage(convId, 'user', content);
|
|
126
|
+
|
|
127
|
+
if (!ai) {
|
|
128
|
+
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const history = getMessages(convId) as { role: string; content: string }[];
|
|
133
|
+
const systemPrompt = getSetting('system_prompt');
|
|
134
|
+
const messages: ChatMessage[] = [
|
|
135
|
+
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
|
|
136
|
+
...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
|
|
140
|
+
const ctrl = new AbortController();
|
|
141
|
+
activeStreams.set(convId, ctrl);
|
|
142
|
+
|
|
143
|
+
ai.chat(
|
|
144
|
+
messages,
|
|
145
|
+
config.ai.model,
|
|
146
|
+
(token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
|
|
147
|
+
(full, usage) => {
|
|
148
|
+
const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: config.ai.model });
|
|
149
|
+
ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
|
|
150
|
+
activeStreams.delete(convId);
|
|
151
|
+
},
|
|
152
|
+
(err) => {
|
|
153
|
+
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
|
|
154
|
+
activeStreams.delete(convId);
|
|
155
|
+
},
|
|
156
|
+
ctrl.signal,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (msg.type === 'user:stop') {
|
|
161
|
+
const ctrl = activeStreams.get(msg.data.conversationId);
|
|
162
|
+
if (ctrl) { ctrl.abort(); activeStreams.delete(msg.data.conversationId); }
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
server.listen(port, () => log.ok(`Worker on port ${port}`));
|
|
168
|
+
|
|
169
|
+
process.on('SIGTERM', () => { closeDb(); server.close(); process.exit(0); });
|