fraim-framework 2.0.144 → 2.0.146
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/dist/src/ai-hub/desktop-main.js +10 -1
- package/dist/src/ai-hub/preferences.js +3 -0
- package/dist/src/ai-hub/server.js +110 -4
- package/dist/src/cli/commands/add-ide.js +22 -21
- package/dist/src/cli/mcp/command-resolution.js +4 -1
- package/dist/src/cli/setup/ide-detector.js +13 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +13 -0
- package/public/ai-hub/script.js +300 -15
- package/public/ai-hub/styles.css +199 -4
|
@@ -3,6 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.launchDesktopShell = launchDesktopShell;
|
|
4
4
|
const electron_1 = require("electron");
|
|
5
5
|
const server_1 = require("./server");
|
|
6
|
+
const db_service_1 = require("../fraim/db-service");
|
|
7
|
+
function tryCreateDbService() {
|
|
8
|
+
try {
|
|
9
|
+
return new db_service_1.FraimDbService();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
let server = null;
|
|
7
16
|
let mainWindow = null;
|
|
8
17
|
let stopping = null;
|
|
@@ -79,7 +88,7 @@ async function createWindow(url) {
|
|
|
79
88
|
}
|
|
80
89
|
async function launchDesktopShell(options) {
|
|
81
90
|
const port = await (0, server_1.findAvailablePort)(options.preferredPort);
|
|
82
|
-
server = new server_1.AiHubServer({ projectPath: options.projectPath });
|
|
91
|
+
server = new server_1.AiHubServer({ projectPath: options.projectPath, dbService: tryCreateDbService() });
|
|
83
92
|
await server.start(port);
|
|
84
93
|
await createWindow(`http://127.0.0.1:${port}/ai-hub/`);
|
|
85
94
|
}
|
|
@@ -14,6 +14,7 @@ const defaultPreferences = (projectPath) => ({
|
|
|
14
14
|
employeeId: DEFAULT_EMPLOYEE,
|
|
15
15
|
categoryId: DEFAULT_CATEGORY,
|
|
16
16
|
recentJobIds: [],
|
|
17
|
+
personaKey: null,
|
|
17
18
|
});
|
|
18
19
|
class AiHubPreferencesStore {
|
|
19
20
|
constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
|
|
@@ -30,6 +31,8 @@ class AiHubPreferencesStore {
|
|
|
30
31
|
employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
|
|
31
32
|
categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
|
|
32
33
|
recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
|
|
34
|
+
personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
|
|
35
|
+
apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
catch {
|
|
@@ -13,6 +13,33 @@ const os_1 = __importDefault(require("os"));
|
|
|
13
13
|
const crypto_1 = require("crypto");
|
|
14
14
|
const child_process_1 = require("child_process");
|
|
15
15
|
const types_1 = require("../first-run/types");
|
|
16
|
+
const persona_entitlement_service_1 = require("../services/persona-entitlement-service");
|
|
17
|
+
const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
|
|
18
|
+
const PERSONA_AVATAR_SEEDS = {
|
|
19
|
+
maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
|
|
20
|
+
beza: { seed: 'BEZA-strategist', bg: 'c7d2fe' },
|
|
21
|
+
pam: { seed: 'PAM-product', bg: 'ddd6fe' },
|
|
22
|
+
swen: { seed: 'SWEN-engineer', bg: 'bfdbfe' },
|
|
23
|
+
qasm: { seed: 'QASM-quality', bg: 'a7f3d0' },
|
|
24
|
+
huxley: { seed: 'HUXLEY-design', bg: 'fbcfe8' },
|
|
25
|
+
gautam: { seed: 'Gautam-marketing', bg: 'fed7aa' },
|
|
26
|
+
cela: { seed: 'CELA-legal', bg: 'cbd5e1' },
|
|
27
|
+
sekhar: { seed: 'SEKHAR-security', bg: 'fecaca' },
|
|
28
|
+
ashley: { seed: 'Ashley-assistant', bg: 'fde68a' },
|
|
29
|
+
mandy: { seed: 'MANDY-manager', bg: 'ede9fe' },
|
|
30
|
+
hari: { seed: 'HARI-hr', bg: 'ccfbf1' },
|
|
31
|
+
careena: { seed: 'CAREENA-career-coach', bg: 'e0f2fe' },
|
|
32
|
+
ricardo: { seed: 'RICARDO-recruiter', bg: 'ede9fe' },
|
|
33
|
+
sade: { seed: 'SADE-salesforce-dev', bg: 'bae6fd' },
|
|
34
|
+
sam: { seed: 'SAM-sales-manager', bg: 'bbf7d0' },
|
|
35
|
+
casey: { seed: 'CASEY-customer-cx', bg: 'fecdd3' },
|
|
36
|
+
};
|
|
37
|
+
function buildPersonaAvatarUrl(personaKey) {
|
|
38
|
+
const data = PERSONA_AVATAR_SEEDS[personaKey] ?? { seed: personaKey, bg: 'f1f5f9' };
|
|
39
|
+
const params = new URLSearchParams({ seed: data.seed, backgroundColor: data.bg, radius: '50' });
|
|
40
|
+
return `https://api.dicebear.com/9.x/notionists/svg?${params.toString()}`;
|
|
41
|
+
}
|
|
42
|
+
const db_service_1 = require("../fraim/db-service");
|
|
16
43
|
const catalog_1 = require("./catalog");
|
|
17
44
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
18
45
|
const hosts_1 = require("./hosts");
|
|
@@ -327,6 +354,18 @@ class AiHubServer {
|
|
|
327
354
|
this.projectPath = options.projectPath || process.cwd();
|
|
328
355
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
329
356
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
357
|
+
if (options.dbService !== undefined) {
|
|
358
|
+
this.dbService = options.dbService;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
try {
|
|
362
|
+
this.dbService = new db_service_1.FraimDbService();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
this.dbService = undefined;
|
|
366
|
+
console.warn('[ai-hub] No dbService — personas will show as locked');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
330
369
|
this.app.use(express_1.default.json());
|
|
331
370
|
this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
|
|
332
371
|
this.app.get('/health', (_req, res) => {
|
|
@@ -338,6 +377,15 @@ class AiHubServer {
|
|
|
338
377
|
return this.app;
|
|
339
378
|
}
|
|
340
379
|
async start(port) {
|
|
380
|
+
if (this.dbService) {
|
|
381
|
+
try {
|
|
382
|
+
await this.dbService.connect();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
|
|
386
|
+
this.dbService = undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
341
389
|
await new Promise((resolve, reject) => {
|
|
342
390
|
this.httpServer = this.app.listen(port, '127.0.0.1');
|
|
343
391
|
this.httpServer.once('listening', () => resolve());
|
|
@@ -358,7 +406,7 @@ class AiHubServer {
|
|
|
358
406
|
});
|
|
359
407
|
this.httpServer = undefined;
|
|
360
408
|
}
|
|
361
|
-
bootstrapResponse(projectPath) {
|
|
409
|
+
async bootstrapResponse(projectPath, apiKey) {
|
|
362
410
|
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
363
411
|
const employees = this.hostRuntime.detectEmployees();
|
|
364
412
|
let preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
@@ -373,8 +421,13 @@ class AiHubServer {
|
|
|
373
421
|
}
|
|
374
422
|
}
|
|
375
423
|
const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
|
|
376
|
-
const
|
|
424
|
+
const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
|
|
425
|
+
const jobs = rawJobs.map((job) => ({
|
|
426
|
+
...job,
|
|
427
|
+
requiredPersonaKey: (0, persona_capability_bundles_1.getProtectedPersonaForJob)(job.id),
|
|
428
|
+
}));
|
|
377
429
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
|
|
430
|
+
const { personas, subscriptionActive } = await this.computePersonas(apiKey || preferences.apiKey);
|
|
378
431
|
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
379
432
|
// so the bootstrap surface (used on first paint) carries stages and
|
|
380
433
|
// live totals — not just the raw run state.
|
|
@@ -388,15 +441,67 @@ class AiHubServer {
|
|
|
388
441
|
jobs,
|
|
389
442
|
managerTemplates,
|
|
390
443
|
employees,
|
|
444
|
+
personas,
|
|
445
|
+
subscriptionActive,
|
|
391
446
|
activeRun,
|
|
392
447
|
};
|
|
393
448
|
}
|
|
449
|
+
async computePersonas(apiKey) {
|
|
450
|
+
const allBundles = (0, persona_capability_bundles_1.listPersonaCapabilityBundles)();
|
|
451
|
+
const fallbackPersonas = allBundles.map((bundle) => ({
|
|
452
|
+
key: bundle.personaKey,
|
|
453
|
+
displayName: bundle.catalogMetadata.displayName,
|
|
454
|
+
role: bundle.catalogMetadata.role,
|
|
455
|
+
avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
|
|
456
|
+
pricingLabel: bundle.catalogMetadata.pricingLabel,
|
|
457
|
+
status: 'locked',
|
|
458
|
+
hireUrl: (0, persona_entitlement_service_1.buildPersonaHireUrl)(bundle.personaKey, bundle.defaultHireMode),
|
|
459
|
+
}));
|
|
460
|
+
if (!apiKey || !this.dbService)
|
|
461
|
+
return { personas: fallbackPersonas, subscriptionActive: false };
|
|
462
|
+
try {
|
|
463
|
+
const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(this.dbService, 'anonymous', apiKey);
|
|
464
|
+
const hiredKeys = new Set(state.entitlements
|
|
465
|
+
.filter((e) => e.status === 'active')
|
|
466
|
+
.map((e) => e.personaKey));
|
|
467
|
+
const personas = allBundles.map((bundle) => ({
|
|
468
|
+
key: bundle.personaKey,
|
|
469
|
+
displayName: bundle.catalogMetadata.displayName,
|
|
470
|
+
role: bundle.catalogMetadata.role,
|
|
471
|
+
avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
|
|
472
|
+
pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
|
|
473
|
+
status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
|
|
474
|
+
hireUrl: (0, persona_entitlement_service_1.buildPersonaHireUrl)(bundle.personaKey, bundle.defaultHireMode),
|
|
475
|
+
}));
|
|
476
|
+
return { personas, subscriptionActive: state.subscriptionActive };
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
console.error('[ai-hub] persona entitlement lookup failed:', err);
|
|
480
|
+
return { personas: fallbackPersonas, subscriptionActive: false };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
394
483
|
registerRoutes() {
|
|
395
|
-
this.app.get('/api/ai-hub/bootstrap', (req, res) => {
|
|
484
|
+
this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
|
|
396
485
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
397
486
|
? req.query.projectPath
|
|
398
487
|
: this.projectPath;
|
|
399
|
-
|
|
488
|
+
// Read API key from header — query-param API keys are prohibited (§3.14)
|
|
489
|
+
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
490
|
+
res.json(await this.bootstrapResponse(projectPath, apiKey));
|
|
491
|
+
});
|
|
492
|
+
this.app.post('/api/ai-hub/api-key', (req, res) => {
|
|
493
|
+
const { apiKey } = req.body;
|
|
494
|
+
if (!apiKey || typeof apiKey !== 'string')
|
|
495
|
+
return res.status(400).json({ error: 'apiKey required' });
|
|
496
|
+
const prefs = this.preferencesStore.load(this.projectPath);
|
|
497
|
+
this.preferencesStore.save({ ...prefs, apiKey });
|
|
498
|
+
return res.json({ ok: true });
|
|
499
|
+
});
|
|
500
|
+
this.app.post('/api/ai-hub/preferences', (req, res) => {
|
|
501
|
+
const { personaKey } = req.body;
|
|
502
|
+
const prefs = this.preferencesStore.load(this.projectPath);
|
|
503
|
+
this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
|
|
504
|
+
return res.json({ ok: true });
|
|
400
505
|
});
|
|
401
506
|
this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
|
|
402
507
|
try {
|
|
@@ -508,6 +613,7 @@ class AiHubServer {
|
|
|
508
613
|
phaseHistory: [],
|
|
509
614
|
totals: emptyTotals(),
|
|
510
615
|
lastStatusChangeAt: startTimestamp,
|
|
616
|
+
personaKey: (0, persona_capability_bundles_1.getProtectedPersonaForJob)(jobId) ?? null,
|
|
511
617
|
// Gemini CLI does not emit a session ID in its output stream;
|
|
512
618
|
// pre-seed one so the Send button is enabled and the continue
|
|
513
619
|
// endpoint can proceed. continueRun for Gemini ignores it.
|
|
@@ -354,38 +354,39 @@ const runAddIDE = async (options) => {
|
|
|
354
354
|
else {
|
|
355
355
|
console.log(chalk_1.default.green('✅ Using existing FRAIM configuration\n'));
|
|
356
356
|
}
|
|
357
|
-
// Detect available IDEs
|
|
358
|
-
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
359
|
-
if (detectedIDEs.length === 0) {
|
|
360
|
-
console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
|
|
361
|
-
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
|
|
362
|
-
console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
357
|
let idesToConfigure;
|
|
366
358
|
if (options.ide) {
|
|
367
|
-
//
|
|
359
|
+
// Explicit IDE requested: configure it regardless of whether it is
|
|
360
|
+
// currently detected. Use the detected path if available (may differ
|
|
361
|
+
// from the default), otherwise fall back to the default so the config
|
|
362
|
+
// can be pre-staged before the IDE is installed.
|
|
368
363
|
const requestedIDE = (0, ide_detector_1.findIDEByName)(options.ide);
|
|
369
364
|
if (!requestedIDE) {
|
|
370
365
|
console.log(chalk_1.default.red(`❌ IDE "${options.ide}" not supported.`));
|
|
371
366
|
console.log(chalk_1.default.yellow('💡 Use "fraim add-ide --list" to see supported IDEs.'));
|
|
372
367
|
return;
|
|
373
368
|
}
|
|
369
|
+
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
374
370
|
const detectedIDE = detectedIDEs.find(ide => ide.name === requestedIDE.name);
|
|
375
|
-
|
|
376
|
-
console.log(chalk_1.default.red(`❌ ${requestedIDE.name} not found on your system.`));
|
|
377
|
-
console.log(chalk_1.default.yellow(`💡 Please install ${requestedIDE.name} and try again.`));
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
idesToConfigure = [detectedIDE];
|
|
381
|
-
}
|
|
382
|
-
else if (options.all) {
|
|
383
|
-
// Configure all detected IDEs
|
|
384
|
-
idesToConfigure = detectedIDEs;
|
|
371
|
+
idesToConfigure = [detectedIDE || requestedIDE];
|
|
385
372
|
}
|
|
386
373
|
else {
|
|
387
|
-
//
|
|
388
|
-
|
|
374
|
+
// Auto-detect or interactive selection.
|
|
375
|
+
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
376
|
+
if (detectedIDEs.length === 0) {
|
|
377
|
+
console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
|
|
378
|
+
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
|
|
379
|
+
console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (options.all) {
|
|
383
|
+
// Configure all detected IDEs
|
|
384
|
+
idesToConfigure = detectedIDEs;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// Interactive selection
|
|
388
|
+
idesToConfigure = await promptForIDESelection(detectedIDEs, platformTokens);
|
|
389
|
+
}
|
|
389
390
|
}
|
|
390
391
|
if (idesToConfigure.length === 0) {
|
|
391
392
|
console.log(chalk_1.default.yellow('⚠️ No IDEs selected for configuration.'));
|
|
@@ -73,6 +73,9 @@ const resolveManagedCommand = (command) => {
|
|
|
73
73
|
if (command !== 'npx') {
|
|
74
74
|
return command;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Prefer system-installed npx so we don't install our own Node when the
|
|
77
|
+
// machine already has one. Fall back to the FRAIM-managed portable copy
|
|
78
|
+
// only when no system npx is found. Last resort: bare command name.
|
|
79
|
+
return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableNpxCommand)() || command;
|
|
77
80
|
};
|
|
78
81
|
exports.resolveManagedCommand = resolveManagedCommand;
|
|
@@ -199,11 +199,23 @@ const findBestConfigPath = (ide) => {
|
|
|
199
199
|
// Return default path if nothing found (will be created)
|
|
200
200
|
return ide.configPath;
|
|
201
201
|
};
|
|
202
|
+
let _cachedIDEs = null;
|
|
203
|
+
let _cacheTimestamp = 0;
|
|
204
|
+
let _cacheHomeDir = '';
|
|
205
|
+
const DETECT_CACHE_TTL_MS = 5000;
|
|
202
206
|
const detectInstalledIDEs = () => {
|
|
203
|
-
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const currentHome = os_1.default.homedir();
|
|
209
|
+
if (_cachedIDEs !== null && _cacheHomeDir === currentHome && (now - _cacheTimestamp) < DETECT_CACHE_TTL_MS) {
|
|
210
|
+
return _cachedIDEs;
|
|
211
|
+
}
|
|
212
|
+
_cachedIDEs = exports.IDE_CONFIGS.filter(ide => ide.detectMethod()).map(ide => ({
|
|
204
213
|
...ide,
|
|
205
214
|
configPath: findBestConfigPath(ide)
|
|
206
215
|
}));
|
|
216
|
+
_cacheTimestamp = now;
|
|
217
|
+
_cacheHomeDir = currentHome;
|
|
218
|
+
return _cachedIDEs;
|
|
207
219
|
};
|
|
208
220
|
exports.detectInstalledIDEs = detectInstalledIDEs;
|
|
209
221
|
const getAllSupportedIDEs = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.146",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/index.html
CHANGED
|
@@ -65,6 +65,9 @@
|
|
|
65
65
|
<div class="layout">
|
|
66
66
|
<aside class="rail">
|
|
67
67
|
<button class="new-conv" type="button" id="new-conv-btn">+ New job</button>
|
|
68
|
+
<!-- R2.4: team roster — horizontal row of avatar chips per hired persona -->
|
|
69
|
+
<div class="team-roster" id="team-roster" hidden></div>
|
|
70
|
+
|
|
68
71
|
<div class="conv-list" id="conv-list"></div>
|
|
69
72
|
</aside>
|
|
70
73
|
|
|
@@ -138,8 +141,18 @@
|
|
|
138
141
|
</div>
|
|
139
142
|
<div class="modal-body">
|
|
140
143
|
<input class="search" type="text" placeholder="Search jobs…" id="job-search">
|
|
144
|
+
<!-- R3+: persona job filter — only shown when subscription is active -->
|
|
145
|
+
<div id="job-persona-filter" hidden></div>
|
|
141
146
|
<div id="job-catalog"></div>
|
|
142
147
|
</div>
|
|
148
|
+
<!-- R3: hire-required notice — shown when a locked job is clicked -->
|
|
149
|
+
<div id="hire-notice" hidden>
|
|
150
|
+
<p id="hire-notice-text"></p>
|
|
151
|
+
<div class="hire-notice-actions">
|
|
152
|
+
<a id="hire-notice-link" href="#" target="_blank" rel="noopener" class="send-button">Go to Pricing →</a>
|
|
153
|
+
<button id="hire-notice-back" type="button" class="ghost">← Back to all jobs</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
143
156
|
<div class="modal-footer">
|
|
144
157
|
<span class="left" id="job-pick-status">Choose a job to continue</span>
|
|
145
158
|
<div class="right">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -20,6 +20,9 @@ const state = {
|
|
|
20
20
|
pollHandle: null,
|
|
21
21
|
selectedJob: null, // chosen in modal step 1
|
|
22
22
|
selectedEmployeeId: null,
|
|
23
|
+
selectedPersonaKey: null, // R4: null = "All employees"
|
|
24
|
+
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
25
|
+
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
const els = {};
|
|
@@ -32,6 +35,8 @@ function gatherElements() {
|
|
|
32
35
|
const ids = [
|
|
33
36
|
'project-button', 'project-name',
|
|
34
37
|
'new-conv-btn', 'conv-list',
|
|
38
|
+
// Issue #385: team roster
|
|
39
|
+
'team-roster',
|
|
35
40
|
'empty', 'active-conv', 'active-title', 'active-job',
|
|
36
41
|
'progress', 'stage', 'latest', 'artifact-slot', 'messages',
|
|
37
42
|
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
@@ -39,6 +44,9 @@ function gatherElements() {
|
|
|
39
44
|
'modal', 'step1', 'step2',
|
|
40
45
|
'cancel1', 'next1', 'back2', 'start',
|
|
41
46
|
'job-search', 'job-catalog', 'job-pick-status',
|
|
47
|
+
// Issue #385: hire-required notice, persona job filter
|
|
48
|
+
'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
|
|
49
|
+
'job-persona-filter',
|
|
42
50
|
'picked-name', 'picked-desc', 'instructions',
|
|
43
51
|
'employee-select', 'agent-install-panel', 'active-employee-select',
|
|
44
52
|
'freeform-btn',
|
|
@@ -57,7 +65,8 @@ async function requestJson(url, options) {
|
|
|
57
65
|
let payload = null;
|
|
58
66
|
try { payload = await response.json(); } catch { /* may be empty */ }
|
|
59
67
|
if (!response.ok) {
|
|
60
|
-
const
|
|
68
|
+
const errVal = payload && payload.error;
|
|
69
|
+
const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
|
|
61
70
|
throw new Error(message);
|
|
62
71
|
}
|
|
63
72
|
return payload;
|
|
@@ -65,10 +74,16 @@ async function requestJson(url, options) {
|
|
|
65
74
|
|
|
66
75
|
async function loadBootstrap(projectPath) {
|
|
67
76
|
const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
|
|
68
|
-
const
|
|
77
|
+
const fetchOptions = {};
|
|
78
|
+
if (state.storedApiKey) fetchOptions.headers = { 'x-fraim-api-key': state.storedApiKey };
|
|
79
|
+
const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
|
|
69
80
|
state.bootstrap = bootstrap;
|
|
70
81
|
state.projectPath = bootstrap.project.path;
|
|
71
82
|
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
83
|
+
// Restore persona selection persisted on the server side.
|
|
84
|
+
if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
|
|
85
|
+
state.selectedPersonaKey = bootstrap.preferences.personaKey;
|
|
86
|
+
}
|
|
72
87
|
// Render header project name + status line.
|
|
73
88
|
els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
|
|
74
89
|
els['status-line'].textContent = bootstrap.project.message || '';
|
|
@@ -165,6 +180,17 @@ function loadConversationsFromStorage() {
|
|
|
165
180
|
} catch {
|
|
166
181
|
state.activeId = null;
|
|
167
182
|
}
|
|
183
|
+
// Retroactively fix titles that were saved as the generic "New job" fallback
|
|
184
|
+
// by re-deriving from the first manager message.
|
|
185
|
+
for (const convList of Object.values(state.conversations)) {
|
|
186
|
+
for (const conv of convList) {
|
|
187
|
+
if (conv.title !== 'New job') continue;
|
|
188
|
+
const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
|
|
189
|
+
if (!firstMsg) continue;
|
|
190
|
+
const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
|
|
191
|
+
if (rederived !== 'New job') conv.title = rederived;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
168
194
|
}
|
|
169
195
|
|
|
170
196
|
function persistConversations() {
|
|
@@ -221,19 +247,60 @@ function newConversationId() {
|
|
|
221
247
|
// ---------------------------------------------------------------------------
|
|
222
248
|
|
|
223
249
|
function renderRail() {
|
|
250
|
+
renderTeamRoster();
|
|
224
251
|
els['conv-list'].innerHTML = '';
|
|
225
|
-
|
|
252
|
+
// R4: filter by selected persona when one is active.
|
|
253
|
+
const list = projectConversations().filter((conv) =>
|
|
254
|
+
!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey
|
|
255
|
+
);
|
|
226
256
|
for (const conv of list) {
|
|
227
257
|
const btn = document.createElement('button');
|
|
228
258
|
btn.type = 'button';
|
|
229
259
|
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
230
260
|
btn.dataset.conv = conv.id;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (conv.
|
|
236
|
-
|
|
261
|
+
// Avatar chip — 34px visual anchor on the left for every item.
|
|
262
|
+
const personas = state.bootstrap?.personas || [];
|
|
263
|
+
let persona = null;
|
|
264
|
+
const chip = document.createElement('span');
|
|
265
|
+
if (conv.personaKey) {
|
|
266
|
+
persona = personas.find((p) => p.key === conv.personaKey) || null;
|
|
267
|
+
chip.className = 'conv-persona-chip';
|
|
268
|
+
chip.title = persona ? persona.displayName : conv.personaKey;
|
|
269
|
+
if (persona && persona.avatarUrl) {
|
|
270
|
+
const img = document.createElement('img');
|
|
271
|
+
img.src = persona.avatarUrl;
|
|
272
|
+
img.alt = persona.displayName;
|
|
273
|
+
chip.appendChild(img);
|
|
274
|
+
} else {
|
|
275
|
+
const label = persona ? persona.displayName : conv.personaKey;
|
|
276
|
+
chip.textContent = label.slice(0, 2).toUpperCase();
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
chip.className = 'conv-persona-chip conv-persona-chip--free';
|
|
280
|
+
chip.title = 'Free job';
|
|
281
|
+
chip.innerHTML = '<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-3.314 3.134-6 7-6s7 2.686 7 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
|
282
|
+
}
|
|
283
|
+
btn.appendChild(chip);
|
|
284
|
+
// Text body: persona name label (if persona) stacked above job title.
|
|
285
|
+
const bodyDiv = document.createElement('span');
|
|
286
|
+
bodyDiv.className = 'conv-body';
|
|
287
|
+
if (persona) {
|
|
288
|
+
const nameSpan = document.createElement('span');
|
|
289
|
+
nameSpan.className = 'conv-persona-name';
|
|
290
|
+
nameSpan.textContent = persona.displayName;
|
|
291
|
+
bodyDiv.appendChild(nameSpan);
|
|
292
|
+
}
|
|
293
|
+
const titleSpan = document.createElement('span');
|
|
294
|
+
titleSpan.className = 'conv-title';
|
|
295
|
+
titleSpan.textContent = conv.title || '';
|
|
296
|
+
bodyDiv.appendChild(titleSpan);
|
|
297
|
+
btn.appendChild(bodyDiv);
|
|
298
|
+
const statusSpan = document.createElement('span');
|
|
299
|
+
statusSpan.className = 'conv-status';
|
|
300
|
+
statusSpan.textContent = statusLabel(conv.status);
|
|
301
|
+
if (conv.status === 'running') statusSpan.classList.add('running');
|
|
302
|
+
if (conv.status === 'failed') statusSpan.classList.add('failed');
|
|
303
|
+
btn.appendChild(statusSpan);
|
|
237
304
|
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
238
305
|
els['conv-list'].appendChild(btn);
|
|
239
306
|
}
|
|
@@ -245,6 +312,169 @@ function statusLabel(s) {
|
|
|
245
312
|
return 'Done';
|
|
246
313
|
}
|
|
247
314
|
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Issue #385 — Persona UI (R3 + R4)
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
// Prefix a conversation title with its persona display name when a persona
|
|
320
|
+
// is assigned, so the rail and header always show who is responsible.
|
|
321
|
+
function conversationTitle(conv) {
|
|
322
|
+
if (!conv) return '';
|
|
323
|
+
if (!conv.personaKey) return conv.title || '';
|
|
324
|
+
const personas = state.bootstrap?.personas || [];
|
|
325
|
+
const persona = personas.find((p) => p.key === conv.personaKey);
|
|
326
|
+
const prefix = persona ? persona.displayName : conv.personaKey;
|
|
327
|
+
return `${prefix}: ${conv.title || ''}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// R4.2 — team roster: one avatar chip per hired persona above the conv list.
|
|
331
|
+
// Only rendered when at least one persona is hired (subscription active).
|
|
332
|
+
function renderTeamRoster() {
|
|
333
|
+
const roster = els['team-roster'];
|
|
334
|
+
if (!roster) return;
|
|
335
|
+
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
336
|
+
if (personas.length === 0) {
|
|
337
|
+
roster.hidden = true;
|
|
338
|
+
// Reset persona selection when there's no active subscription.
|
|
339
|
+
if (state.selectedPersonaKey) state.selectedPersonaKey = null;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
roster.hidden = false;
|
|
343
|
+
roster.innerHTML = '';
|
|
344
|
+
const allChip = document.createElement('button');
|
|
345
|
+
allChip.type = 'button';
|
|
346
|
+
allChip.className = 'roster-chip' + (!state.selectedPersonaKey ? ' active' : '');
|
|
347
|
+
allChip.title = 'All employees';
|
|
348
|
+
allChip.textContent = 'All';
|
|
349
|
+
allChip.addEventListener('click', () => setSelectedPersona(null));
|
|
350
|
+
roster.appendChild(allChip);
|
|
351
|
+
for (const persona of personas) {
|
|
352
|
+
const chip = document.createElement('button');
|
|
353
|
+
chip.type = 'button';
|
|
354
|
+
chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
|
|
355
|
+
chip.title = persona.displayName;
|
|
356
|
+
chip.setAttribute('aria-label', persona.displayName);
|
|
357
|
+
if (persona.avatarUrl) {
|
|
358
|
+
const img = document.createElement('img');
|
|
359
|
+
img.src = persona.avatarUrl;
|
|
360
|
+
img.alt = persona.displayName;
|
|
361
|
+
chip.appendChild(img);
|
|
362
|
+
} else {
|
|
363
|
+
chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
364
|
+
}
|
|
365
|
+
chip.addEventListener('click', () => setSelectedPersona(persona.key));
|
|
366
|
+
roster.appendChild(chip);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
|
|
371
|
+
function buildAvatarChip(persona, size) {
|
|
372
|
+
const chip = document.createElement('span');
|
|
373
|
+
chip.className = 'emp-avatar-sm';
|
|
374
|
+
chip.style.width = size + 'px';
|
|
375
|
+
chip.style.height = size + 'px';
|
|
376
|
+
chip.style.fontSize = Math.max(8, Math.floor(size * 0.42)) + 'px';
|
|
377
|
+
if (persona && persona.avatarUrl) {
|
|
378
|
+
const img = document.createElement('img');
|
|
379
|
+
img.src = persona.avatarUrl;
|
|
380
|
+
img.alt = persona.displayName;
|
|
381
|
+
chip.appendChild(img);
|
|
382
|
+
} else if (persona) {
|
|
383
|
+
chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
384
|
+
}
|
|
385
|
+
return chip;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function setSelectedPersona(key) {
|
|
389
|
+
state.selectedPersonaKey = key || null;
|
|
390
|
+
renderRail();
|
|
391
|
+
savePersonaPreference(key || null);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function savePersonaPreference(key) {
|
|
395
|
+
try {
|
|
396
|
+
await requestJson('/api/ai-hub/preferences', {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: { 'Content-Type': 'application/json' },
|
|
399
|
+
body: JSON.stringify({ personaKey: key }),
|
|
400
|
+
});
|
|
401
|
+
} catch { /* best-effort */ }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// R3.2/R3.3 — show hire-required notice when a locked job is clicked.
|
|
405
|
+
function showHireNotice(job) {
|
|
406
|
+
const personas = state.bootstrap?.personas || [];
|
|
407
|
+
const persona = personas.find((p) => p.key === job.requiredPersonaKey);
|
|
408
|
+
els['job-catalog'].hidden = true;
|
|
409
|
+
els['hire-notice'].hidden = false;
|
|
410
|
+
if (els['hire-notice-text']) {
|
|
411
|
+
els['hire-notice-text'].textContent = persona
|
|
412
|
+
? `"${job.title}" requires ${persona.displayName} (${persona.pricingLabel}). Hire them to unlock this job.`
|
|
413
|
+
: `"${job.title}" requires a persona that is not yet hired. Go to Pricing to hire them.`;
|
|
414
|
+
}
|
|
415
|
+
if (els['hire-notice-link'] && persona && persona.hireUrl) {
|
|
416
|
+
// R3.3: append returnTo so the pricing page can redirect back after hire.
|
|
417
|
+
const returnTo = encodeURIComponent(window.location.href);
|
|
418
|
+
els['hire-notice-link'].href = `${persona.hireUrl}&returnTo=${returnTo}`;
|
|
419
|
+
}
|
|
420
|
+
state.selectedJob = null;
|
|
421
|
+
els['next1'].disabled = true;
|
|
422
|
+
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function hideHireNotice() {
|
|
426
|
+
if (els['hire-notice']) els['hire-notice'].hidden = true;
|
|
427
|
+
if (els['job-catalog']) els['job-catalog'].hidden = false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// R3+ — persona filter bar inside the New Job modal.
|
|
431
|
+
// Shows "All jobs" + one chip per hired persona + "Free only".
|
|
432
|
+
// Only rendered when at least one persona is hired (subscription active).
|
|
433
|
+
function renderModalPersonaFilter() {
|
|
434
|
+
const container = els['job-persona-filter'];
|
|
435
|
+
if (!container) return;
|
|
436
|
+
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
437
|
+
if (personas.length === 0) {
|
|
438
|
+
container.hidden = true;
|
|
439
|
+
state.modalPersonaFilter = null;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
container.hidden = false;
|
|
443
|
+
container.innerHTML = '';
|
|
444
|
+
|
|
445
|
+
function makeFilterChip(key, label, avatarUrl) {
|
|
446
|
+
const btn = document.createElement('button');
|
|
447
|
+
btn.type = 'button';
|
|
448
|
+
btn.className = 'jf-chip' + (state.modalPersonaFilter === key ? ' active' : '');
|
|
449
|
+
if (avatarUrl) {
|
|
450
|
+
const img = document.createElement('img');
|
|
451
|
+
img.src = avatarUrl;
|
|
452
|
+
img.alt = label;
|
|
453
|
+
btn.appendChild(img);
|
|
454
|
+
}
|
|
455
|
+
const span = document.createElement('span');
|
|
456
|
+
span.textContent = label;
|
|
457
|
+
btn.appendChild(span);
|
|
458
|
+
btn.addEventListener('click', () => {
|
|
459
|
+
state.modalPersonaFilter = key;
|
|
460
|
+
// Reset job selection when filter changes.
|
|
461
|
+
state.selectedJob = null;
|
|
462
|
+
els['next1'].disabled = true;
|
|
463
|
+
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
464
|
+
hideHireNotice();
|
|
465
|
+
renderModalPersonaFilter();
|
|
466
|
+
renderJobCatalog(els['job-search'].value);
|
|
467
|
+
});
|
|
468
|
+
return btn;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
container.appendChild(makeFilterChip(null, 'All jobs'));
|
|
472
|
+
for (const persona of personas) {
|
|
473
|
+
container.appendChild(makeFilterChip(persona.key, persona.displayName, persona.avatarUrl));
|
|
474
|
+
}
|
|
475
|
+
container.appendChild(makeFilterChip('__free__', 'Free only'));
|
|
476
|
+
}
|
|
477
|
+
|
|
248
478
|
// ---------------------------------------------------------------------------
|
|
249
479
|
// Active conversation rendering
|
|
250
480
|
// ---------------------------------------------------------------------------
|
|
@@ -271,7 +501,7 @@ function renderActive() {
|
|
|
271
501
|
}
|
|
272
502
|
els['empty'].hidden = true;
|
|
273
503
|
els['active-conv'].hidden = false;
|
|
274
|
-
els['active-title'].textContent = conv
|
|
504
|
+
els['active-title'].textContent = conversationTitle(conv);
|
|
275
505
|
els['active-job'].textContent = `Job: ${conv.jobTitle}`;
|
|
276
506
|
|
|
277
507
|
// Progress section. Plain text updates are cheap and don't animate.
|
|
@@ -806,6 +1036,8 @@ function openModal(opts) {
|
|
|
806
1036
|
const filter = concept ? CONCEPT_PICKER_FILTERS[concept] : null;
|
|
807
1037
|
state.selectedJob = null;
|
|
808
1038
|
state.activeFilter = filter || null;
|
|
1039
|
+
// Inherit the rail persona selection as the starting filter for the modal.
|
|
1040
|
+
state.modalPersonaFilter = state.selectedPersonaKey || null;
|
|
809
1041
|
els['next1'].disabled = true;
|
|
810
1042
|
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
811
1043
|
els['job-search'].value = '';
|
|
@@ -813,6 +1045,9 @@ function openModal(opts) {
|
|
|
813
1045
|
els['start'].disabled = true;
|
|
814
1046
|
els['step1'].hidden = false;
|
|
815
1047
|
els['step2'].hidden = true;
|
|
1048
|
+
// R3: always start with the catalog visible, hire-notice hidden.
|
|
1049
|
+
if (els['hire-notice']) els['hire-notice'].hidden = true;
|
|
1050
|
+
if (els['job-catalog']) els['job-catalog'].hidden = false;
|
|
816
1051
|
// Update the modal heading + subhead to match the filter context.
|
|
817
1052
|
const h = document.querySelector('#step1 .modal-header h2');
|
|
818
1053
|
const p = document.querySelector('#step1 .modal-header p');
|
|
@@ -826,6 +1061,7 @@ function openModal(opts) {
|
|
|
826
1061
|
p.textContent = 'Pick one job. You can always start a new job for different work.';
|
|
827
1062
|
}
|
|
828
1063
|
renderEmployeeSelect();
|
|
1064
|
+
renderModalPersonaFilter();
|
|
829
1065
|
renderJobCatalog();
|
|
830
1066
|
els['modal'].hidden = false;
|
|
831
1067
|
els['modal'].classList.add('open');
|
|
@@ -850,8 +1086,10 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
850
1086
|
const managers = state.bootstrap?.managerTemplates || [];
|
|
851
1087
|
|
|
852
1088
|
// Decide which sources contribute given the active filter.
|
|
1089
|
+
// Manager templates are hidden whenever a persona/free chip is active —
|
|
1090
|
+
// they're meta-management jobs, not persona-specific work.
|
|
853
1091
|
const showEmployee = !filter || filter.kind === 'employee';
|
|
854
|
-
const showManager = !filter || filter.kind === 'manager';
|
|
1092
|
+
const showManager = state.modalPersonaFilter == null && (!filter || filter.kind === 'manager');
|
|
855
1093
|
|
|
856
1094
|
// Group employees by category.
|
|
857
1095
|
const employeeGroups = [];
|
|
@@ -860,7 +1098,13 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
860
1098
|
for (const cat of cats) {
|
|
861
1099
|
const rows = employees.filter((j) =>
|
|
862
1100
|
j.categoryId === cat.id &&
|
|
863
|
-
(!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f))
|
|
1101
|
+
(!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f)) &&
|
|
1102
|
+
// R3+: modal persona filter; null = All, '__free__' = free jobs only,
|
|
1103
|
+
// personaKey = only that persona's jobs (free jobs excluded).
|
|
1104
|
+
(state.modalPersonaFilter == null ||
|
|
1105
|
+
(state.modalPersonaFilter === '__free__'
|
|
1106
|
+
? j.requiredPersonaKey == null
|
|
1107
|
+
: j.requiredPersonaKey === state.modalPersonaFilter))
|
|
864
1108
|
);
|
|
865
1109
|
if (rows.length > 0) employeeGroups.push({ label: cat.label, items: rows });
|
|
866
1110
|
}
|
|
@@ -885,11 +1129,22 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
885
1129
|
const h4 = document.createElement('h4');
|
|
886
1130
|
h4.textContent = label;
|
|
887
1131
|
wrap.appendChild(h4);
|
|
1132
|
+
const personas = state.bootstrap?.personas || [];
|
|
1133
|
+
// Lock enforcement is only active when the workspace has an active subscription.
|
|
1134
|
+
// Legacy workspaces (subscriptionActive = false) see the full catalog with no locks.
|
|
1135
|
+
const personaSystemActive = state.bootstrap?.subscriptionActive ?? false;
|
|
888
1136
|
for (const job of items) {
|
|
889
1137
|
total += 1;
|
|
1138
|
+
// R3.1: a job is locked when it requires a persona with status 'locked',
|
|
1139
|
+
// but only when the persona system is active (subscription present).
|
|
1140
|
+
const isLocked = personaSystemActive && job.requiredPersonaKey != null && personas.some(
|
|
1141
|
+
(p) => p.key === job.requiredPersonaKey && p.status === 'locked'
|
|
1142
|
+
);
|
|
890
1143
|
const btn = document.createElement('button');
|
|
891
1144
|
btn.type = 'button';
|
|
892
|
-
btn.className = 'job-option' +
|
|
1145
|
+
btn.className = 'job-option' +
|
|
1146
|
+
(isLocked ? ' locked' : '') +
|
|
1147
|
+
(state.selectedJob?.id === job.id ? ' selected' : '');
|
|
893
1148
|
btn.dataset.jobId = job.id;
|
|
894
1149
|
const strong = document.createElement('strong');
|
|
895
1150
|
strong.textContent = job.title;
|
|
@@ -897,7 +1152,20 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
897
1152
|
span.textContent = job.intent || '';
|
|
898
1153
|
btn.appendChild(strong);
|
|
899
1154
|
btn.appendChild(span);
|
|
1155
|
+
if (isLocked) {
|
|
1156
|
+
// R3.1: inline lock badge showing persona display name.
|
|
1157
|
+
const lockBadge = document.createElement('span');
|
|
1158
|
+
lockBadge.className = 'lock-badge';
|
|
1159
|
+
const persona = personas.find((p) => p.key === job.requiredPersonaKey);
|
|
1160
|
+
lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
|
|
1161
|
+
btn.appendChild(lockBadge);
|
|
1162
|
+
}
|
|
900
1163
|
btn.addEventListener('click', () => {
|
|
1164
|
+
if (isLocked) {
|
|
1165
|
+
// R3.2: locked job click shows hire-required notice.
|
|
1166
|
+
showHireNotice(job);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
901
1169
|
state.selectedJob = job;
|
|
902
1170
|
els['next1'].disabled = false;
|
|
903
1171
|
els['job-pick-status'].textContent = `Selected: ${job.title}`;
|
|
@@ -1093,13 +1361,20 @@ async function refreshEmployees() {
|
|
|
1093
1361
|
// string, never just the instructions slice.
|
|
1094
1362
|
function deriveTitle(jobTitle, instructions) {
|
|
1095
1363
|
const trimmedJob = (jobTitle || '').trim();
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1364
|
+
const raw = (instructions || '').trim();
|
|
1365
|
+
// Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
|
|
1366
|
+
const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
|
|
1367
|
+
const stripped = raw
|
|
1098
1368
|
.replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
|
|
1099
1369
|
.replace(/\s+/g, ' ')
|
|
1100
1370
|
.trim();
|
|
1101
1371
|
const words = stripped.split(' ').filter(Boolean).slice(0, 6);
|
|
1102
1372
|
const intent = words.join(' ');
|
|
1373
|
+
// When instructions are solely a FRAIM invocation, the slug IS the job — use it
|
|
1374
|
+
// regardless of jobTitle (which may be the generic "Freeform task" placeholder).
|
|
1375
|
+
if (!intent && fraimSlugMatch) {
|
|
1376
|
+
return fraimSlugMatch[1].split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
1377
|
+
}
|
|
1103
1378
|
if (!trimmedJob && !intent) return 'New job';
|
|
1104
1379
|
if (!intent) return trimmedJob;
|
|
1105
1380
|
// If the job title and the start of the instructions happen to be the
|
|
@@ -1177,6 +1452,8 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1177
1452
|
jobId: job.id,
|
|
1178
1453
|
jobTitle: job.title,
|
|
1179
1454
|
employeeId,
|
|
1455
|
+
// R4: assign the persona key for this job (null for free jobs or freeform).
|
|
1456
|
+
personaKey: (job.requiredPersonaKey != null ? job.requiredPersonaKey : null),
|
|
1180
1457
|
runId: null,
|
|
1181
1458
|
sessionId: null,
|
|
1182
1459
|
status: 'running',
|
|
@@ -1310,6 +1587,10 @@ function foldRunIntoConversation(conv, run) {
|
|
|
1310
1587
|
}
|
|
1311
1588
|
// Track session for resumption.
|
|
1312
1589
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
1590
|
+
// R4: persist the persona key from the server-side run record.
|
|
1591
|
+
if (run.personaKey !== undefined && conv.personaKey == null) {
|
|
1592
|
+
conv.personaKey = run.personaKey;
|
|
1593
|
+
}
|
|
1313
1594
|
// Update status.
|
|
1314
1595
|
if (run.status === 'completed') conv.status = 'completed';
|
|
1315
1596
|
else if (run.status === 'failed') conv.status = 'failed';
|
|
@@ -1438,6 +1719,10 @@ function wireEvents() {
|
|
|
1438
1719
|
els['project-button'].addEventListener('click', pickProject);
|
|
1439
1720
|
els['new-conv-btn'].addEventListener('click', openModal);
|
|
1440
1721
|
els['cancel1'].addEventListener('click', closeModal);
|
|
1722
|
+
// Issue #385: hire-required notice back button.
|
|
1723
|
+
if (els['hire-notice-back']) {
|
|
1724
|
+
els['hire-notice-back'].addEventListener('click', hideHireNotice);
|
|
1725
|
+
}
|
|
1441
1726
|
els['back2'].addEventListener('click', () => {
|
|
1442
1727
|
els['step1'].hidden = false;
|
|
1443
1728
|
els['step2'].hidden = true;
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -227,11 +227,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
227
227
|
background: transparent;
|
|
228
228
|
border: 1px solid transparent;
|
|
229
229
|
border-radius: 10px;
|
|
230
|
-
padding: 10px
|
|
230
|
+
padding: 8px 10px;
|
|
231
231
|
display: flex;
|
|
232
232
|
align-items: center;
|
|
233
233
|
justify-content: space-between;
|
|
234
|
-
gap:
|
|
234
|
+
gap: 10px;
|
|
235
235
|
color: var(--text);
|
|
236
236
|
}
|
|
237
237
|
.conv-item:hover { background: var(--soft); }
|
|
@@ -240,9 +240,47 @@ button { font: inherit; cursor: pointer; }
|
|
|
240
240
|
border-color: var(--line);
|
|
241
241
|
box-shadow: var(--shadow);
|
|
242
242
|
}
|
|
243
|
-
|
|
243
|
+
/* Text column: stacks persona name label above job title */
|
|
244
|
+
.conv-body {
|
|
245
|
+
flex: 1;
|
|
246
|
+
min-width: 0;
|
|
247
|
+
display: flex;
|
|
248
|
+
flex-direction: column;
|
|
249
|
+
gap: 1px;
|
|
250
|
+
}
|
|
251
|
+
.conv-persona-name {
|
|
252
|
+
font-size: 10px;
|
|
253
|
+
font-weight: 700;
|
|
254
|
+
color: var(--accent-strong);
|
|
255
|
+
letter-spacing: 0.06em;
|
|
256
|
+
text-transform: uppercase;
|
|
257
|
+
white-space: nowrap;
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
text-overflow: ellipsis;
|
|
260
|
+
}
|
|
261
|
+
.conv-title { font-size: 13px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
262
|
+
/* Persona avatar chip — enlarged to 34px to serve as a clear visual anchor */
|
|
263
|
+
.conv-persona-chip {
|
|
264
|
+
flex-shrink: 0;
|
|
265
|
+
width: 34px;
|
|
266
|
+
height: 34px;
|
|
267
|
+
border-radius: 50%;
|
|
268
|
+
background: var(--accent-soft);
|
|
269
|
+
border: 1.5px solid var(--accent);
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
justify-content: center;
|
|
273
|
+
font-size: 11px;
|
|
274
|
+
font-weight: 700;
|
|
275
|
+
color: var(--accent-strong);
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
line-height: 1;
|
|
278
|
+
text-transform: uppercase;
|
|
279
|
+
}
|
|
280
|
+
.conv-persona-chip img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
|
281
|
+
.conv-persona-chip--free { background: transparent; border-color: var(--line); color: var(--muted); }
|
|
244
282
|
.conv-status {
|
|
245
|
-
font-size:
|
|
283
|
+
font-size: 11px;
|
|
246
284
|
color: var(--muted);
|
|
247
285
|
font-weight: 500;
|
|
248
286
|
flex-shrink: 0;
|
|
@@ -912,3 +950,160 @@ button.small { padding: 4px 10px; font-size: 12px; }
|
|
|
912
950
|
.totals span { cursor: help; }
|
|
913
951
|
.totals .sep { color: var(--line); }
|
|
914
952
|
.totals strong { color: var(--text); font-weight: 600; }
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
/* ── Issue #385: Team roster, Employee selector, Lock badges, Hire notice ── */
|
|
956
|
+
|
|
957
|
+
/* Team roster — horizontal chip row above conv list */
|
|
958
|
+
.team-roster {
|
|
959
|
+
display: flex;
|
|
960
|
+
align-items: center;
|
|
961
|
+
gap: 6px;
|
|
962
|
+
padding: 8px 10px 4px;
|
|
963
|
+
flex-wrap: wrap;
|
|
964
|
+
}
|
|
965
|
+
.roster-chip {
|
|
966
|
+
display: flex;
|
|
967
|
+
align-items: center;
|
|
968
|
+
justify-content: center;
|
|
969
|
+
width: 36px;
|
|
970
|
+
height: 36px;
|
|
971
|
+
border-radius: 50%;
|
|
972
|
+
background: var(--accent-soft);
|
|
973
|
+
border: 2px solid var(--line);
|
|
974
|
+
font-size: 11px;
|
|
975
|
+
font-weight: 700;
|
|
976
|
+
color: var(--accent-strong);
|
|
977
|
+
cursor: pointer;
|
|
978
|
+
overflow: hidden;
|
|
979
|
+
flex-shrink: 0;
|
|
980
|
+
}
|
|
981
|
+
.roster-chip.active { border-color: var(--accent); }
|
|
982
|
+
.roster-chip img { width: 100%; height: 100%; object-fit: cover; }
|
|
983
|
+
.roster-chip-add {
|
|
984
|
+
width: 36px;
|
|
985
|
+
height: 36px;
|
|
986
|
+
border-radius: 50%;
|
|
987
|
+
border: 2px dashed var(--line);
|
|
988
|
+
background: none;
|
|
989
|
+
font-size: 18px;
|
|
990
|
+
color: var(--muted);
|
|
991
|
+
cursor: pointer;
|
|
992
|
+
display: flex;
|
|
993
|
+
align-items: center;
|
|
994
|
+
justify-content: center;
|
|
995
|
+
text-decoration: none;
|
|
996
|
+
}
|
|
997
|
+
.roster-chip-add:hover { border-color: var(--accent); color: var(--accent); }
|
|
998
|
+
|
|
999
|
+
/* Employee selector */
|
|
1000
|
+
.employee-selector { padding: 4px 8px; position: relative; }
|
|
1001
|
+
.emp-select-btn {
|
|
1002
|
+
display: flex;
|
|
1003
|
+
align-items: center;
|
|
1004
|
+
gap: 6px;
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
background: var(--soft);
|
|
1007
|
+
border: 1px solid var(--line);
|
|
1008
|
+
border-radius: 8px;
|
|
1009
|
+
padding: 7px 10px;
|
|
1010
|
+
font: inherit;
|
|
1011
|
+
font-size: 13px;
|
|
1012
|
+
color: var(--text);
|
|
1013
|
+
cursor: pointer;
|
|
1014
|
+
text-align: left;
|
|
1015
|
+
}
|
|
1016
|
+
.emp-select-btn:hover { background: var(--accent-soft); border-color: var(--accent); }
|
|
1017
|
+
.emp-avatar-sm {
|
|
1018
|
+
width: 20px;
|
|
1019
|
+
height: 20px;
|
|
1020
|
+
border-radius: 50%;
|
|
1021
|
+
background: var(--accent-soft);
|
|
1022
|
+
font-size: 9px;
|
|
1023
|
+
font-weight: 700;
|
|
1024
|
+
color: var(--accent-strong);
|
|
1025
|
+
display: inline-flex;
|
|
1026
|
+
align-items: center;
|
|
1027
|
+
justify-content: center;
|
|
1028
|
+
flex-shrink: 0;
|
|
1029
|
+
overflow: hidden;
|
|
1030
|
+
}
|
|
1031
|
+
.emp-avatar-sm img { width: 100%; height: 100%; object-fit: cover; }
|
|
1032
|
+
.emp-caret { margin-left: auto; color: var(--muted); font-size: 11px; }
|
|
1033
|
+
.emp-dropdown {
|
|
1034
|
+
position: absolute;
|
|
1035
|
+
top: calc(100% + 2px);
|
|
1036
|
+
left: 8px;
|
|
1037
|
+
right: 8px;
|
|
1038
|
+
background: var(--surface);
|
|
1039
|
+
border: 1px solid var(--line);
|
|
1040
|
+
border-radius: 8px;
|
|
1041
|
+
box-shadow: var(--shadow-lg);
|
|
1042
|
+
z-index: 100;
|
|
1043
|
+
overflow: hidden;
|
|
1044
|
+
}
|
|
1045
|
+
.emp-option {
|
|
1046
|
+
display: flex;
|
|
1047
|
+
align-items: center;
|
|
1048
|
+
gap: 8px;
|
|
1049
|
+
padding: 8px 12px;
|
|
1050
|
+
font-size: 13px;
|
|
1051
|
+
color: var(--text);
|
|
1052
|
+
cursor: pointer;
|
|
1053
|
+
background: none;
|
|
1054
|
+
border: none;
|
|
1055
|
+
width: 100%;
|
|
1056
|
+
text-align: left;
|
|
1057
|
+
font: inherit;
|
|
1058
|
+
}
|
|
1059
|
+
.emp-option:hover { background: var(--soft); }
|
|
1060
|
+
.emp-option.active { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
|
|
1061
|
+
|
|
1062
|
+
/* Lock badge — inline after job title */
|
|
1063
|
+
.lock-badge {
|
|
1064
|
+
font-size: 11px;
|
|
1065
|
+
font-weight: 600;
|
|
1066
|
+
color: var(--warn);
|
|
1067
|
+
margin-left: 6px;
|
|
1068
|
+
white-space: nowrap;
|
|
1069
|
+
}
|
|
1070
|
+
.job-option.locked { opacity: 0.85; }
|
|
1071
|
+
.job-option.locked:hover { background: var(--warn-soft) !important; }
|
|
1072
|
+
|
|
1073
|
+
/* Persona job filter bar inside New Job modal */
|
|
1074
|
+
#job-persona-filter {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
flex-wrap: wrap;
|
|
1077
|
+
gap: 6px;
|
|
1078
|
+
margin-bottom: 12px;
|
|
1079
|
+
}
|
|
1080
|
+
.jf-chip {
|
|
1081
|
+
display: inline-flex;
|
|
1082
|
+
align-items: center;
|
|
1083
|
+
gap: 5px;
|
|
1084
|
+
padding: 4px 10px 4px 6px;
|
|
1085
|
+
border: 1.5px solid var(--line);
|
|
1086
|
+
border-radius: 20px;
|
|
1087
|
+
background: transparent;
|
|
1088
|
+
cursor: pointer;
|
|
1089
|
+
font-size: 13px;
|
|
1090
|
+
font-weight: 500;
|
|
1091
|
+
color: var(--text);
|
|
1092
|
+
transition: border-color 0.15s, background 0.15s;
|
|
1093
|
+
white-space: nowrap;
|
|
1094
|
+
}
|
|
1095
|
+
.jf-chip:hover { border-color: var(--accent); }
|
|
1096
|
+
.jf-chip.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
|
|
1097
|
+
.jf-chip img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
|
|
1098
|
+
|
|
1099
|
+
/* Hire-required notice */
|
|
1100
|
+
#hire-notice {
|
|
1101
|
+
margin: 0 0 0;
|
|
1102
|
+
border-left: 3px solid var(--warn);
|
|
1103
|
+
border-radius: 8px;
|
|
1104
|
+
background: var(--warn-soft);
|
|
1105
|
+
padding: 16px 18px;
|
|
1106
|
+
margin: 0 0 12px;
|
|
1107
|
+
}
|
|
1108
|
+
#hire-notice p { margin: 0 0 12px; font-size: 14px; color: var(--text); }
|
|
1109
|
+
.hire-notice-actions { display: flex; align-items: center; gap: 10px; }
|