fraim-hub 2.0.283 → 2.0.284
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/hosts.js +2 -1
- package/dist/src/ai-hub/hub-latest-version.js +9 -2
- package/dist/src/ai-hub/hub-launcher.js +7 -3
- package/dist/src/ai-hub/server.js +373 -1
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +7 -6
- package/dist/src/core/utils/windows-shim-path-search.js +56 -0
- package/package.json +3 -2
- package/public/ai-hub/excel-taskpane/index.html +47 -11
- package/public/ai-hub/index.html +4 -0
- package/public/ai-hub/script.js +29 -5
- package/public/ai-hub/styles.css +22 -0
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -2156,7 +2156,8 @@ class FakeHostRuntime {
|
|
|
2156
2156
|
}
|
|
2157
2157
|
startRun(hostId, _projectPath, message, handlers, _sessionId) {
|
|
2158
2158
|
this.lastStartMessage = message;
|
|
2159
|
-
|
|
2159
|
+
const reply = this.startReply ?? this.fakeEmployeeReply('start', message);
|
|
2160
|
+
return this.fakeProcess(hostId, reply, handlers);
|
|
2160
2161
|
}
|
|
2161
2162
|
continueRun(hostId, _projectPath, sessionId, message, handlers) {
|
|
2162
2163
|
this.lastContinueMessage = message;
|
|
@@ -9,9 +9,16 @@ exports.refreshLatestPublishedVersionInBackground = refreshLatestPublishedVersio
|
|
|
9
9
|
exports.__resetLatestVersionCache = __resetLatestVersionCache;
|
|
10
10
|
const https_1 = __importDefault(require("https"));
|
|
11
11
|
// Issue #755: best-effort "latest published fraim version" for the Hub's
|
|
12
|
-
// update-available nudge. Cached in-process (
|
|
12
|
+
// update-available nudge. Cached in-process (5m TTL) and never throws — returns
|
|
13
13
|
// null when offline or the registry is unreachable, so the nudge simply hides.
|
|
14
|
-
|
|
14
|
+
// Issue #1379: TTL_MS was 1 hour, so a real npm publish could take up to an hour to
|
|
15
|
+
// ever show up in a running Hub server's own cache, confirmed live (one process still
|
|
16
|
+
// reporting a stale "latest" long after a newer version had been on the registry for
|
|
17
|
+
// several minutes, while a freshly-started process reported the correct one). The
|
|
18
|
+
// client only calls GET /api/ai-hub/version on page load, bootstrap refresh, and
|
|
19
|
+
// persona-identity retry (not a fast poll loop), so 5 minutes cuts worst-case
|
|
20
|
+
// staleness by 12x with no meaningful increase in registry request volume.
|
|
21
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
15
22
|
const FAILED_RETRY_MS = 5 * 60 * 1000;
|
|
16
23
|
const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
|
|
17
24
|
let cache = { value: null, at: 0 };
|
|
@@ -18,6 +18,7 @@ exports.writeMacosAppBundle = writeMacosAppBundle;
|
|
|
18
18
|
// `fraim-hub install` from the npm package.
|
|
19
19
|
const fs_1 = __importDefault(require("fs"));
|
|
20
20
|
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const windows_shim_path_search_1 = require("../core/utils/windows-shim-path-search");
|
|
21
22
|
/**
|
|
22
23
|
* What the launcher runs.
|
|
23
24
|
*
|
|
@@ -94,13 +95,16 @@ function renderWindowsLauncher(nodeDir) {
|
|
|
94
95
|
'rem re-running `npx fraim-hub@latest install` recreates it.',
|
|
95
96
|
'setlocal',
|
|
96
97
|
`set "PATH=%PATH%;${batchEscape(nodeDir)}"`,
|
|
97
|
-
|
|
98
|
-
'
|
|
98
|
+
// Issue #1375: `where npx` depends on `where.exe` (C:\Windows\System32), which is
|
|
99
|
+
// unresolvable when the launching process's PATH omits System32. The PATH-search below
|
|
100
|
+
// needs no external binary and resolves an absolute path we can invoke directly.
|
|
101
|
+
...(0, windows_shim_path_search_1.windowsPathSearchLines)('NPX_CMD', 'npx'),
|
|
102
|
+
'if not defined NPX_CMD (',
|
|
99
103
|
` echo ${MISSING_NODE_MESSAGE}`,
|
|
100
104
|
' pause',
|
|
101
105
|
' exit /b 1',
|
|
102
106
|
')',
|
|
103
|
-
`call
|
|
107
|
+
`call "%NPX_CMD%" ${NPX_ARGS} %*`,
|
|
104
108
|
'',
|
|
105
109
|
].join('\r\n');
|
|
106
110
|
}
|
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
39
|
+
exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
40
|
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
41
41
|
exports.hubCommandVersion = hubCommandVersion;
|
|
42
42
|
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
@@ -48,6 +48,7 @@ const crypto_1 = require("crypto");
|
|
|
48
48
|
const child_process_1 = require("child_process");
|
|
49
49
|
const https_1 = __importDefault(require("https"));
|
|
50
50
|
const types_1 = require("../first-run/types");
|
|
51
|
+
const local_provider_registry_1 = require("../cli/providers/local-provider-registry");
|
|
51
52
|
const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
|
|
52
53
|
const brand_store_1 = require("../core/brand-store");
|
|
53
54
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
@@ -604,6 +605,227 @@ class HostConfigStore {
|
|
|
604
605
|
}
|
|
605
606
|
}
|
|
606
607
|
exports.HostConfigStore = HostConfigStore;
|
|
608
|
+
class HubConnectorStatusStore {
|
|
609
|
+
constructor(filePath) {
|
|
610
|
+
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-connectors.json');
|
|
611
|
+
}
|
|
612
|
+
load() {
|
|
613
|
+
try {
|
|
614
|
+
if (!fs_1.default.existsSync(this.filePath))
|
|
615
|
+
return [];
|
|
616
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
|
|
617
|
+
if (!Array.isArray(parsed))
|
|
618
|
+
return [];
|
|
619
|
+
return parsed.map((entry) => normalizeStoredConnectorStatus(entry)).filter(Boolean);
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
saveStatus(status) {
|
|
626
|
+
const list = this.load();
|
|
627
|
+
const next = list.filter((entry) => entry.id !== status.id);
|
|
628
|
+
next.push(status);
|
|
629
|
+
fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
|
|
630
|
+
fs_1.default.writeFileSync(this.filePath, JSON.stringify(next.sort((a, b) => a.id.localeCompare(b.id)), null, 2));
|
|
631
|
+
return status;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
exports.HubConnectorStatusStore = HubConnectorStatusStore;
|
|
635
|
+
function normalizeConnectorId(raw) {
|
|
636
|
+
if (typeof raw !== 'string')
|
|
637
|
+
return null;
|
|
638
|
+
const id = raw.trim().toLowerCase();
|
|
639
|
+
return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(id) ? id : null;
|
|
640
|
+
}
|
|
641
|
+
function connectorReadiness(configured, authenticated) {
|
|
642
|
+
if (!configured)
|
|
643
|
+
return 'missing_config';
|
|
644
|
+
if (!authenticated)
|
|
645
|
+
return 'needs_auth';
|
|
646
|
+
return 'ready';
|
|
647
|
+
}
|
|
648
|
+
function normalizeStoredConnectorStatus(raw) {
|
|
649
|
+
if (!raw || typeof raw !== 'object')
|
|
650
|
+
return null;
|
|
651
|
+
const value = raw;
|
|
652
|
+
const id = normalizeConnectorId(value.id);
|
|
653
|
+
if (!id)
|
|
654
|
+
return null;
|
|
655
|
+
const configured = Boolean(value.configured);
|
|
656
|
+
const authenticated = Boolean(value.authenticated);
|
|
657
|
+
const label = typeof value.label === 'string' && value.label.trim() ? value.label.trim() : id;
|
|
658
|
+
const transport = value.transport === 'stdio' || value.transport === 'http' ? value.transport : undefined;
|
|
659
|
+
const authMode = value.authMode === 'none' ||
|
|
660
|
+
value.authMode === 'env' ||
|
|
661
|
+
value.authMode === 'oauth' ||
|
|
662
|
+
value.authMode === 'api-token' ||
|
|
663
|
+
value.authMode === 'host-managed' ||
|
|
664
|
+
value.authMode === 'unknown'
|
|
665
|
+
? value.authMode
|
|
666
|
+
: undefined;
|
|
667
|
+
return {
|
|
668
|
+
id,
|
|
669
|
+
label,
|
|
670
|
+
configured,
|
|
671
|
+
authenticated,
|
|
672
|
+
readiness: connectorReadiness(configured, authenticated),
|
|
673
|
+
source: 'configured',
|
|
674
|
+
...(transport ? { transport } : {}),
|
|
675
|
+
...(authMode ? { authMode } : {}),
|
|
676
|
+
...(typeof value.detail === 'string' && value.detail.trim() ? { detail: value.detail.trim() } : {}),
|
|
677
|
+
...(typeof value.updatedAt === 'string' && value.updatedAt.trim() ? { updatedAt: value.updatedAt.trim() } : {}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function hasSecretLikeField(raw) {
|
|
681
|
+
if (!raw || typeof raw !== 'object')
|
|
682
|
+
return false;
|
|
683
|
+
if (Array.isArray(raw))
|
|
684
|
+
return raw.some((entry) => hasSecretLikeField(entry));
|
|
685
|
+
return Object.entries(raw).some(([key, value]) => /token|secret|password|apikey|api_key|credential/i.test(key) || hasSecretLikeField(value));
|
|
686
|
+
}
|
|
687
|
+
function readFraimApiKeyConfigured() {
|
|
688
|
+
if (process.env.FRAIM_API_KEY && process.env.FRAIM_API_KEY.trim())
|
|
689
|
+
return true;
|
|
690
|
+
try {
|
|
691
|
+
const configPath = path_1.default.join(os_1.default.homedir(), '.fraim', 'config.json');
|
|
692
|
+
if (!fs_1.default.existsSync(configPath))
|
|
693
|
+
return false;
|
|
694
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
695
|
+
return typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0;
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function providerConnectorStatus(provider) {
|
|
702
|
+
if (!provider.mcpServer)
|
|
703
|
+
return null;
|
|
704
|
+
const mcpServer = provider.mcpServer;
|
|
705
|
+
const needsAuth = Boolean(mcpServer.authHeaderTemplate || mcpServer.envTemplate);
|
|
706
|
+
return {
|
|
707
|
+
id: provider.id,
|
|
708
|
+
label: provider.displayName,
|
|
709
|
+
configured: false,
|
|
710
|
+
authenticated: false,
|
|
711
|
+
readiness: 'missing_config',
|
|
712
|
+
source: 'derived',
|
|
713
|
+
transport: mcpServer.type,
|
|
714
|
+
authMode: needsAuth ? 'unknown' : 'host-managed',
|
|
715
|
+
detail: 'External provider readiness is supplied by an agent-guided setup on this Hub machine, then recorded with non-secret connector status.',
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function genericConnectorStatus(id) {
|
|
719
|
+
const provider = (0, local_provider_registry_1.getAllLocalProviders)().find((entry) => entry.id === id);
|
|
720
|
+
const providerStatus = provider ? providerConnectorStatus(provider) : null;
|
|
721
|
+
if (providerStatus)
|
|
722
|
+
return providerStatus;
|
|
723
|
+
return {
|
|
724
|
+
id,
|
|
725
|
+
label: id,
|
|
726
|
+
configured: false,
|
|
727
|
+
authenticated: false,
|
|
728
|
+
readiness: 'missing_config',
|
|
729
|
+
source: 'derived',
|
|
730
|
+
authMode: 'unknown',
|
|
731
|
+
detail: 'No connector status has been configured for this machine.',
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function buildStoredConnectorStatusFromDerived(status) {
|
|
735
|
+
return normalizeStoredConnectorStatus({
|
|
736
|
+
...status,
|
|
737
|
+
source: 'configured',
|
|
738
|
+
updatedAt: new Date().toISOString(),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function connectorSetupResponseForStatus(status, setupStatus, message, action) {
|
|
742
|
+
return {
|
|
743
|
+
connectorId: status.id,
|
|
744
|
+
status: setupStatus,
|
|
745
|
+
connector: status,
|
|
746
|
+
...(action ? { action } : {}),
|
|
747
|
+
checkUrl: `/api/ai-hub/connectors/${status.id}/setup/check`,
|
|
748
|
+
message,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function buildDerivedConnectorStatuses() {
|
|
752
|
+
const fraimReady = readFraimApiKeyConfigured();
|
|
753
|
+
const base = [
|
|
754
|
+
{
|
|
755
|
+
id: 'fraim',
|
|
756
|
+
label: 'FRAIM',
|
|
757
|
+
configured: fraimReady,
|
|
758
|
+
authenticated: fraimReady,
|
|
759
|
+
readiness: connectorReadiness(fraimReady, fraimReady),
|
|
760
|
+
source: 'derived',
|
|
761
|
+
transport: 'stdio',
|
|
762
|
+
authMode: 'api-token',
|
|
763
|
+
detail: fraimReady ? 'FRAIM credential is available on this machine.' : 'Run fraim setup on this machine.',
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
id: 'git',
|
|
767
|
+
label: 'Git',
|
|
768
|
+
configured: true,
|
|
769
|
+
authenticated: true,
|
|
770
|
+
readiness: 'ready',
|
|
771
|
+
source: 'derived',
|
|
772
|
+
transport: 'stdio',
|
|
773
|
+
authMode: 'none',
|
|
774
|
+
detail: 'Base local Git MCP server is available in the generated FRAIM MCP set.',
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
id: 'playwright',
|
|
778
|
+
label: 'Playwright',
|
|
779
|
+
configured: true,
|
|
780
|
+
authenticated: true,
|
|
781
|
+
readiness: 'ready',
|
|
782
|
+
source: 'derived',
|
|
783
|
+
transport: 'stdio',
|
|
784
|
+
authMode: 'none',
|
|
785
|
+
detail: 'Base local Playwright MCP server is available in the generated FRAIM MCP set.',
|
|
786
|
+
},
|
|
787
|
+
];
|
|
788
|
+
const providers = (0, local_provider_registry_1.getAllLocalProviders)()
|
|
789
|
+
.map((provider) => providerConnectorStatus(provider))
|
|
790
|
+
.filter(Boolean);
|
|
791
|
+
return [...base, ...providers];
|
|
792
|
+
}
|
|
793
|
+
function mergeConnectorStatuses(stored) {
|
|
794
|
+
const merged = new Map();
|
|
795
|
+
for (const status of buildDerivedConnectorStatuses())
|
|
796
|
+
merged.set(status.id, status);
|
|
797
|
+
for (const status of stored) {
|
|
798
|
+
const derived = merged.get(status.id);
|
|
799
|
+
if (derived?.readiness === 'ready' && status.readiness !== 'ready')
|
|
800
|
+
continue;
|
|
801
|
+
merged.set(status.id, status);
|
|
802
|
+
}
|
|
803
|
+
return [...merged.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
804
|
+
}
|
|
805
|
+
function missingRequiredConnectors(required, connectors) {
|
|
806
|
+
if (!Array.isArray(required) || required.length === 0)
|
|
807
|
+
return [];
|
|
808
|
+
const byId = new Map(connectors.map((connector) => [connector.id, connector]));
|
|
809
|
+
const missing = [];
|
|
810
|
+
for (const raw of required) {
|
|
811
|
+
const id = normalizeConnectorId(raw);
|
|
812
|
+
if (!id)
|
|
813
|
+
continue;
|
|
814
|
+
const connector = byId.get(id) || {
|
|
815
|
+
id,
|
|
816
|
+
label: id,
|
|
817
|
+
configured: false,
|
|
818
|
+
authenticated: false,
|
|
819
|
+
readiness: 'missing_config',
|
|
820
|
+
source: 'derived',
|
|
821
|
+
authMode: 'unknown',
|
|
822
|
+
detail: 'No connector status has been configured for this machine.',
|
|
823
|
+
};
|
|
824
|
+
if (connector.readiness !== 'ready')
|
|
825
|
+
missing.push(connector);
|
|
826
|
+
}
|
|
827
|
+
return missing;
|
|
828
|
+
}
|
|
607
829
|
async function pingHost(host) {
|
|
608
830
|
const start = Date.now();
|
|
609
831
|
try {
|
|
@@ -1530,6 +1752,31 @@ function hubCommandVersion(command, extraBinDirs, basePath) {
|
|
|
1530
1752
|
const raw = (result.stdout || result.stderr || '').trim();
|
|
1531
1753
|
return raw || null;
|
|
1532
1754
|
}
|
|
1755
|
+
function hubRunSync(command, args, env) {
|
|
1756
|
+
const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, process.env.PATH) || command;
|
|
1757
|
+
const executable = process.platform === 'win32' ? 'cmd.exe' : resolvedCommand;
|
|
1758
|
+
const realArgs = process.platform === 'win32'
|
|
1759
|
+
? ['/d', '/s', '/c', [resolvedCommand, ...args].map(hosts_1.escapeWindowsArg).join(' ')]
|
|
1760
|
+
: args;
|
|
1761
|
+
const childEnv = { ...process.env };
|
|
1762
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
1763
|
+
if (value === undefined)
|
|
1764
|
+
delete childEnv[key];
|
|
1765
|
+
else
|
|
1766
|
+
childEnv[key] = value;
|
|
1767
|
+
}
|
|
1768
|
+
const result = (0, child_process_1.spawnSync)(executable, realArgs, {
|
|
1769
|
+
encoding: 'utf8',
|
|
1770
|
+
timeout: 10_000,
|
|
1771
|
+
env: childEnv,
|
|
1772
|
+
});
|
|
1773
|
+
return {
|
|
1774
|
+
status: result.status,
|
|
1775
|
+
stdout: result.stdout || '',
|
|
1776
|
+
stderr: result.stderr || '',
|
|
1777
|
+
...(result.error ? { error: result.error } : {}),
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1533
1780
|
function hubRunProcess(command, args, env) {
|
|
1534
1781
|
if (process.env.NODE_ENV === 'test' && command === 'npm' && process.env.FRAIM_TEST_HUB_NPM_ERROR) {
|
|
1535
1782
|
return Promise.reject(new Error(process.env.FRAIM_TEST_HUB_NPM_ERROR));
|
|
@@ -2200,6 +2447,7 @@ class AiHubServer {
|
|
|
2200
2447
|
this.deploymentStoreProvided = Boolean(options.deploymentStore);
|
|
2201
2448
|
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
2202
2449
|
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
2450
|
+
this.connectorStatusStore = options.connectorStatusStore ?? new HubConnectorStatusStore();
|
|
2203
2451
|
this.restartRecoveryPolicy = new restart_recovery_policy_1.RestartRecoveryPolicy({ machineLevelJobIds: MACHINE_LEVEL_JOB_IDS });
|
|
2204
2452
|
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
2205
2453
|
// CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
|
|
@@ -5446,6 +5694,16 @@ class AiHubServer {
|
|
|
5446
5694
|
if (!instructions && !legacyMessage) {
|
|
5447
5695
|
throw new Error('Coach your employee before starting the run.');
|
|
5448
5696
|
}
|
|
5697
|
+
const missingConnectors = missingRequiredConnectors(req.body.requiredConnectors, mergeConnectorStatuses(this.connectorStatusStore.load()));
|
|
5698
|
+
if (missingConnectors.length > 0) {
|
|
5699
|
+
return res.status(409).json({
|
|
5700
|
+
error: 'Remote Hub connector preflight failed.',
|
|
5701
|
+
code: 'missing_remote_connectors',
|
|
5702
|
+
missingConnectors,
|
|
5703
|
+
capabilitiesUrl: '/api/ai-hub/capabilities',
|
|
5704
|
+
setupHint: 'Configure the connector on this Hub machine through the agent-guided setup workflow, then update non-secret connector status and retry.',
|
|
5705
|
+
});
|
|
5706
|
+
}
|
|
5449
5707
|
const employees = this.hostRuntime.detectEmployees();
|
|
5450
5708
|
const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(req.body.configuredAgentId, requestedHostId, employees);
|
|
5451
5709
|
const prepared = instructions
|
|
@@ -6551,6 +6809,120 @@ class AiHubServer {
|
|
|
6551
6809
|
});
|
|
6552
6810
|
// ─── End Issue #945 ───────────────────────────────────────────────────────
|
|
6553
6811
|
// GET /api/ai-hub/hosts — list registered remote hosts with health status.
|
|
6812
|
+
this.app.get('/api/ai-hub/capabilities', (_req, res) => {
|
|
6813
|
+
const employees = this.hostRuntime.detectEmployees();
|
|
6814
|
+
const response = {
|
|
6815
|
+
host: {
|
|
6816
|
+
id: 'local',
|
|
6817
|
+
label: os_1.default.hostname(),
|
|
6818
|
+
url: this.hubBase,
|
|
6819
|
+
},
|
|
6820
|
+
generatedAt: new Date().toISOString(),
|
|
6821
|
+
agents: this.configuredAgentsForCurrentMachine(employees).map((agent) => (0, configured_agents_1.projectConfiguredAgent)(agent, employees)),
|
|
6822
|
+
connectors: mergeConnectorStatuses(this.connectorStatusStore.load()),
|
|
6823
|
+
};
|
|
6824
|
+
return res.json(response);
|
|
6825
|
+
});
|
|
6826
|
+
this.app.put('/api/ai-hub/connectors/:id/status', (req, res) => {
|
|
6827
|
+
if (!this.requireTrustedHubOrigin(req, res))
|
|
6828
|
+
return;
|
|
6829
|
+
const id = normalizeConnectorId(req.params.id);
|
|
6830
|
+
if (!id)
|
|
6831
|
+
return res.status(400).json({ error: 'Invalid connector id.' });
|
|
6832
|
+
if (hasSecretLikeField(req.body)) {
|
|
6833
|
+
console.warn('[ai-hub] connector status rejected secret-shaped payload', { connectorId: id });
|
|
6834
|
+
return res.status(400).json({ error: 'Connector status must not include tokens, passwords, API keys, or credentials.' });
|
|
6835
|
+
}
|
|
6836
|
+
const configured = Boolean(req.body?.configured);
|
|
6837
|
+
const authenticated = Boolean(req.body?.authenticated);
|
|
6838
|
+
const candidate = normalizeStoredConnectorStatus({
|
|
6839
|
+
id,
|
|
6840
|
+
label: typeof req.body?.label === 'string' ? req.body.label : id,
|
|
6841
|
+
configured,
|
|
6842
|
+
authenticated,
|
|
6843
|
+
transport: req.body?.transport,
|
|
6844
|
+
authMode: req.body?.authMode,
|
|
6845
|
+
detail: req.body?.detail,
|
|
6846
|
+
updatedAt: new Date().toISOString(),
|
|
6847
|
+
});
|
|
6848
|
+
if (!candidate)
|
|
6849
|
+
return res.status(400).json({ error: 'Invalid connector status.' });
|
|
6850
|
+
const saved = this.connectorStatusStore.saveStatus(candidate);
|
|
6851
|
+
console.log('[ai-hub] connector status updated', {
|
|
6852
|
+
connectorId: saved.id,
|
|
6853
|
+
readiness: saved.readiness,
|
|
6854
|
+
configured: saved.configured,
|
|
6855
|
+
authenticated: saved.authenticated,
|
|
6856
|
+
});
|
|
6857
|
+
return res.json(saved);
|
|
6858
|
+
});
|
|
6859
|
+
this.app.post('/api/ai-hub/connectors/:id/setup/start', (req, res) => {
|
|
6860
|
+
if (!this.requireTrustedHubOrigin(req, res))
|
|
6861
|
+
return;
|
|
6862
|
+
const id = normalizeConnectorId(req.params.id);
|
|
6863
|
+
if (!id)
|
|
6864
|
+
return res.status(400).json({ error: 'Invalid connector id.' });
|
|
6865
|
+
if (hasSecretLikeField(req.body)) {
|
|
6866
|
+
console.warn('[ai-hub] connector setup rejected secret-shaped payload', { connectorId: id, action: 'start' });
|
|
6867
|
+
return res.status(400).json({ error: 'Connector setup must not include tokens, passwords, API keys, or credentials.' });
|
|
6868
|
+
}
|
|
6869
|
+
const current = mergeConnectorStatuses(this.connectorStatusStore.load()).find((status) => status.id === id) || genericConnectorStatus(id);
|
|
6870
|
+
if (current.readiness === 'ready') {
|
|
6871
|
+
const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
|
|
6872
|
+
console.log('[ai-hub] connector setup status updated', {
|
|
6873
|
+
connectorId: saved.id,
|
|
6874
|
+
action: 'start',
|
|
6875
|
+
setupStatus: 'verified',
|
|
6876
|
+
readiness: saved.readiness,
|
|
6877
|
+
});
|
|
6878
|
+
return res.json(connectorSetupResponseForStatus(saved, 'verified', `${saved.label} is verified on this Hub machine.`));
|
|
6879
|
+
}
|
|
6880
|
+
const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
|
|
6881
|
+
console.log('[ai-hub] connector setup status updated', {
|
|
6882
|
+
connectorId: saved.id,
|
|
6883
|
+
action: 'start',
|
|
6884
|
+
setupStatus: 'action_required',
|
|
6885
|
+
readiness: saved.readiness,
|
|
6886
|
+
});
|
|
6887
|
+
return res.json(connectorSetupResponseForStatus(saved, 'action_required', `${saved.label} must be configured on this Hub machine before the remote run can start.`, {
|
|
6888
|
+
type: 'manual',
|
|
6889
|
+
label: `Configure ${saved.label} on the remote Hub machine`,
|
|
6890
|
+
detail: 'Use the appropriate provider or MCP setup flow for the selected remote machine. Do not assume a CLI dependency from the Hub API; after verification, record non-secret readiness with PUT /api/ai-hub/connectors/{id}/status.',
|
|
6891
|
+
}));
|
|
6892
|
+
});
|
|
6893
|
+
this.app.post('/api/ai-hub/connectors/:id/setup/check', (req, res) => {
|
|
6894
|
+
if (!this.requireTrustedHubOrigin(req, res))
|
|
6895
|
+
return;
|
|
6896
|
+
const id = normalizeConnectorId(req.params.id);
|
|
6897
|
+
if (!id)
|
|
6898
|
+
return res.status(400).json({ error: 'Invalid connector id.' });
|
|
6899
|
+
if (hasSecretLikeField(req.body)) {
|
|
6900
|
+
console.warn('[ai-hub] connector setup rejected secret-shaped payload', { connectorId: id, action: 'check' });
|
|
6901
|
+
return res.status(400).json({ error: 'Connector setup check must not include tokens, passwords, API keys, or credentials.' });
|
|
6902
|
+
}
|
|
6903
|
+
const current = mergeConnectorStatuses(this.connectorStatusStore.load()).find((status) => status.id === id) || genericConnectorStatus(id);
|
|
6904
|
+
const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
|
|
6905
|
+
if (saved.readiness === 'ready') {
|
|
6906
|
+
console.log('[ai-hub] connector setup status updated', {
|
|
6907
|
+
connectorId: saved.id,
|
|
6908
|
+
action: 'check',
|
|
6909
|
+
setupStatus: 'verified',
|
|
6910
|
+
readiness: saved.readiness,
|
|
6911
|
+
});
|
|
6912
|
+
return res.json(connectorSetupResponseForStatus(saved, 'verified', `${saved.label} is verified on this Hub machine.`));
|
|
6913
|
+
}
|
|
6914
|
+
console.log('[ai-hub] connector setup status updated', {
|
|
6915
|
+
connectorId: saved.id,
|
|
6916
|
+
action: 'check',
|
|
6917
|
+
setupStatus: 'action_required',
|
|
6918
|
+
readiness: saved.readiness,
|
|
6919
|
+
});
|
|
6920
|
+
return res.json(connectorSetupResponseForStatus(saved, 'action_required', `${saved.label} is not ready on this Hub machine.`, {
|
|
6921
|
+
type: 'manual',
|
|
6922
|
+
label: `Verify ${saved.label} on the remote Hub machine`,
|
|
6923
|
+
detail: 'The agent-guided setup must verify the provider on the remote machine and then PUT a non-secret ready status. The Hub does not infer provider-specific auth state.',
|
|
6924
|
+
}));
|
|
6925
|
+
});
|
|
6554
6926
|
this.app.get('/api/ai-hub/hosts', async (_req, res) => {
|
|
6555
6927
|
const hosts = this.hostConfigStore.load();
|
|
6556
6928
|
const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
|
|
@@ -14,6 +14,7 @@ exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
|
|
|
14
14
|
const fs_1 = __importDefault(require("fs"));
|
|
15
15
|
const os_1 = __importDefault(require("os"));
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
|
|
17
18
|
const LAUNCHER_VERSION = 1;
|
|
18
19
|
const PACKAGED_RUNTIME_VERSION = 1;
|
|
19
20
|
const launcherSource = `#!/usr/bin/env node
|
|
@@ -194,9 +195,9 @@ function packagedFraimShimSource(command, runtime) {
|
|
|
194
195
|
const windowsFraimMcpShimSource = `@echo off
|
|
195
196
|
setlocal
|
|
196
197
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
|
|
198
|
+
${(0, windows_shim_path_search_1.windowsPathSearchLines)('NODE_EXE', 'node').join('\n')}
|
|
199
|
+
if defined NODE_EXE (
|
|
200
|
+
"%NODE_EXE%" "%FRAIM_USER_DIR_VALUE%\\bin\\fraim-mcp-latest.js" %*
|
|
200
201
|
exit /b %ERRORLEVEL%
|
|
201
202
|
)
|
|
202
203
|
if exist "%FRAIM_USER_DIR_VALUE%\\node\\node.exe" (
|
|
@@ -217,9 +218,9 @@ exit /b 1
|
|
|
217
218
|
const windowsFraimNpxShimSource = `@echo off
|
|
218
219
|
setlocal
|
|
219
220
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
220
|
-
|
|
221
|
-
if
|
|
222
|
-
|
|
221
|
+
${(0, windows_shim_path_search_1.windowsPathSearchLines)('NPX_CMD', 'npx').join('\n')}
|
|
222
|
+
if defined NPX_CMD (
|
|
223
|
+
"%NPX_CMD%" %*
|
|
223
224
|
exit /b %ERRORLEVEL%
|
|
224
225
|
)
|
|
225
226
|
if exist "%FRAIM_USER_DIR_VALUE%\\node\\npx.cmd" (
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared Windows batch-script primitive for resolving an executable on `PATH` without
|
|
4
|
+
* depending on `where.exe`.
|
|
5
|
+
*
|
|
6
|
+
* Issue #1375: every generated `.cmd` launcher in this repo used to probe for `node`/`npx`
|
|
7
|
+
* with `where <name> >nul 2>nul`. `where.exe` itself lives in `C:\Windows\System32`, so on a
|
|
8
|
+
* launching process whose `PATH` omits `System32` — the normal case for a shell started from
|
|
9
|
+
* the VS Code integrated Git Bash terminal — the probe is unresolvable and falsely reports the
|
|
10
|
+
* target missing even when it is on `PATH`. cmd.exe's own command dispatch resolves external
|
|
11
|
+
* commands strictly through `PATH` (not the OS's default `CreateProcess` search order, which
|
|
12
|
+
* would fall back to the system directory automatically), so this is a real, reproducible gap,
|
|
13
|
+
* not a theoretical one.
|
|
14
|
+
*
|
|
15
|
+
* `for %%X in (<name>) do set "VAR=%%~$PATH:X"` is cmd.exe's native argument-expansion PATH
|
|
16
|
+
* search. It needs no external binary — the direct analogue of a POSIX shell's `command -v`
|
|
17
|
+
* builtin — and resolves to an absolute path the caller can invoke directly. Unlike `where`, it
|
|
18
|
+
* needs an exact filename (no `%PATHEXT%` extension search of its own), so this module tries
|
|
19
|
+
* `.exe` then `.cmd` in turn to match `where`'s original extension-agnostic behavior.
|
|
20
|
+
*
|
|
21
|
+
* Every `.cmd` generator in this repo that needs to resolve node/npx/git on Windows should call
|
|
22
|
+
* this instead of hand-rolling its own `where` probe: three independent reimplementations of this
|
|
23
|
+
* exact check (`src/cli/mcp/fraim-mcp-latest-launcher.ts`, `src/ai-hub/hub-launcher.ts`, and
|
|
24
|
+
* `scripts/installer/fraim-install-win.template.cmd`) is exactly how the `where`-based bug shipped
|
|
25
|
+
* on Windows only while the POSIX shim's single `command -v` call stayed correct.
|
|
26
|
+
*
|
|
27
|
+
* `scripts/installer/fraim-install-win.template.cmd` is a static text file, not generated from
|
|
28
|
+
* this module (it is read verbatim and two tokens substituted, not built from TypeScript), so it
|
|
29
|
+
* cannot import this function. It re-implements the identical pattern as literal batch text —
|
|
30
|
+
* keep both in sync when this logic changes.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.windowsPathSearchLines = windowsPathSearchLines;
|
|
34
|
+
/**
|
|
35
|
+
* Extensions tried, in order, when resolving a bare command name — mirrors the two extensions
|
|
36
|
+
* every tool this repo probes for actually ships as: `node.exe`/`git.exe`/`winget.exe` are native
|
|
37
|
+
* PE binaries, `npx.cmd` is npm's batch shim. `%%~$PATH:X` needs an exact filename (unlike `where`,
|
|
38
|
+
* it does not consult `%PATHEXT%` itself), so trying each extension in turn is what restores
|
|
39
|
+
* `where`'s original extension-agnostic behavior.
|
|
40
|
+
*/
|
|
41
|
+
const WINDOWS_EXECUTABLE_EXTENSIONS = ['.exe', '.cmd'];
|
|
42
|
+
/**
|
|
43
|
+
* Returns the batch-script lines that resolve `targetBaseName` (no extension) on `PATH` into
|
|
44
|
+
* `varName`, trying each of `WINDOWS_EXECUTABLE_EXTENSIONS` in turn, or leave `varName` undefined
|
|
45
|
+
* if none match. Caller decides line joining (`\n` vs `\r\n`) and what to do with the result —
|
|
46
|
+
* typically `if defined <varName> ( "%<varName>%" ... )`.
|
|
47
|
+
*
|
|
48
|
+
* @param varName Batch variable to receive the resolved absolute path, e.g. `NODE_EXE`.
|
|
49
|
+
* @param targetBaseName Command name without extension, e.g. `node`, `npx`, `git`, `winget`.
|
|
50
|
+
*/
|
|
51
|
+
function windowsPathSearchLines(varName, targetBaseName) {
|
|
52
|
+
return [
|
|
53
|
+
`set "${varName}="`,
|
|
54
|
+
...WINDOWS_EXECUTABLE_EXTENSIONS.map((ext) => `if not defined ${varName} for %%X in (${targetBaseName}${ext}) do set "${varName}=%%~$PATH:X"`),
|
|
55
|
+
];
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.284",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"dist/src/core/utils/ports.js",
|
|
76
76
|
"dist/src/core/utils/project-fraim-paths.js",
|
|
77
77
|
"dist/src/core/utils/setup-preferences.js",
|
|
78
|
+
"dist/src/core/utils/windows-shim-path-search.js",
|
|
78
79
|
"dist/src/db/payment-repository.js",
|
|
79
80
|
"dist/src/fraim/db-service.js",
|
|
80
81
|
"dist/src/first-run/",
|
|
@@ -209,7 +210,7 @@
|
|
|
209
210
|
"electron-updater": "^6.8.9",
|
|
210
211
|
"express": "^5.2.1",
|
|
211
212
|
"extract-zip": "^2.0.1",
|
|
212
|
-
"fraim": "2.0.
|
|
213
|
+
"fraim": "2.0.284",
|
|
213
214
|
"mongodb": "^7.0.0",
|
|
214
215
|
"node-cron": "4.2.1",
|
|
215
216
|
"node-edge-tts": "^1.2.10",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
|
|
122
122
|
function emptyContext() {
|
|
123
123
|
var meta = docMeta();
|
|
124
|
-
return { docUrl: meta.docUrl, docTitle: meta.docTitle, selection: '', hasSelection: true, bodyPreview: '', wordCount: 0, comments: [] };
|
|
124
|
+
return { docUrl: meta.docUrl, docTitle: meta.docTitle, selection: '', hasSelection: true, bodyPreview: '', wordCount: 0, comments: [], hostApp: 'Excel' };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
function readFullContext(cb) {
|
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
bodyPreview: body.slice(0, 800),
|
|
151
151
|
wordCount: body ? body.split(/\s+/).filter(Boolean).length : 0,
|
|
152
152
|
comments: commentList,
|
|
153
|
+
hostApp: 'Excel',
|
|
153
154
|
});
|
|
154
155
|
});
|
|
155
156
|
}).catch(function() { cb(emptyContext()); });
|
|
@@ -198,20 +199,42 @@
|
|
|
198
199
|
|
|
199
200
|
// ── Write-back (R4) ─────────────────────────────────────────────────────────
|
|
200
201
|
// Never overwrites the cell that was selected when the job started: if that
|
|
201
|
-
// selection was meaningful (anything but the sheet's bare A1 default)
|
|
202
|
-
// write into the cell immediately adjacent
|
|
203
|
-
//
|
|
204
|
-
// a
|
|
205
|
-
//
|
|
202
|
+
// selection was meaningful (anything but the sheet's bare A1 default) AND the
|
|
203
|
+
// output is plain text (not a table), write into the cell immediately adjacent
|
|
204
|
+
// to it (same row, next column). For markdown-table output, or when only a
|
|
205
|
+
// default A1 was selected, append a new block at the end of the used range.
|
|
206
|
+
// Either way, add a cell comment on the first written cell for audit/undo-by-
|
|
207
|
+
// review (R4) and rely on Excel's native Ctrl+Z for the reject path.
|
|
208
|
+
|
|
209
|
+
// Parse a markdown table into a 2D string array. Returns null when the text
|
|
210
|
+
// contains no recognisable table. Separator rows (| --- |) are skipped.
|
|
211
|
+
function parseMarkdownTable(text) {
|
|
212
|
+
var rows = [];
|
|
213
|
+
var lines = String(text || '').split('\n');
|
|
214
|
+
for (var i = 0; i < lines.length; i++) {
|
|
215
|
+
var line = lines[i].trim();
|
|
216
|
+
if (!line.startsWith('|')) continue;
|
|
217
|
+
var cells = line.replace(/^\||\|$/g, '').split('|');
|
|
218
|
+
var isSep = cells.every(function(c) { return /^\s*:?-+:?\s*$/.test(c); });
|
|
219
|
+
if (isSep) continue;
|
|
220
|
+
rows.push(cells.map(function(c) { return c.trim(); }));
|
|
221
|
+
}
|
|
222
|
+
return rows.length >= 1 ? rows : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
function writeBackNonDestructive(text, jobLabel, cb) {
|
|
207
226
|
if (typeof Excel === 'undefined' || !Excel.run) { cb(false, 'Excel API 1.10+ not available'); return; }
|
|
208
227
|
var info = lastSelectionInfo;
|
|
209
228
|
var meaningful = !!(info && !isDefaultA1(info));
|
|
210
229
|
var note = 'Written by FRAIM' + (jobLabel ? ' (' + jobLabel + ')' : '') + ' - ' + new Date().toISOString();
|
|
230
|
+
var tableData = parseMarkdownTable(text);
|
|
211
231
|
try {
|
|
212
232
|
Excel.run(function(ctx) {
|
|
213
233
|
var sheet = ctx.workbook.worksheets.getActiveWorksheet();
|
|
214
|
-
|
|
234
|
+
// Plain-text adjacent write only when the selection was meaningful AND
|
|
235
|
+
// the output is not a multi-row table (tables always land at end of range
|
|
236
|
+
// because they span rows and would clobber data if placed mid-sheet).
|
|
237
|
+
if (meaningful && !tableData) {
|
|
215
238
|
var addr = String(info.address).split('!').pop();
|
|
216
239
|
var selRange = sheet.getRange(addr);
|
|
217
240
|
var target = selRange.getOffsetRange(0, 1);
|
|
@@ -222,10 +245,23 @@
|
|
|
222
245
|
var used = sheet.getUsedRangeOrNullObject();
|
|
223
246
|
used.load('rowCount,rowIndex,isNullObject');
|
|
224
247
|
return ctx.sync().then(function() {
|
|
225
|
-
var nextRow = used.isNullObject ?
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
248
|
+
var nextRow = used.isNullObject ? 1 : (used.rowIndex + used.rowCount);
|
|
249
|
+
if (tableData) {
|
|
250
|
+
var numRows = tableData.length;
|
|
251
|
+
var numCols = tableData.reduce(function(mx, r) { return Math.max(mx, r.length); }, 0);
|
|
252
|
+
var padded = tableData.map(function(row) {
|
|
253
|
+
var r = row.slice(0, numCols);
|
|
254
|
+
while (r.length < numCols) r.push('');
|
|
255
|
+
return r;
|
|
256
|
+
});
|
|
257
|
+
var tableRange = sheet.getRangeByIndexes(nextRow, 0, numRows, numCols);
|
|
258
|
+
tableRange.values = padded;
|
|
259
|
+
sheet.comments.add(sheet.getRangeByIndexes(nextRow, 0, 1, 1), note);
|
|
260
|
+
} else {
|
|
261
|
+
var target = sheet.getRangeByIndexes(nextRow, 0, 1, 1);
|
|
262
|
+
target.values = [[text]];
|
|
263
|
+
sheet.comments.add(target, note);
|
|
264
|
+
}
|
|
229
265
|
return ctx.sync();
|
|
230
266
|
});
|
|
231
267
|
}).then(function() { cb(true); }).catch(function(e) { cb(false, (e && e.message) || String(e)); });
|
package/public/ai-hub/index.html
CHANGED
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
<div class="nav-right">
|
|
51
51
|
<!-- #744: FRAIM co-mark (co-branding, not white-label) shown when a brand is set. -->
|
|
52
52
|
<span class="hub-cobrand" id="hub-cobrand" hidden></span>
|
|
53
|
+
<!-- #1379: persistent top-bar update indicator, driven by tfPopulateVersionInfo() alongside
|
|
54
|
+
the #am-update account-menu nudge below. Hidden until an update is actually available;
|
|
55
|
+
the `title` attribute (set in JS) carries the version + action-to-take hover text. -->
|
|
56
|
+
<button class="hub-update-badge" id="hub-update-badge" type="button" hidden>⬆️ Updates available</button>
|
|
53
57
|
<button class="avatar-btn" id="avatar-btn" type="button" title="Account & settings">SM</button>
|
|
54
58
|
<div id="account-menu" class="account-menu">
|
|
55
59
|
<div class="am-header">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -576,6 +576,13 @@ function buildWordContextBlock(wc) {
|
|
|
576
576
|
const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
|
|
577
577
|
parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
|
|
578
578
|
}
|
|
579
|
+
if (wc.hostApp === 'Excel') {
|
|
580
|
+
parts.push(
|
|
581
|
+
'Host: Excel add-in. Your final text response is written automatically into this workbook.\n' +
|
|
582
|
+
'For structured output (tables, schedules, amortization calculators, grids): format your response as a Markdown table (| Header | Header | / | value | value |) so each cell is written to the correct row and column in the sheet.\n' +
|
|
583
|
+
'Do NOT use Python, openpyxl, or any file-creation tool to produce a standalone .xlsx file — all output goes directly into the currently open workbook.'
|
|
584
|
+
);
|
|
585
|
+
}
|
|
579
586
|
if (parts.length === 0) return '';
|
|
580
587
|
return `[Word Document Context]\n${parts.join('\n\n')}`;
|
|
581
588
|
}
|
|
@@ -15861,6 +15868,11 @@ function tfWireShell() {
|
|
|
15861
15868
|
}
|
|
15862
15869
|
const avatar = document.getElementById('avatar-btn');
|
|
15863
15870
|
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
15871
|
+
// #1379: clicking the top-bar badge opens the same account menu that already carries
|
|
15872
|
+
// the full update-available detail, rather than duplicating that content into a
|
|
15873
|
+
// second popover.
|
|
15874
|
+
const updateBadge = document.getElementById('hub-update-badge');
|
|
15875
|
+
if (updateBadge) updateBadge.addEventListener('click', tfToggleAccountMenu);
|
|
15864
15876
|
tfWireThemeToggle();
|
|
15865
15877
|
const accountItem = document.getElementById('am-account');
|
|
15866
15878
|
if (accountItem) accountItem.addEventListener('click', (e) => {
|
|
@@ -16080,24 +16092,36 @@ function tfPopulateAccountMenu() {
|
|
|
16080
16092
|
// #755: show the running Hub build version and, when a newer version is published,
|
|
16081
16093
|
// an update-available nudge. Running version is cheap (from bootstrap); the latest
|
|
16082
16094
|
// check hits the server's cached /api/ai-hub/version (best-effort, hidden on failure).
|
|
16095
|
+
// #1379: the account-menu nudge above is only visible once the manager opens that menu,
|
|
16096
|
+
// so a persistent top-bar indicator (#hub-update-badge, in .nav-right before #avatar-btn)
|
|
16097
|
+
// surfaces the same info without requiring the click. Both surfaces are driven from the
|
|
16098
|
+
// same response and use the same copy; #am-update is kept as-is (not removed) per #1379's
|
|
16099
|
+
// own "implementer's call, as long as the information isn't lost."
|
|
16083
16100
|
function tfPopulateVersionInfo() {
|
|
16084
16101
|
const versionEl = document.getElementById('am-version');
|
|
16085
16102
|
const running = (state.bootstrap && state.bootstrap.version) || '';
|
|
16086
16103
|
if (versionEl) versionEl.textContent = running ? `FRAIM Hub v${running}` : '';
|
|
16087
16104
|
const updateEl = document.getElementById('am-update');
|
|
16088
16105
|
const updateSub = document.getElementById('am-update-sub');
|
|
16089
|
-
|
|
16106
|
+
const badgeEl = document.getElementById('hub-update-badge');
|
|
16107
|
+
if (!updateEl && !badgeEl) return;
|
|
16090
16108
|
fetch('/api/ai-hub/version')
|
|
16091
16109
|
.then((r) => (r.ok ? r.json() : null))
|
|
16092
16110
|
.then((info) => {
|
|
16093
16111
|
if (info && info.updateAvailable && info.latest) {
|
|
16094
|
-
|
|
16095
|
-
|
|
16112
|
+
const actionText = `v${info.latest} is available. Quit the Hub (tray → Quit) and relaunch to update.`;
|
|
16113
|
+
if (updateSub) updateSub.textContent = actionText;
|
|
16114
|
+
if (updateEl) updateEl.hidden = false;
|
|
16115
|
+
if (badgeEl) {
|
|
16116
|
+
badgeEl.title = actionText;
|
|
16117
|
+
badgeEl.hidden = false;
|
|
16118
|
+
}
|
|
16096
16119
|
} else {
|
|
16097
|
-
updateEl.hidden = true;
|
|
16120
|
+
if (updateEl) updateEl.hidden = true;
|
|
16121
|
+
if (badgeEl) badgeEl.hidden = true;
|
|
16098
16122
|
}
|
|
16099
16123
|
})
|
|
16100
|
-
.catch(() => { /* offline / best-effort: leave
|
|
16124
|
+
.catch(() => { /* offline / best-effort: leave both surfaces hidden */ });
|
|
16101
16125
|
}
|
|
16102
16126
|
|
|
16103
16127
|
function tfRequestedConnectedSurface() {
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -4419,6 +4419,22 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
4419
4419
|
}
|
|
4420
4420
|
.avatar-btn:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
|
|
4421
4421
|
|
|
4422
|
+
/* Issue #1379: persistent top-bar update indicator, sits in .nav-right just before
|
|
4423
|
+
.avatar-btn. Pill sizing/radius matches the established .run-state-pill convention;
|
|
4424
|
+
accent-colored (not warn) since a new version is a nudge, not a problem. */
|
|
4425
|
+
.hub-update-badge {
|
|
4426
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
4427
|
+
padding: 7px 12px; border-radius: 999px;
|
|
4428
|
+
font-size: 11px; font-weight: 700; letter-spacing: .02em; white-space: nowrap;
|
|
4429
|
+
background: var(--accent); color: #fff; border: none;
|
|
4430
|
+
cursor: pointer; flex-shrink: 0; margin-left: 8px;
|
|
4431
|
+
box-shadow: 0 1px 4px rgba(0,0,0,.18);
|
|
4432
|
+
transition: opacity .12s, box-shadow .12s;
|
|
4433
|
+
}
|
|
4434
|
+
.hub-update-badge:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
|
|
4435
|
+
.hub-update-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4436
|
+
.hub-update-badge[hidden] { display: none; }
|
|
4437
|
+
|
|
4422
4438
|
/* Suppress the old Hub's header and rail when inside the workspace-conv */
|
|
4423
4439
|
.workspace-conv .header { display: none !important; }
|
|
4424
4440
|
.workspace-conv .rail { display: none !important; }
|
|
@@ -5400,6 +5416,12 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
5400
5416
|
.hub-brand { padding: 0 2px 0 10px; }
|
|
5401
5417
|
.hub-brand-divider { margin: 0 4px 0 8px; }
|
|
5402
5418
|
.hub-tab { padding: 10px 12px; }
|
|
5419
|
+
/* #1379: the badge's own text ("Updates available") is exactly what forced the same
|
|
5420
|
+
horizontal-scroll problem this block already exists to prevent - measured at 375px:
|
|
5421
|
+
95px of overflow with the badge shown, none without it. Same treatment as the
|
|
5422
|
+
co-mark above: the update is still reachable via #avatar-btn -> #am-update at this
|
|
5423
|
+
width, just not duplicated in the top bar where there's no room for it. */
|
|
5424
|
+
.hub-update-badge { display: none; }
|
|
5403
5425
|
}
|
|
5404
5426
|
|
|
5405
5427
|
/* Brand editor (Company tab → Brand accordion). */
|