fluxy-bot 0.12.7 → 0.13.1
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/package.json +5 -5
- package/shared/config.ts +12 -0
- package/supervisor/channels/chat-channel.ts +44 -0
- package/supervisor/channels/index.ts +48 -0
- package/supervisor/channels/role-resolver.ts +41 -0
- package/supervisor/channels/router.ts +245 -0
- package/supervisor/channels/types.ts +55 -0
- package/supervisor/fluxy-agent.ts +157 -0
- package/supervisor/index.ts +30 -0
- package/supervisor/scheduler.ts +9 -0
- package/worker/db.ts +62 -0
- package/worker/index.ts +42 -1
- package/worker/prompts/customer-system-prompt.txt +46 -0
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +58 -49
- package/workspace/client/src/components/ui/sheet.tsx +1 -1
- /package/workspace/skills/code-reviewer/{skills/code-reviewer/SKILL.md → SKILL.md} +0 -0
- /package/workspace/skills/daily-standup/{skills/daily-standup/SKILL.md → SKILL.md} +0 -0
- /package/workspace/skills/workspace-helper/{skills/workspace-helper/SKILL.md → SKILL.md} +0 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fluxy-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"releaseNotes": [
|
|
5
|
-
"
|
|
6
|
-
"2. ",
|
|
7
|
-
"3. ",
|
|
8
|
-
"4. "
|
|
5
|
+
"1. react router implemented",
|
|
6
|
+
"2. new workspace design",
|
|
7
|
+
"3. tour",
|
|
8
|
+
"4. worspace helth checker"
|
|
9
9
|
],
|
|
10
10
|
"description": "Self-hosted, self-evolving AI agent with its own dashboard.",
|
|
11
11
|
"type": "module",
|
package/shared/config.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import { paths, DATA_DIR } from './paths.js';
|
|
3
3
|
|
|
4
|
+
export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
|
|
5
|
+
|
|
4
6
|
export interface BotConfig {
|
|
5
7
|
port: number;
|
|
6
8
|
username: string;
|
|
@@ -26,6 +28,16 @@ export interface BotConfig {
|
|
|
26
28
|
address: string;
|
|
27
29
|
};
|
|
28
30
|
tunnelUrl?: string;
|
|
31
|
+
channels?: {
|
|
32
|
+
whatsapp?: { enabled: boolean };
|
|
33
|
+
telegram?: { enabled: boolean; botToken?: string };
|
|
34
|
+
};
|
|
35
|
+
customerMode?: {
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
systemPromptFile?: string;
|
|
38
|
+
businessName?: string;
|
|
39
|
+
businessDescription?: string;
|
|
40
|
+
};
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
const DEFAULTS: BotConfig = {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Channel — wraps the existing WebSocket chat as a Channel.
|
|
3
|
+
* Chat is ALWAYS the owner channel. This adapter bridges the existing
|
|
4
|
+
* WS broadcast system with the Channel interface.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Channel, ChannelRouter, OutgoingMessage } from './types.js';
|
|
8
|
+
|
|
9
|
+
export interface ChatChannelOpts {
|
|
10
|
+
broadcastFluxy: (type: string, data: any) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ChatChannel implements Channel {
|
|
14
|
+
readonly type = 'chat' as const;
|
|
15
|
+
private opts: ChatChannelOpts;
|
|
16
|
+
|
|
17
|
+
constructor(opts: ChatChannelOpts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async initialize(_router: ChannelRouter): Promise<void> {
|
|
22
|
+
// Chat channel is always initialized — WS server is managed by supervisor
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async sendMessage(_to: string, message: OutgoingMessage): Promise<void> {
|
|
26
|
+
// Route through existing broadcast system
|
|
27
|
+
this.opts.broadcastFluxy('chat:sync', {
|
|
28
|
+
conversationId: message.conversationKey,
|
|
29
|
+
message: {
|
|
30
|
+
role: 'assistant',
|
|
31
|
+
content: message.content,
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ownsConversation(conversationKey: string): boolean {
|
|
38
|
+
return conversationKey.startsWith('chat:');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async shutdown(): Promise<void> {
|
|
42
|
+
// WS server lifecycle is managed by supervisor
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel registry — initializes and registers all enabled channels.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { BotConfig } from '../../shared/config.js';
|
|
6
|
+
import { log } from '../../shared/logger.js';
|
|
7
|
+
import { initRoleResolver } from './role-resolver.js';
|
|
8
|
+
import { ChannelRouter, type ChannelRouterOpts } from './router.js';
|
|
9
|
+
import { ChatChannel } from './chat-channel.js';
|
|
10
|
+
|
|
11
|
+
export { ChannelRouter } from './router.js';
|
|
12
|
+
export type { Channel, ChannelType, SenderRole, SenderIdentity, IncomingMessage, OutgoingMessage } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create and initialize the channel router with all enabled channels.
|
|
16
|
+
*/
|
|
17
|
+
export async function initializeChannels(
|
|
18
|
+
config: BotConfig,
|
|
19
|
+
opts: ChannelRouterOpts,
|
|
20
|
+
): Promise<ChannelRouter> {
|
|
21
|
+
// Initialize the role resolver with worker API access
|
|
22
|
+
initRoleResolver({ workerApi: opts.workerApi });
|
|
23
|
+
|
|
24
|
+
// Create the router
|
|
25
|
+
const router = new ChannelRouter(opts);
|
|
26
|
+
|
|
27
|
+
// Chat channel is always registered
|
|
28
|
+
const chatChannel = new ChatChannel({ broadcastFluxy: opts.broadcastFluxy });
|
|
29
|
+
router.registerChannel(chatChannel);
|
|
30
|
+
await chatChannel.initialize(router);
|
|
31
|
+
|
|
32
|
+
// WhatsApp channel — registered if enabled in config
|
|
33
|
+
// The actual implementation is done by Fluxy as a skill.
|
|
34
|
+
// See docs/whatsapp-channel-guide.md for implementation details.
|
|
35
|
+
if (config.channels?.whatsapp?.enabled) {
|
|
36
|
+
log.info('[channels] WhatsApp is enabled in config — waiting for channel implementation to register');
|
|
37
|
+
// The WhatsApp channel will self-register via router.registerChannel()
|
|
38
|
+
// when the skill implementation calls it during initialization.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Telegram channel — placeholder for future implementation
|
|
42
|
+
if (config.channels?.telegram?.enabled) {
|
|
43
|
+
log.info('[channels] Telegram is enabled in config — waiting for channel implementation to register');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
log.ok(`[channels] Initialized with ${router.getAllChannels().length} channel(s)`);
|
|
47
|
+
return router;
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role resolver for multi-channel access control.
|
|
3
|
+
* Chat is ALWAYS owner. Other channels look up the contacts DB.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChannelType, SenderRole } from './types.js';
|
|
7
|
+
|
|
8
|
+
export interface RoleResolverOpts {
|
|
9
|
+
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let opts: RoleResolverOpts | null = null;
|
|
13
|
+
|
|
14
|
+
export function initRoleResolver(resolverOpts: RoleResolverOpts) {
|
|
15
|
+
opts = resolverOpts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the role of a sender.
|
|
20
|
+
* Chat channel is ALWAYS owner — no lookup needed.
|
|
21
|
+
* Other channels check the contacts DB via the worker API.
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveRole(channelType: ChannelType, senderId: string): Promise<SenderRole> {
|
|
24
|
+
// Chat is sacred — always the owner
|
|
25
|
+
if (channelType === 'chat') return 'owner';
|
|
26
|
+
|
|
27
|
+
if (!opts) return 'customer';
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = await opts.workerApi(
|
|
31
|
+
`/api/contacts/resolve?channel_type=${encodeURIComponent(channelType)}&identifier=${encodeURIComponent(senderId)}`
|
|
32
|
+
);
|
|
33
|
+
if (result?.role && ['owner', 'admin', 'customer'].includes(result.role)) {
|
|
34
|
+
return result.role as SenderRole;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// If lookup fails, default to customer (safest)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return 'customer';
|
|
41
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Router — central orchestrator for multi-channel message handling.
|
|
3
|
+
* Receives messages from any channel, resolves roles, picks the right agent,
|
|
4
|
+
* and routes responses back to the originating channel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { log } from '../../shared/logger.js';
|
|
8
|
+
import { resolveRole } from './role-resolver.js';
|
|
9
|
+
import { startFluxyAgentQuery, startCustomerAgentQuery, type RecentMessage, type CustomerContext } from '../fluxy-agent.js';
|
|
10
|
+
import type { Channel, ChannelType, IncomingMessage, OutgoingMessage, SenderRole, ChannelRouter as IChannelRouter } from './types.js';
|
|
11
|
+
import { loadConfig } from '../../shared/config.js';
|
|
12
|
+
|
|
13
|
+
export interface ChannelRouterOpts {
|
|
14
|
+
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
15
|
+
broadcastFluxy: (type: string, data: any) => void;
|
|
16
|
+
getModel: () => string;
|
|
17
|
+
restartBackend: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Per-conversationKey lock to allow concurrent queries across channels
|
|
21
|
+
const activeChannelQueries = new Map<string, boolean>();
|
|
22
|
+
|
|
23
|
+
export class ChannelRouter implements IChannelRouter {
|
|
24
|
+
private channels = new Map<ChannelType, Channel>();
|
|
25
|
+
private opts: ChannelRouterOpts;
|
|
26
|
+
|
|
27
|
+
constructor(opts: ChannelRouterOpts) {
|
|
28
|
+
this.opts = opts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
registerChannel(channel: Channel): void {
|
|
32
|
+
this.channels.set(channel.type, channel);
|
|
33
|
+
log.info(`[router] Registered channel: ${channel.type}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getChannel(type: ChannelType): Channel | undefined {
|
|
37
|
+
return this.channels.get(type);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get all registered channels (for shutdown, etc.) */
|
|
41
|
+
getAllChannels(): Channel[] {
|
|
42
|
+
return Array.from(this.channels.values());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getChannelForConversation(conversationKey: string): Channel | undefined {
|
|
46
|
+
const channelType = conversationKey.split(':')[0] as ChannelType;
|
|
47
|
+
return this.channels.get(channelType);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle an incoming message from any channel.
|
|
52
|
+
* Resolves role, creates/reuses conversation, dispatches to the right agent.
|
|
53
|
+
*/
|
|
54
|
+
async handleIncoming(message: IncomingMessage): Promise<void> {
|
|
55
|
+
const { sender, content } = message;
|
|
56
|
+
const { conversationKey, channelType, senderId, displayName } = sender;
|
|
57
|
+
|
|
58
|
+
// Resolve role (chat is always owner, others check contacts DB)
|
|
59
|
+
const role = await resolveRole(channelType, senderId);
|
|
60
|
+
sender.role = role;
|
|
61
|
+
|
|
62
|
+
log.info(`[router] Incoming from ${channelType}:${senderId} (${displayName}) role=${role} key=${conversationKey}`);
|
|
63
|
+
|
|
64
|
+
// Prevent duplicate queries for the same conversation key
|
|
65
|
+
if (activeChannelQueries.get(conversationKey)) {
|
|
66
|
+
log.warn(`[router] Query already active for ${conversationKey}, skipping`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
activeChannelQueries.set(conversationKey, true);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const model = this.opts.getModel();
|
|
73
|
+
|
|
74
|
+
// Get or create a conversation in the DB for this conversation key
|
|
75
|
+
const conv = await this.opts.workerApi('/api/conversations', 'POST', {
|
|
76
|
+
title: content.slice(0, 80),
|
|
77
|
+
model,
|
|
78
|
+
});
|
|
79
|
+
const convId = conv.id;
|
|
80
|
+
|
|
81
|
+
// Save user message to DB
|
|
82
|
+
await this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
83
|
+
role: 'user',
|
|
84
|
+
content,
|
|
85
|
+
meta: { model },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Fetch recent messages for context
|
|
89
|
+
let recentMessages: RecentMessage[] = [];
|
|
90
|
+
try {
|
|
91
|
+
const recentRaw = await this.opts.workerApi(`/api/conversations/${convId}/messages/recent?limit=20`) as any[];
|
|
92
|
+
if (Array.isArray(recentRaw)) {
|
|
93
|
+
const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
|
|
94
|
+
if (filtered.length > 0) {
|
|
95
|
+
recentMessages = filtered.slice(0, -1).map((m: any) => ({
|
|
96
|
+
role: m.role as 'user' | 'assistant',
|
|
97
|
+
content: m.content,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
// Get the originating channel for response routing
|
|
104
|
+
const channel = this.channels.get(channelType);
|
|
105
|
+
|
|
106
|
+
if (role === 'owner' || role === 'admin') {
|
|
107
|
+
await this.dispatchOwnerQuery(convId, conversationKey, content, model, channel, senderId, recentMessages, channelType);
|
|
108
|
+
} else {
|
|
109
|
+
await this.dispatchCustomerQuery(convId, conversationKey, content, model, channel, senderId, displayName, recentMessages, channelType);
|
|
110
|
+
}
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
log.warn(`[router] Error handling message from ${conversationKey}: ${err.message}`);
|
|
113
|
+
} finally {
|
|
114
|
+
activeChannelQueries.delete(conversationKey);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Dispatch a query for owner/admin — full Fluxy capabilities.
|
|
120
|
+
*/
|
|
121
|
+
private async dispatchOwnerQuery(
|
|
122
|
+
convId: string,
|
|
123
|
+
conversationKey: string,
|
|
124
|
+
content: string,
|
|
125
|
+
model: string,
|
|
126
|
+
channel: Channel | undefined,
|
|
127
|
+
senderId: string,
|
|
128
|
+
recentMessages: RecentMessage[],
|
|
129
|
+
channelType: ChannelType,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
// Fetch bot/human names
|
|
132
|
+
let botName = 'Fluxy', humanName = 'Human';
|
|
133
|
+
try {
|
|
134
|
+
const status = await this.opts.workerApi('/api/onboard/status');
|
|
135
|
+
botName = status.agentName || 'Fluxy';
|
|
136
|
+
humanName = status.userName || 'Human';
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
return new Promise<void>((resolve) => {
|
|
140
|
+
startFluxyAgentQuery(convId, content, model, (type, eventData) => {
|
|
141
|
+
if (type === 'bot:response' && eventData.content) {
|
|
142
|
+
// Save to DB
|
|
143
|
+
this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
144
|
+
role: 'assistant', content: eventData.content, meta: { model },
|
|
145
|
+
}).catch(() => {});
|
|
146
|
+
|
|
147
|
+
// Route response back to originating channel
|
|
148
|
+
if (channel && channelType !== 'chat') {
|
|
149
|
+
channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
|
|
150
|
+
log.warn(`[router] Failed to send response to ${channelType}: ${err.message}`);
|
|
151
|
+
});
|
|
152
|
+
// Also broadcast to chat UI so owner sees the exchange
|
|
153
|
+
this.opts.broadcastFluxy('chat:sync', {
|
|
154
|
+
conversationId: convId,
|
|
155
|
+
message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString(), channel: channelType },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (type === 'bot:done') {
|
|
161
|
+
if (eventData.usedFileTools) {
|
|
162
|
+
this.opts.restartBackend();
|
|
163
|
+
}
|
|
164
|
+
resolve();
|
|
165
|
+
}
|
|
166
|
+
}, undefined, undefined, { botName, humanName }, recentMessages);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Dispatch a query for customer — restricted capabilities.
|
|
172
|
+
*/
|
|
173
|
+
private async dispatchCustomerQuery(
|
|
174
|
+
convId: string,
|
|
175
|
+
conversationKey: string,
|
|
176
|
+
content: string,
|
|
177
|
+
model: string,
|
|
178
|
+
channel: Channel | undefined,
|
|
179
|
+
senderId: string,
|
|
180
|
+
displayName: string,
|
|
181
|
+
recentMessages: RecentMessage[],
|
|
182
|
+
channelType: ChannelType,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const config = loadConfig();
|
|
185
|
+
const customerMode = config.customerMode || {};
|
|
186
|
+
|
|
187
|
+
const customerContext: CustomerContext = {
|
|
188
|
+
senderName: displayName,
|
|
189
|
+
senderIdentifier: senderId,
|
|
190
|
+
channelType,
|
|
191
|
+
businessName: customerMode.businessName || 'Our Business',
|
|
192
|
+
businessDescription: customerMode.businessDescription || '',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return new Promise<void>((resolve) => {
|
|
196
|
+
startCustomerAgentQuery(conversationKey, content, model, (type, eventData) => {
|
|
197
|
+
if (type === 'bot:response' && eventData.content) {
|
|
198
|
+
// Save to DB
|
|
199
|
+
this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
200
|
+
role: 'assistant', content: eventData.content, meta: { model },
|
|
201
|
+
}).catch(() => {});
|
|
202
|
+
|
|
203
|
+
// Route response back to originating channel
|
|
204
|
+
if (channel) {
|
|
205
|
+
channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
|
|
206
|
+
log.warn(`[router] Failed to send customer response to ${channelType}: ${err.message}`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (type === 'bot:done') {
|
|
212
|
+
resolve();
|
|
213
|
+
}
|
|
214
|
+
}, customerContext, recentMessages);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Route a pulse/cron message to ALL owner-role channels.
|
|
220
|
+
* Customer channels NEVER receive autonomous messages.
|
|
221
|
+
*/
|
|
222
|
+
async routePulseMessage(content: string, title?: string, _priority?: string): Promise<void> {
|
|
223
|
+
for (const [type, channel] of this.channels) {
|
|
224
|
+
// Chat is handled by existing broadcastFluxy — skip to avoid duplicates
|
|
225
|
+
if (type === 'chat') continue;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Find all owner contacts for this channel type
|
|
229
|
+
const contacts = await this.opts.workerApi('/api/contacts') as any[];
|
|
230
|
+
const ownerContacts = (contacts || []).filter(
|
|
231
|
+
(c: any) => c.channel_type === type && c.role === 'owner'
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
for (const contact of ownerContacts) {
|
|
235
|
+
await channel.sendMessage(contact.identifier, {
|
|
236
|
+
content: title ? `**${title}**\n\n${content}` : content,
|
|
237
|
+
conversationKey: `${type}:${contact.identifier}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch (err: any) {
|
|
241
|
+
log.warn(`[router] Failed to route pulse to ${type}: ${err.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-channel communication types.
|
|
3
|
+
* Defines the channel abstraction, message formats, and role system.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
|
|
7
|
+
export type SenderRole = 'owner' | 'admin' | 'customer';
|
|
8
|
+
|
|
9
|
+
export interface SenderIdentity {
|
|
10
|
+
channelType: ChannelType;
|
|
11
|
+
senderId: string; // phone number, telegram ID, 'owner' for chat
|
|
12
|
+
displayName: string;
|
|
13
|
+
role: SenderRole;
|
|
14
|
+
conversationKey: string; // "whatsapp:+5511999999999", "chat:owner"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IncomingMessage {
|
|
18
|
+
sender: SenderIdentity;
|
|
19
|
+
content: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
attachments?: Array<{
|
|
22
|
+
type: 'image' | 'file' | 'audio';
|
|
23
|
+
name: string;
|
|
24
|
+
mediaType: string;
|
|
25
|
+
data: string; // base64
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OutgoingMessage {
|
|
30
|
+
content: string;
|
|
31
|
+
conversationKey: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Forward reference — ChannelRouter is imported by channel implementations
|
|
35
|
+
export interface ChannelRouter {
|
|
36
|
+
handleIncoming(message: IncomingMessage): Promise<void>;
|
|
37
|
+
routePulseMessage(content: string, title?: string, priority?: string): Promise<void>;
|
|
38
|
+
getChannelForConversation(conversationKey: string): Channel | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Channel {
|
|
42
|
+
readonly type: ChannelType;
|
|
43
|
+
|
|
44
|
+
/** Initialize the channel (connect socket, start webhook listener, etc.) */
|
|
45
|
+
initialize(router: ChannelRouter): Promise<void>;
|
|
46
|
+
|
|
47
|
+
/** Send a message back through this channel */
|
|
48
|
+
sendMessage(to: string, message: OutgoingMessage): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Check if this channel owns a given conversation key */
|
|
51
|
+
ownsConversation(conversationKey: string): boolean;
|
|
52
|
+
|
|
53
|
+
/** Graceful shutdown */
|
|
54
|
+
shutdown(): Promise<void>;
|
|
55
|
+
}
|
|
@@ -12,6 +12,16 @@ import type { SavedFile } from './file-saver.js';
|
|
|
12
12
|
import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
13
13
|
|
|
14
14
|
const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'fluxy-system-prompt.txt');
|
|
15
|
+
const CUSTOMER_PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'customer-system-prompt.txt');
|
|
16
|
+
|
|
17
|
+
/** Default tools disallowed for customer conversations. Edit this list to control customer access. */
|
|
18
|
+
const CUSTOMER_DISALLOWED_TOOLS = [
|
|
19
|
+
'Bash',
|
|
20
|
+
// Add more tools here to restrict customer access, e.g.:
|
|
21
|
+
// 'Write',
|
|
22
|
+
// 'Edit',
|
|
23
|
+
// 'Agent',
|
|
24
|
+
];
|
|
15
25
|
|
|
16
26
|
export interface RecentMessage {
|
|
17
27
|
role: 'user' | 'assistant';
|
|
@@ -160,12 +170,24 @@ export async function startFluxyAgentQuery(
|
|
|
160
170
|
|
|
161
171
|
try {
|
|
162
172
|
// Auto-discover all skill plugins in workspace/skills/ — any folder with a valid plugin.json is loaded
|
|
173
|
+
// Skills use a flat structure on disk (SKILL.md at the root), but the SDK expects
|
|
174
|
+
// skills/{name}/SKILL.md — we bridge the gap with symlinks created on discovery.
|
|
163
175
|
const skillsDir = path.join(PKG_DIR, 'workspace', 'skills');
|
|
164
176
|
const plugins: { type: 'local'; path: string }[] = [];
|
|
165
177
|
try {
|
|
166
178
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
167
179
|
if (entry.isDirectory() && fs.existsSync(path.join(skillsDir, entry.name, '.claude-plugin', 'plugin.json'))) {
|
|
168
180
|
plugins.push({ type: 'local' as const, path: path.join(skillsDir, entry.name) });
|
|
181
|
+
|
|
182
|
+
// Bridge flat SKILL.md → nested path the SDK expects (via symlink)
|
|
183
|
+
const skillName = entry.name;
|
|
184
|
+
const flatSkillMd = path.join(skillsDir, skillName, 'SKILL.md');
|
|
185
|
+
const sdkDir = path.join(skillsDir, skillName, 'skills', skillName);
|
|
186
|
+
const sdkSkillMd = path.join(sdkDir, 'SKILL.md');
|
|
187
|
+
if (fs.existsSync(flatSkillMd) && !fs.existsSync(sdkSkillMd)) {
|
|
188
|
+
fs.mkdirSync(sdkDir, { recursive: true });
|
|
189
|
+
fs.symlinkSync(flatSkillMd, sdkSkillMd);
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
}
|
|
171
193
|
} catch {}
|
|
@@ -277,6 +299,141 @@ export async function startFluxyAgentQuery(
|
|
|
277
299
|
}
|
|
278
300
|
}
|
|
279
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Read the customer-facing system prompt, replacing placeholders.
|
|
304
|
+
*/
|
|
305
|
+
function readCustomerSystemPrompt(ctx: CustomerContext): string {
|
|
306
|
+
try {
|
|
307
|
+
const raw = fs.readFileSync(CUSTOMER_PROMPT_FILE, 'utf-8').trim();
|
|
308
|
+
if (!raw) return `You are a helpful business assistant for ${ctx.businessName}.`;
|
|
309
|
+
return raw
|
|
310
|
+
.replace(/\$BUSINESS_NAME/g, ctx.businessName)
|
|
311
|
+
.replace(/\$BUSINESS_DESCRIPTION/g, ctx.businessDescription)
|
|
312
|
+
.replace(/\$SENDER_NAME/g, ctx.senderName)
|
|
313
|
+
.replace(/\$CHANNEL/g, ctx.channelType)
|
|
314
|
+
.replace(/\$DISALLOWED_TOOLS/g, CUSTOMER_DISALLOWED_TOOLS.join(', ') || '(none)');
|
|
315
|
+
} catch {
|
|
316
|
+
return `You are a helpful business assistant for ${ctx.businessName}.`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface CustomerContext {
|
|
321
|
+
senderName: string;
|
|
322
|
+
senderIdentifier: string;
|
|
323
|
+
channelType: string;
|
|
324
|
+
businessName: string;
|
|
325
|
+
businessDescription: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Run an Agent SDK query for a customer conversation.
|
|
330
|
+
* Restricted: different system prompt, limited tools, no memory files, no plugins, no MCP.
|
|
331
|
+
*/
|
|
332
|
+
export async function startCustomerAgentQuery(
|
|
333
|
+
conversationKey: string,
|
|
334
|
+
prompt: string,
|
|
335
|
+
model: string,
|
|
336
|
+
onMessage: (type: string, data: any) => void,
|
|
337
|
+
customerContext: CustomerContext,
|
|
338
|
+
recentMessages?: RecentMessage[],
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const oauthToken = await getClaudeAccessToken();
|
|
341
|
+
if (!oauthToken) {
|
|
342
|
+
onMessage('bot:error', { conversationId: conversationKey, error: 'Claude OAuth token not found.' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const abortController = new AbortController();
|
|
347
|
+
let customerPrompt = readCustomerSystemPrompt(customerContext);
|
|
348
|
+
|
|
349
|
+
if (recentMessages?.length) {
|
|
350
|
+
customerPrompt = customerPrompt.replace(
|
|
351
|
+
'$CONVERSATION_HISTORY',
|
|
352
|
+
formatConversationHistory(recentMessages),
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
customerPrompt = customerPrompt.replace('$CONVERSATION_HISTORY', '(none)');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
activeQueries.set(conversationKey, { abortController });
|
|
359
|
+
|
|
360
|
+
let fullText = '';
|
|
361
|
+
let stderrBuf = '';
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const claudeQuery = query({
|
|
365
|
+
prompt,
|
|
366
|
+
options: {
|
|
367
|
+
model,
|
|
368
|
+
cwd: WORKSPACE_DIR,
|
|
369
|
+
permissionMode: 'bypassPermissions',
|
|
370
|
+
allowDangerouslySkipPermissions: true,
|
|
371
|
+
disallowedTools: CUSTOMER_DISALLOWED_TOOLS,
|
|
372
|
+
maxTurns: 10,
|
|
373
|
+
abortController,
|
|
374
|
+
systemPrompt: customerPrompt,
|
|
375
|
+
// No plugins, no MCP servers for customers
|
|
376
|
+
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
377
|
+
env: {
|
|
378
|
+
...process.env as Record<string, string>,
|
|
379
|
+
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
380
|
+
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
onMessage('bot:typing', { conversationId: conversationKey });
|
|
386
|
+
|
|
387
|
+
for await (const msg of claudeQuery) {
|
|
388
|
+
if (abortController.signal.aborted) break;
|
|
389
|
+
|
|
390
|
+
switch (msg.type) {
|
|
391
|
+
case 'assistant': {
|
|
392
|
+
const assistantMsg = msg.message;
|
|
393
|
+
if (!assistantMsg?.content) break;
|
|
394
|
+
for (const block of assistantMsg.content) {
|
|
395
|
+
if (block.type === 'text' && block.text) {
|
|
396
|
+
if (fullText && !fullText.endsWith('\n')) {
|
|
397
|
+
fullText += '\n\n';
|
|
398
|
+
onMessage('bot:token', { conversationId: conversationKey, token: '\n\n' });
|
|
399
|
+
}
|
|
400
|
+
fullText += block.text;
|
|
401
|
+
onMessage('bot:token', { conversationId: conversationKey, token: block.text });
|
|
402
|
+
} else if (block.type === 'tool_use') {
|
|
403
|
+
onMessage('bot:tool', { conversationId: conversationKey, name: block.name, input: block.input });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'result': {
|
|
409
|
+
if (fullText) {
|
|
410
|
+
onMessage('bot:response', { conversationId: conversationKey, content: fullText });
|
|
411
|
+
fullText = '';
|
|
412
|
+
} else if (msg.subtype?.startsWith('error')) {
|
|
413
|
+
const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
|
|
414
|
+
onMessage('bot:error', { conversationId: conversationKey, error: errorText });
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (fullText && !abortController.signal.aborted) {
|
|
422
|
+
onMessage('bot:response', { conversationId: conversationKey, content: fullText });
|
|
423
|
+
}
|
|
424
|
+
} catch (err: any) {
|
|
425
|
+
if (!abortController.signal.aborted) {
|
|
426
|
+
const detail = stderrBuf.trim();
|
|
427
|
+
const msg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
428
|
+
log.warn(`Customer agent error (${conversationKey}): ${msg}`);
|
|
429
|
+
onMessage('bot:error', { conversationId: conversationKey, error: msg });
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
activeQueries.delete(conversationKey);
|
|
433
|
+
onMessage('bot:done', { conversationId: conversationKey, usedFileTools: false });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
280
437
|
/** Stop an in-flight query */
|
|
281
438
|
export function stopFluxyAgentQuery(conversationId: string): void {
|
|
282
439
|
const q = activeQueries.get(conversationId);
|
package/supervisor/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { startFluxyAgentQuery, stopFluxyAgentQuery, type RecentMessage } from '.
|
|
|
17
17
|
import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
|
|
18
18
|
import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
19
19
|
import { startScheduler, stopScheduler } from './scheduler.js';
|
|
20
|
+
import { initializeChannels, type ChannelRouter } from './channels/index.js';
|
|
20
21
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
21
22
|
import crypto from 'crypto';
|
|
22
23
|
|
|
@@ -1034,6 +1035,25 @@ export async function startSupervisor() {
|
|
|
1034
1035
|
// Spawn backend (worker runs in-process)
|
|
1035
1036
|
spawnBackend(backendPort);
|
|
1036
1037
|
|
|
1038
|
+
// Initialize multi-channel router
|
|
1039
|
+
let channelRouter: ChannelRouter | null = null;
|
|
1040
|
+
(async () => {
|
|
1041
|
+
try {
|
|
1042
|
+
channelRouter = await initializeChannels(config, {
|
|
1043
|
+
workerApi,
|
|
1044
|
+
broadcastFluxy,
|
|
1045
|
+
restartBackend: async () => {
|
|
1046
|
+
resetBackendRestarts();
|
|
1047
|
+
await stopBackend();
|
|
1048
|
+
spawnBackend(backendPort);
|
|
1049
|
+
},
|
|
1050
|
+
getModel: () => loadConfig().ai.model,
|
|
1051
|
+
});
|
|
1052
|
+
} catch (err: any) {
|
|
1053
|
+
log.warn(`[channels] Initialization failed: ${err.message}`);
|
|
1054
|
+
}
|
|
1055
|
+
})();
|
|
1056
|
+
|
|
1037
1057
|
// Start pulse/cron scheduler
|
|
1038
1058
|
startScheduler({
|
|
1039
1059
|
broadcastFluxy,
|
|
@@ -1044,6 +1064,7 @@ export async function startSupervisor() {
|
|
|
1044
1064
|
spawnBackend(backendPort);
|
|
1045
1065
|
},
|
|
1046
1066
|
getModel: () => loadConfig().ai.model,
|
|
1067
|
+
channelRouter: () => channelRouter,
|
|
1047
1068
|
});
|
|
1048
1069
|
|
|
1049
1070
|
// Watch workspace files for changes — auto-restart backend
|
|
@@ -1247,9 +1268,18 @@ export async function startSupervisor() {
|
|
|
1247
1268
|
}, 30_000);
|
|
1248
1269
|
}
|
|
1249
1270
|
|
|
1271
|
+
// Expose channel router for external channel registration (e.g., WhatsApp skill)
|
|
1272
|
+
(globalThis as any).__fluxyChannelRouter = () => channelRouter;
|
|
1273
|
+
|
|
1250
1274
|
// Shutdown
|
|
1251
1275
|
const shutdown = async () => {
|
|
1252
1276
|
log.info('Shutting down...');
|
|
1277
|
+
// Shutdown all channels gracefully
|
|
1278
|
+
if (channelRouter) {
|
|
1279
|
+
for (const channel of channelRouter.getAllChannels()) {
|
|
1280
|
+
try { await channel.shutdown(); } catch {}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1253
1283
|
stopScheduler();
|
|
1254
1284
|
backendWatcher.close();
|
|
1255
1285
|
workspaceWatcher.close();
|
package/supervisor/scheduler.ts
CHANGED
|
@@ -33,6 +33,7 @@ interface SchedulerOpts {
|
|
|
33
33
|
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
34
34
|
restartBackend: () => void;
|
|
35
35
|
getModel: () => string;
|
|
36
|
+
channelRouter?: () => import('./channels/router.js').ChannelRouter | null;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
// State
|
|
@@ -201,6 +202,14 @@ function triggerAgent(prompt: string, label: string, onComplete?: () => void) {
|
|
|
201
202
|
}).catch((err: any) => {
|
|
202
203
|
log.warn(`[scheduler] Push send failed: ${err.message}`);
|
|
203
204
|
});
|
|
205
|
+
|
|
206
|
+
// Route pulse/cron messages to all owner channels (WhatsApp, Telegram, etc.)
|
|
207
|
+
const router = schedulerOpts?.channelRouter?.();
|
|
208
|
+
if (router) {
|
|
209
|
+
router.routePulseMessage(messageContent, titleMatch?.[1]).catch((err: any) => {
|
|
210
|
+
log.warn(`[scheduler] Channel routing failed: ${err.message}`);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
204
213
|
}
|
|
205
214
|
}
|
|
206
215
|
|
package/worker/db.ts
CHANGED
|
@@ -48,6 +48,16 @@ CREATE TABLE IF NOT EXISTS trusted_devices (
|
|
|
48
48
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
49
49
|
);
|
|
50
50
|
CREATE INDEX IF NOT EXISTS idx_td_token ON trusted_devices(token);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
52
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
53
|
+
channel_type TEXT NOT NULL,
|
|
54
|
+
identifier TEXT NOT NULL,
|
|
55
|
+
display_name TEXT,
|
|
56
|
+
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('owner', 'admin', 'customer')),
|
|
57
|
+
notes TEXT,
|
|
58
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
59
|
+
UNIQUE(channel_type, identifier)
|
|
60
|
+
);
|
|
51
61
|
`;
|
|
52
62
|
|
|
53
63
|
let db: Database.Database;
|
|
@@ -76,6 +86,18 @@ export function initDb(): void {
|
|
|
76
86
|
if (!msgCols2.some((c) => c.name === 'attachments')) {
|
|
77
87
|
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
|
|
78
88
|
}
|
|
89
|
+
|
|
90
|
+
// Migration: add channel columns to conversations (multi-channel support)
|
|
91
|
+
const convCols = db.prepare("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
92
|
+
if (!convCols.some((c) => c.name === 'channel_type')) {
|
|
93
|
+
db.exec("ALTER TABLE conversations ADD COLUMN channel_type TEXT DEFAULT 'chat'");
|
|
94
|
+
}
|
|
95
|
+
if (!convCols.some((c) => c.name === 'conversation_key')) {
|
|
96
|
+
db.exec('ALTER TABLE conversations ADD COLUMN conversation_key TEXT');
|
|
97
|
+
}
|
|
98
|
+
if (!convCols.some((c) => c.name === 'sender_role')) {
|
|
99
|
+
db.exec("ALTER TABLE conversations ADD COLUMN sender_role TEXT DEFAULT 'owner'");
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
export function closeDb(): void { db?.close(); }
|
|
@@ -196,3 +218,43 @@ export function getMessagesBefore(convId: string, beforeId: string, limit = 20)
|
|
|
196
218
|
) sub ORDER BY id ASC
|
|
197
219
|
`).all(convId, beforeId, limit);
|
|
198
220
|
}
|
|
221
|
+
|
|
222
|
+
// ── Contacts (multi-channel role management) ──
|
|
223
|
+
|
|
224
|
+
export function listContacts() {
|
|
225
|
+
return db.prepare('SELECT * FROM contacts ORDER BY created_at DESC').all();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function resolveContact(channelType: string, identifier: string): { role: string } | undefined {
|
|
229
|
+
return db.prepare('SELECT role FROM contacts WHERE channel_type = ? AND identifier = ?').get(channelType, identifier) as any;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function addContact(channelType: string, identifier: string, role: string, displayName?: string, notes?: string) {
|
|
233
|
+
return db.prepare(
|
|
234
|
+
'INSERT INTO contacts (channel_type, identifier, role, display_name, notes) VALUES (?, ?, ?, ?, ?) RETURNING *'
|
|
235
|
+
).get(channelType, identifier, role, displayName ?? null, notes ?? null) as any;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function updateContact(id: string, updates: { role?: string; display_name?: string; notes?: string }) {
|
|
239
|
+
const sets: string[] = [];
|
|
240
|
+
const vals: any[] = [];
|
|
241
|
+
if (updates.role !== undefined) { sets.push('role = ?'); vals.push(updates.role); }
|
|
242
|
+
if (updates.display_name !== undefined) { sets.push('display_name = ?'); vals.push(updates.display_name); }
|
|
243
|
+
if (updates.notes !== undefined) { sets.push('notes = ?'); vals.push(updates.notes); }
|
|
244
|
+
if (!sets.length) return;
|
|
245
|
+
vals.push(id);
|
|
246
|
+
db.prepare(`UPDATE contacts SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function deleteContact(id: string) {
|
|
250
|
+
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Find or create a conversation for a given conversation key
|
|
254
|
+
export function getOrCreateConversation(conversationKey: string, channelType: string, senderRole: string, title?: string, model?: string) {
|
|
255
|
+
const existing = db.prepare('SELECT * FROM conversations WHERE conversation_key = ? ORDER BY updated_at DESC LIMIT 1').get(conversationKey) as any;
|
|
256
|
+
if (existing) return existing;
|
|
257
|
+
return db.prepare(
|
|
258
|
+
'INSERT INTO conversations (title, model, channel_type, conversation_key, sender_role) VALUES (?, ?, ?, ?, ?) RETURNING *'
|
|
259
|
+
).get(title ?? 'Chat', model ?? null, channelType, conversationKey, senderRole) as any;
|
|
260
|
+
}
|
package/worker/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
6
6
|
import { paths, WORKSPACE_DIR } from '../shared/paths.js';
|
|
7
7
|
import { log } from '../shared/logger.js';
|
|
8
|
-
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
|
|
8
|
+
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices, listContacts, resolveContact, addContact, updateContact, deleteContact } from './db.js';
|
|
9
9
|
import webpush from 'web-push';
|
|
10
10
|
import { TOTP } from 'otpauth';
|
|
11
11
|
import QRCode from 'qrcode';
|
|
@@ -877,6 +877,47 @@ app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req,
|
|
|
877
877
|
}
|
|
878
878
|
});
|
|
879
879
|
|
|
880
|
+
// ── Contacts (multi-channel role management) ──
|
|
881
|
+
|
|
882
|
+
app.get('/api/contacts', (_req, res) => {
|
|
883
|
+
res.json(listContacts());
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
app.get('/api/contacts/resolve', (req, res) => {
|
|
887
|
+
const channelType = req.query.channel_type as string;
|
|
888
|
+
const identifier = req.query.identifier as string;
|
|
889
|
+
if (!channelType || !identifier) { res.status(400).json({ error: 'Missing channel_type or identifier' }); return; }
|
|
890
|
+
const contact = resolveContact(channelType, identifier);
|
|
891
|
+
res.json({ role: contact?.role || 'customer' });
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
app.post('/api/contacts', (req, res) => {
|
|
895
|
+
const { channel_type, identifier, role, display_name, notes } = req.body || {};
|
|
896
|
+
if (!channel_type || !identifier || !role) { res.status(400).json({ error: 'Missing channel_type, identifier, or role' }); return; }
|
|
897
|
+
if (!['owner', 'admin', 'customer'].includes(role)) { res.status(400).json({ error: 'Invalid role' }); return; }
|
|
898
|
+
try {
|
|
899
|
+
const contact = addContact(channel_type, identifier, role, display_name, notes);
|
|
900
|
+
res.json(contact);
|
|
901
|
+
} catch (err: any) {
|
|
902
|
+
if (err.message?.includes('UNIQUE constraint')) {
|
|
903
|
+
res.status(409).json({ error: 'Contact already exists for this channel and identifier' });
|
|
904
|
+
} else {
|
|
905
|
+
res.status(500).json({ error: err.message });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
app.put('/api/contacts/:id', (req, res) => {
|
|
911
|
+
const { role, display_name, notes } = req.body || {};
|
|
912
|
+
updateContact(req.params.id, { role, display_name, notes });
|
|
913
|
+
res.json({ ok: true });
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
app.delete('/api/contacts/:id', (req, res) => {
|
|
917
|
+
deleteContact(req.params.id);
|
|
918
|
+
res.json({ ok: true });
|
|
919
|
+
});
|
|
920
|
+
|
|
880
921
|
// Serve stored files (audio, images, documents)
|
|
881
922
|
app.use('/api/files', express.static(paths.files));
|
|
882
923
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Identity
|
|
2
|
+
|
|
3
|
+
You are $BUSINESS_NAME's assistant. You are helpful, professional, and friendly.
|
|
4
|
+
|
|
5
|
+
You are talking to $SENDER_NAME via $CHANNEL.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# What You Can Do
|
|
10
|
+
|
|
11
|
+
- Answer questions about the business, products, and services
|
|
12
|
+
- Help with support inquiries and troubleshooting
|
|
13
|
+
- Provide information and guidance
|
|
14
|
+
- Schedule appointments or take notes for follow-up
|
|
15
|
+
- Be conversational and helpful
|
|
16
|
+
|
|
17
|
+
# What You Cannot Do
|
|
18
|
+
|
|
19
|
+
- Access internal systems, files, or databases
|
|
20
|
+
- Run commands or modify anything on the server
|
|
21
|
+
- Access personal information about the business owner
|
|
22
|
+
- Make promises about pricing, refunds, or policies unless explicitly stated in your context
|
|
23
|
+
- Reveal any technical details about how you work internally
|
|
24
|
+
|
|
25
|
+
# Business Context
|
|
26
|
+
|
|
27
|
+
$BUSINESS_DESCRIPTION
|
|
28
|
+
|
|
29
|
+
# Behavior Guidelines
|
|
30
|
+
|
|
31
|
+
- Be warm and professional — you represent the business
|
|
32
|
+
- If you don't know the answer, say so honestly and offer to have someone follow up
|
|
33
|
+
- Keep responses concise and focused
|
|
34
|
+
- If the customer seems frustrated, acknowledge their feelings before problem-solving
|
|
35
|
+
- Never pretend to be a human — if asked, say you're an AI assistant for $BUSINESS_NAME
|
|
36
|
+
- Do not discuss topics unrelated to the business unless the customer is making casual conversation
|
|
37
|
+
|
|
38
|
+
# Disallowed Tools
|
|
39
|
+
|
|
40
|
+
The following tools are NOT available to you in this context:
|
|
41
|
+
$DISALLOWED_TOOLS
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Recent Conversation
|
|
46
|
+
$CONVERSATION_HISTORY
|
|
@@ -16,12 +16,65 @@ const TOUR_KEY = 'fluxy_workspace_tour_done';
|
|
|
16
16
|
|
|
17
17
|
export default function WorkspaceTour() {
|
|
18
18
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// Mark immediately to prevent StrictMode double-fire
|
|
22
|
-
localStorage.setItem(TOUR_KEY, 'pending');
|
|
19
|
+
const val = localStorage.getItem(TOUR_KEY);
|
|
20
|
+
if (val === '1') return;
|
|
23
21
|
|
|
24
22
|
const timer = setTimeout(() => {
|
|
23
|
+
// Prevent StrictMode double-fire
|
|
24
|
+
if (localStorage.getItem(TOUR_KEY) === '1') return;
|
|
25
|
+
|
|
26
|
+
// Build steps dynamically — skip missing elements
|
|
27
|
+
const steps: any[] = [
|
|
28
|
+
{
|
|
29
|
+
element: '#tour-sidebar',
|
|
30
|
+
popover: {
|
|
31
|
+
title: 'Your Sidebar',
|
|
32
|
+
description: 'When you ask Fluxy to build something, it creates an App that shows up right here. Think of it as your personal app launcher.',
|
|
33
|
+
side: 'right',
|
|
34
|
+
align: 'start',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
element: '#tour-dashboard',
|
|
39
|
+
popover: {
|
|
40
|
+
title: 'Your Dashboard',
|
|
41
|
+
description: 'Fluxy can add widgets here with a summary of your apps, the weather in your city, home automation controls... whatever you need at a glance.',
|
|
42
|
+
side: 'left',
|
|
43
|
+
align: 'start',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Only add chat step if the widget exists (not present in dev:workspace mode)
|
|
49
|
+
if (document.getElementById('fluxy-widget-toggle')) {
|
|
50
|
+
steps.push({
|
|
51
|
+
element: '#fluxy-widget-toggle',
|
|
52
|
+
popover: {
|
|
53
|
+
title: 'Chat with Fluxy',
|
|
54
|
+
description: 'This is where you talk to your Fluxy. Tell it what to build, ask questions, or just chat. It is always here for you.',
|
|
55
|
+
side: 'left',
|
|
56
|
+
align: 'end',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
steps.push({
|
|
62
|
+
popover: {
|
|
63
|
+
title: 'A few things to know',
|
|
64
|
+
description: `
|
|
65
|
+
<div style="display:flex;flex-direction:column;gap:12px;font-size:13px;line-height:1.5">
|
|
66
|
+
<p><strong>Make it yours.</strong> These widgets are just examples. You own this workspace and Fluxy can transform it into anything you want.</p>
|
|
67
|
+
<p><strong>Set a workspace password.</strong> By default, your workspace is open. Ask Fluxy to add a password so only you can see your apps and data.</p>
|
|
68
|
+
<p><strong>Keep your chat password safe.</strong> It controls access to Fluxy, which has full access to this machine. Use a strong one. If you ever lose it, run <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:4px;font-size:12px">fluxy password-reset</code> in your terminal.</p>
|
|
69
|
+
<p><strong>Fluxy does more than build apps.</strong> It can research topics, manage files, run commands, and help with just about anything. Just ask.</p>
|
|
70
|
+
<p><strong>Fluxy wakes up on its own.</strong> Every 30 minutes, it checks in and can take action proactively. You can adjust this anytime.</p>
|
|
71
|
+
<p><strong>Schedule anything.</strong> Ask things like "Every day at 6am, send me a briefing" or "In 40 minutes, remind me to call the dentist." Fluxy handles cron jobs and one-time reminders.</p>
|
|
72
|
+
<p><strong>Install it on your phone.</strong> Fluxy is a PWA. You can add it to your home screen and enable push notifications. It only pings you when something actually matters, and you can tune that by asking.</p>
|
|
73
|
+
</div>
|
|
74
|
+
`,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
25
78
|
const d = driver({
|
|
26
79
|
showProgress: true,
|
|
27
80
|
animate: true,
|
|
@@ -37,51 +90,7 @@ export default function WorkspaceTour() {
|
|
|
37
90
|
onDestroyed: () => {
|
|
38
91
|
localStorage.setItem(TOUR_KEY, '1');
|
|
39
92
|
},
|
|
40
|
-
steps
|
|
41
|
-
{
|
|
42
|
-
element: '#tour-sidebar',
|
|
43
|
-
popover: {
|
|
44
|
-
title: 'Your Sidebar',
|
|
45
|
-
description: 'When you ask Fluxy to build something, it creates an App that shows up right here. Think of it as your personal app launcher.',
|
|
46
|
-
side: 'right',
|
|
47
|
-
align: 'start',
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
element: '#tour-dashboard',
|
|
52
|
-
popover: {
|
|
53
|
-
title: 'Your Dashboard',
|
|
54
|
-
description: 'Fluxy can add widgets here with a summary of your apps, the weather in your city, home automation controls... whatever you need at a glance.',
|
|
55
|
-
side: 'left',
|
|
56
|
-
align: 'start',
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
element: '#fluxy-widget-toggle',
|
|
61
|
-
popover: {
|
|
62
|
-
title: 'Chat with Fluxy',
|
|
63
|
-
description: 'This is where you talk to your Fluxy. Tell it what to build, ask questions, or just chat. It is always here for you.',
|
|
64
|
-
side: 'left',
|
|
65
|
-
align: 'end',
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
popover: {
|
|
70
|
-
title: 'A few things to know',
|
|
71
|
-
description: `
|
|
72
|
-
<div style="display:flex;flex-direction:column;gap:12px;font-size:13px;line-height:1.5">
|
|
73
|
-
<p><strong>Make it yours.</strong> These widgets are just examples. You own this workspace and Fluxy can transform it into anything you want.</p>
|
|
74
|
-
<p><strong>Set a workspace password.</strong> By default, your workspace is open. Ask Fluxy to add a password so only you can see your apps and data.</p>
|
|
75
|
-
<p><strong>Keep your chat password safe.</strong> It controls access to Fluxy, which has full access to this machine. Use a strong one. If you ever lose it, run <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:4px;font-size:12px">fluxy password-reset</code> in your terminal.</p>
|
|
76
|
-
<p><strong>Fluxy does more than build apps.</strong> It can research topics, manage files, run commands, and help with just about anything. Just ask.</p>
|
|
77
|
-
<p><strong>Fluxy wakes up on its own.</strong> Every 30 minutes, it checks in and can take action proactively. You can adjust this anytime.</p>
|
|
78
|
-
<p><strong>Schedule anything.</strong> Ask things like "Every day at 6am, send me a briefing" or "In 40 minutes, remind me to call the dentist." Fluxy handles cron jobs and one-time reminders.</p>
|
|
79
|
-
<p><strong>Install it on your phone.</strong> Fluxy is a PWA. You can add it to your home screen and enable push notifications. It only pings you when something actually matters, and you can tune that by asking.</p>
|
|
80
|
-
</div>
|
|
81
|
-
`,
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
],
|
|
93
|
+
steps,
|
|
85
94
|
});
|
|
86
95
|
|
|
87
96
|
d.drive();
|
|
@@ -59,7 +59,7 @@ function SheetContent({
|
|
|
59
59
|
data-slot="sheet-content"
|
|
60
60
|
aria-describedby={undefined}
|
|
61
61
|
className={cn(
|
|
62
|
-
"
|
|
62
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 bg-[#1A1A1A]",
|
|
63
63
|
side === "right" &&
|
|
64
64
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
|
65
65
|
side === "left" &&
|
|
File without changes
|
|
File without changes
|
|
File without changes
|