mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 10:02:57 +08:00
Compare commits
10 Commits
cloudcli-l
...
feat/add-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd7044258 | ||
|
|
655501faba | ||
|
|
7c6d00ee93 | ||
|
|
84fadad662 | ||
|
|
5c14e08493 | ||
|
|
f188648a2a | ||
|
|
cdf1a04e26 | ||
|
|
048c671b13 | ||
|
|
2ebe64f218 | ||
|
|
b6cf33308d |
@@ -28,6 +28,9 @@ HOST=0.0.0.0
|
|||||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||||
# CLAUDE_CLI_PATH=claude
|
# CLAUDE_CLI_PATH=claude
|
||||||
|
|
||||||
|
# Uncomment the following line if you want a custom Hermes ACP launcher
|
||||||
|
# HERMES_CLI_PATH=hermes acp
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE CONFIGURATION
|
# DATABASE CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -42,4 +45,3 @@ HOST=0.0.0.0
|
|||||||
VITE_CONTEXT_WINDOW=160000
|
VITE_CONTEXT_WINDOW=160000
|
||||||
CONTEXT_WINDOW=160000
|
CONTEXT_WINDOW=160000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
|
|||||||
@@ -524,7 +524,7 @@
|
|||||||
<td><code>provider</code></td>
|
<td><code>provider</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td><span class="badge badge-optional">Optional</span></td>
|
<td><span class="badge badge-optional">Optional</span></td>
|
||||||
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
|
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, <code>opencode</code>, or <code>hermes</code> (default: <code>claude</code>)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>stream</code></td>
|
<td><code>stream</code></td>
|
||||||
@@ -834,6 +834,7 @@ data: {"type":"done"}</code></pre>
|
|||||||
{ id: 'gemini', name: 'Google' },
|
{ id: 'gemini', name: 'Google' },
|
||||||
{ id: 'cursor', name: 'Cursor' },
|
{ id: 'cursor', name: 'Cursor' },
|
||||||
{ id: 'opencode', name: 'OpenCode' },
|
{ id: 'opencode', name: 'OpenCode' },
|
||||||
|
{ id: 'hermes', name: 'Nous Research' },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function populateModels() {
|
async function populateModels() {
|
||||||
|
|||||||
BIN
public/icons/hermes-agent.png
Normal file
BIN
public/icons/hermes-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -29,9 +29,14 @@ import {
|
|||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
import {
|
||||||
|
getPendingApprovalsForSession,
|
||||||
|
registerApproval,
|
||||||
|
resolveToolApproval,
|
||||||
|
unregisterApproval,
|
||||||
|
} from './shared/tool-approval-registry.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = new Map();
|
|
||||||
// Sessions cancelled via abort-session. The abort handler already sent the
|
// Sessions cancelled via abort-session. The abort handler already sent the
|
||||||
// terminal `complete` (aborted: true) to the client, so the run loop must not
|
// terminal `complete` (aborted: true) to the client, so the run loop must not
|
||||||
// emit a second one when its generator winds down.
|
// emit a second one when its generator winds down.
|
||||||
@@ -64,7 +69,7 @@ function waitForToolApproval(requestId, options = {}) {
|
|||||||
let timeout;
|
let timeout;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
pendingToolApprovals.delete(requestId);
|
unregisterApproval(requestId);
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (signal && abortHandler) {
|
if (signal && abortHandler) {
|
||||||
signal.removeEventListener('abort', abortHandler);
|
signal.removeEventListener('abort', abortHandler);
|
||||||
@@ -96,21 +101,15 @@ function waitForToolApproval(requestId, options = {}) {
|
|||||||
const resolver = (decision) => {
|
const resolver = (decision) => {
|
||||||
finalize(decision);
|
finalize(decision);
|
||||||
};
|
};
|
||||||
// Attach metadata for getPendingApprovalsForSession lookup
|
registerApproval(requestId, {
|
||||||
if (metadata) {
|
resolver,
|
||||||
Object.assign(resolver, metadata);
|
sessionId: metadata?._sessionId ?? null,
|
||||||
}
|
provider: 'claude',
|
||||||
pendingToolApprovals.set(requestId, resolver);
|
meta: metadata ?? {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveToolApproval(requestId, decision) {
|
|
||||||
const resolver = pendingToolApprovals.get(requestId);
|
|
||||||
if (resolver) {
|
|
||||||
resolver(decision);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match stored permission entries against a tool + input combo.
|
// Match stored permission entries against a tool + input combo.
|
||||||
// This only supports exact tool names and the Bash(command:*) shorthand
|
// This only supports exact tool names and the Bash(command:*) shorthand
|
||||||
// used by the UI; it intentionally does not implement full glob semantics,
|
// used by the UI; it intentionally does not implement full glob semantics,
|
||||||
@@ -846,28 +845,6 @@ function getActiveClaudeSDKSessions() {
|
|||||||
return getAllSessions();
|
return getAllSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get pending tool approvals for a specific session.
|
|
||||||
* @param {string} sessionId - The session ID
|
|
||||||
* @returns {Array} Array of pending permission request objects
|
|
||||||
*/
|
|
||||||
function getPendingApprovalsForSession(sessionId) {
|
|
||||||
const pending = [];
|
|
||||||
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
|
||||||
if (resolver._sessionId === sessionId) {
|
|
||||||
pending.push({
|
|
||||||
requestId,
|
|
||||||
toolName: resolver._toolName || 'UnknownTool',
|
|
||||||
input: resolver._input,
|
|
||||||
context: resolver._context,
|
|
||||||
sessionId,
|
|
||||||
receivedAt: resolver._receivedAt || new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
||||||
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
||||||
|
|||||||
407
server/hermes-cli.js
Normal file
407
server/hermes-cli.js
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
import {
|
||||||
|
clearApprovalsForSession,
|
||||||
|
getPendingApprovalsForSession,
|
||||||
|
registerApproval,
|
||||||
|
resolveToolApproval,
|
||||||
|
unregisterApproval,
|
||||||
|
} from './shared/tool-approval-registry.js';
|
||||||
|
import { hermesConnectionManager } from './hermes/acp-client.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'hermes';
|
||||||
|
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||||
|
const activeHermesSessions = new Map();
|
||||||
|
// Session ids whose run was aborted; the terminal `complete` is emitted by
|
||||||
|
// handleChatAbort, so the runtime must not also emit a "completed" one.
|
||||||
|
const abortedSessionIds = new Set();
|
||||||
|
|
||||||
|
function createRequestId() {
|
||||||
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionId(result) {
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result.sessionId
|
||||||
|
|| result.session_id
|
||||||
|
|| result.id
|
||||||
|
|| result.session?.id
|
||||||
|
|| result.session?.sessionId
|
||||||
|
|| result.session?.session_id
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStopReason(result) {
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result.stopReason || result.stop_reason || result.reason || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptParams(sessionId, command) {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
prompt: [{ type: 'text', text: command }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionSetupParams(sessionId, workingDir) {
|
||||||
|
return {
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
cwd: workingDir,
|
||||||
|
mcpServers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLoadSession(connection) {
|
||||||
|
return connection?.initializeResult?.agentCapabilities?.loadSession === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
|
||||||
|
if (!Array.isArray(options)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const match = options.find((option) => option?.kind === kind);
|
||||||
|
if (match?.optionId) {
|
||||||
|
return match.optionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const optionId of fallbackOptionIds) {
|
||||||
|
const match = options.find((option) => option?.optionId === optionId);
|
||||||
|
if (match?.optionId) {
|
||||||
|
return match.optionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPermissionDecision(decision, options = []) {
|
||||||
|
if (!decision) {
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.cancelled) {
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.allow) {
|
||||||
|
const optionId = decision.rememberEntry
|
||||||
|
? findPermissionOption(options, ['allow_always', 'allow_session'], ['allow_always', 'allow_session'])
|
||||||
|
: findPermissionOption(options, ['allow_once'], ['allow_once']);
|
||||||
|
|
||||||
|
if (!optionId) {
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const denyOptionId = findPermissionOption(options, ['reject_once', 'deny', 'reject_always'], ['deny', 'reject_once', 'reject_always']);
|
||||||
|
if (denyOptionId) {
|
||||||
|
return {
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: denyOptionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: { outcome: 'cancelled' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPermission(ws, params, capturedSessionId, sessionSummary) {
|
||||||
|
const requestId = createRequestId();
|
||||||
|
const toolCall = params?.toolCall || params?.tool_call || {};
|
||||||
|
const toolName = params?.toolName
|
||||||
|
|| params?.tool_name
|
||||||
|
|| params?.name
|
||||||
|
|| params?.tool?.name
|
||||||
|
|| toolCall.title
|
||||||
|
|| 'HermesTool';
|
||||||
|
const input = params?.input
|
||||||
|
?? params?.arguments
|
||||||
|
?? params?.toolInput
|
||||||
|
?? params?.tool_input
|
||||||
|
?? toolCall.rawInput
|
||||||
|
?? toolCall.raw_input
|
||||||
|
?? toolCall;
|
||||||
|
|
||||||
|
ws.send(createNormalizedMessage({
|
||||||
|
kind: 'permission_request',
|
||||||
|
requestId,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
provider: PROVIDER,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
registerApproval(requestId, {
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
provider: PROVIDER,
|
||||||
|
meta: {
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
context: params,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
receivedAt: new Date(),
|
||||||
|
},
|
||||||
|
resolver: (decision) => {
|
||||||
|
unregisterApproval(requestId);
|
||||||
|
resolve(createPermissionDecision(decision, params?.options));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnHermes(command, options = {}, ws) {
|
||||||
|
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||||
|
const workingDir = cwd || projectPath || process.cwd();
|
||||||
|
const requestedModel = model === HERMES_CONFIGURED_MODEL ? undefined : model;
|
||||||
|
let capturedSessionId = sessionId || null;
|
||||||
|
let sessionCreatedSent = false;
|
||||||
|
let completeSent = false;
|
||||||
|
let activeKey = capturedSessionId || `pending-${createRequestId()}`;
|
||||||
|
|
||||||
|
const notifyTerminalState = ({ error = null, stopReason = 'completed' } = {}) => {
|
||||||
|
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||||
|
if (!error) {
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: PROVIDER,
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
stopReason,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyRunFailed({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: PROVIDER,
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerSession = (nextSessionId, connection) => {
|
||||||
|
if (!nextSessionId || capturedSessionId === nextSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeHermesSessions.has(activeKey)) {
|
||||||
|
activeHermesSessions.delete(activeKey);
|
||||||
|
}
|
||||||
|
activeKey = nextSessionId;
|
||||||
|
capturedSessionId = nextSessionId;
|
||||||
|
activeHermesSessions.set(activeKey, {
|
||||||
|
connection,
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
status: 'active',
|
||||||
|
aborted: false,
|
||||||
|
ws,
|
||||||
|
sessionSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
|
ws.setSessionId(capturedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
|
sessionCreatedSent = true;
|
||||||
|
ws.send(createNormalizedMessage({
|
||||||
|
kind: 'session_created',
|
||||||
|
newSessionId: capturedSessionId,
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
provider: PROVIDER,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
|
||||||
|
const connection = await hermesConnectionManager.getConnection(workingDir);
|
||||||
|
activeHermesSessions.set(activeKey, {
|
||||||
|
connection,
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
status: 'active',
|
||||||
|
aborted: false,
|
||||||
|
ws,
|
||||||
|
sessionSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unregisterPermissionHandler = connection.registerRequestHandler('session/request_permission', (params) => {
|
||||||
|
const permissionSessionId = params?.sessionId || params?.session_id || null;
|
||||||
|
const active = permissionSessionId
|
||||||
|
? activeHermesSessions.get(permissionSessionId)
|
||||||
|
: activeHermesSessions.get(activeKey);
|
||||||
|
if (!active) {
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForPermission(
|
||||||
|
active.ws,
|
||||||
|
params,
|
||||||
|
active.sessionId || permissionSessionId || capturedSessionId,
|
||||||
|
active.sessionSummary || sessionSummary,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateHandler = (params) => {
|
||||||
|
const updateSessionId = params?.sessionId || params?.session_id || null;
|
||||||
|
if (capturedSessionId && updateSessionId && updateSessionId !== capturedSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSession(updateSessionId, connection);
|
||||||
|
const normalized = sessionsService.normalizeMessage(PROVIDER, params, capturedSessionId || updateSessionId || null);
|
||||||
|
for (const msg of normalized) {
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.on('session/update', updateHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sessionResult;
|
||||||
|
if (sessionId && canLoadSession(connection)) {
|
||||||
|
try {
|
||||||
|
sessionResult = await connection.request('session/load', buildSessionSetupParams(sessionId, workingDir));
|
||||||
|
} catch {
|
||||||
|
sessionResult = { sessionId };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionResult = await connection.request('session/new', buildSessionSetupParams(null, workingDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSession(readSessionId(sessionResult) || sessionId, connection);
|
||||||
|
if (!capturedSessionId) {
|
||||||
|
throw new Error('Hermes ACP did not return a session id.');
|
||||||
|
}
|
||||||
|
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command));
|
||||||
|
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
|
||||||
|
const stopReason = readStopReason(promptResult) || 'completed';
|
||||||
|
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);
|
||||||
|
|
||||||
|
if (promptResult?.usage || promptResult?.tokenUsage || promptResult?.token_usage) {
|
||||||
|
ws.send(createNormalizedMessage({
|
||||||
|
kind: 'status',
|
||||||
|
text: 'token_budget',
|
||||||
|
tokenBudget: promptResult.usage || promptResult.tokenUsage || promptResult.token_usage,
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
provider: PROVIDER,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||||
|
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||||
|
const wasAborted = Boolean(active?.aborted || abortedById || abortedByKey);
|
||||||
|
|
||||||
|
if (!completeSent && !wasAborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 0 }));
|
||||||
|
}
|
||||||
|
activeHermesSessions.delete(finalSessionId);
|
||||||
|
activeHermesSessions.delete(activeKey);
|
||||||
|
clearApprovalsForSession(finalSessionId);
|
||||||
|
notifyTerminalState({ stopReason: wasAborted ? 'aborted' : stopReason });
|
||||||
|
} finally {
|
||||||
|
connection.off('session/update', updateHandler);
|
||||||
|
unregisterPermissionHandler();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||||
|
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||||
|
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||||
|
activeHermesSessions.delete(finalSessionId);
|
||||||
|
activeHermesSessions.delete(activeKey);
|
||||||
|
clearApprovalsForSession(finalSessionId);
|
||||||
|
|
||||||
|
// A cancelled session/prompt rejects here; its aborted terminal `complete`
|
||||||
|
// is sent by handleChatAbort, so don't surface the cancellation as an error.
|
||||||
|
if (abortedById || abortedByKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = await providerAuthService.isProviderInstalled(PROVIDER);
|
||||||
|
const errorContent = !installed
|
||||||
|
? 'Hermes ACP is not installed. Install Hermes and ensure hermes-acp is on PATH.'
|
||||||
|
: error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
ws.send(createNormalizedMessage({
|
||||||
|
kind: 'error',
|
||||||
|
content: errorContent,
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
provider: PROVIDER,
|
||||||
|
}));
|
||||||
|
if (!completeSent) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 1 }));
|
||||||
|
}
|
||||||
|
notifyTerminalState({ error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortHermesSession(providerSessionId) {
|
||||||
|
const active = activeHermesSessions.get(providerSessionId);
|
||||||
|
if (!active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
active.aborted = true;
|
||||||
|
active.status = 'aborted';
|
||||||
|
abortedSessionIds.add(providerSessionId);
|
||||||
|
if (active.sessionId) {
|
||||||
|
abortedSessionIds.add(active.sessionId);
|
||||||
|
}
|
||||||
|
for (const approval of getPendingApprovalsForSession(active.sessionId || providerSessionId)) {
|
||||||
|
resolveToolApproval(approval.requestId, { cancelled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
active.connection.notify('session/cancel', { sessionId: active.sessionId || providerSessionId });
|
||||||
|
} catch {
|
||||||
|
// If Hermes already finished, the caller still sees the run as aborted.
|
||||||
|
}
|
||||||
|
activeHermesSessions.delete(providerSessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHermesSessionActive(sessionId) {
|
||||||
|
return activeHermesSessions.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveHermesSessions() {
|
||||||
|
return Array.from(activeHermesSessions.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
spawnHermes,
|
||||||
|
abortHermesSession,
|
||||||
|
isHermesSessionActive,
|
||||||
|
getActiveHermesSessions,
|
||||||
|
createPermissionDecision,
|
||||||
|
};
|
||||||
287
server/hermes/acp-client.js
Normal file
287
server/hermes/acp-client.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
|
class AcpClient extends EventEmitter {
|
||||||
|
constructor({ command = process.env.HERMES_CLI_PATH || 'hermes acp', cwd = process.cwd(), env = process.env } = {}) {
|
||||||
|
super();
|
||||||
|
const commandParts = command.trim().split(/\s+/);
|
||||||
|
this.command = commandParts.shift() || 'hermes';
|
||||||
|
this.args = commandParts;
|
||||||
|
this.cwd = cwd;
|
||||||
|
this.env = env;
|
||||||
|
this.process = null;
|
||||||
|
this.nextId = 1;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.buffer = '';
|
||||||
|
this.requestHandlers = new Map();
|
||||||
|
this.initialized = false;
|
||||||
|
this.initializeResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.process = spawnFunction(this.command, this.args, {
|
||||||
|
cwd: this.cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...this.env },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout.on('data', (chunk) => this.handleData(chunk));
|
||||||
|
this.process.stderr.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
if (text.trim()) {
|
||||||
|
this.emit('stderr', text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.process.on('error', (error) => this.rejectAll(error));
|
||||||
|
this.process.on('close', (code, signal) => {
|
||||||
|
this.rejectAll(new Error(`hermes-acp exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`));
|
||||||
|
this.emit('close', { code, signal });
|
||||||
|
this.process = null;
|
||||||
|
this.initialized = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.start();
|
||||||
|
this.initializeResult = await this.request('initialize', {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
fs: {
|
||||||
|
readTextFile: false,
|
||||||
|
writeTextFile: false,
|
||||||
|
},
|
||||||
|
terminal: false,
|
||||||
|
},
|
||||||
|
clientInfo: {
|
||||||
|
name: 'CloudCLI',
|
||||||
|
title: 'CloudCLI',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.initialized = true;
|
||||||
|
this.notify('initialized', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onRequest(method, handler) {
|
||||||
|
this.requestHandlers.set(method, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRequestHandler(method, handler) {
|
||||||
|
const handlers = this.requestHandlers.get(method) || new Set();
|
||||||
|
handlers.add(handler);
|
||||||
|
this.requestHandlers.set(method, handlers);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size === 0) {
|
||||||
|
this.requestHandlers.delete(method);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(method, params) {
|
||||||
|
this.start();
|
||||||
|
const id = this.nextId;
|
||||||
|
this.nextId += 1;
|
||||||
|
|
||||||
|
const payload = { jsonrpc: '2.0', id, method, params };
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject, method, params });
|
||||||
|
this.writeMessage(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(method, params) {
|
||||||
|
this.start();
|
||||||
|
this.writeMessage({ jsonrpc: '2.0', method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
writeMessage(payload) {
|
||||||
|
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) {
|
||||||
|
throw new Error('hermes-acp process is not running');
|
||||||
|
}
|
||||||
|
const line = `${JSON.stringify(payload)}\n`;
|
||||||
|
this.process.stdin.write(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleData(chunk) {
|
||||||
|
this.buffer += chunk.toString();
|
||||||
|
|
||||||
|
while (this.buffer.length > 0) {
|
||||||
|
if (this.buffer.startsWith('Content-Length:')) {
|
||||||
|
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const header = this.buffer.slice(0, headerEnd);
|
||||||
|
const match = header.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (!match) {
|
||||||
|
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const length = Number(match[1]);
|
||||||
|
const messageStart = headerEnd + 4;
|
||||||
|
if (this.buffer.length < messageStart + length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = this.buffer.slice(messageStart, messageStart + length);
|
||||||
|
this.buffer = this.buffer.slice(messageStart + length);
|
||||||
|
this.dispatchRaw(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newlineIndex = this.buffer.indexOf('\n');
|
||||||
|
if (newlineIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = this.buffer.slice(0, newlineIndex).trim();
|
||||||
|
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||||
|
if (raw) {
|
||||||
|
this.dispatchRaw(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchRaw(raw) {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.dispatchMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchMessage(message) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'id') && (message.result !== undefined || message.error !== undefined)) {
|
||||||
|
const pending = this.pending.get(message.id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
const messageText = message.error.message || JSON.stringify(message.error);
|
||||||
|
const error = new Error(`ACP ${pending.method} failed: ${messageText}`);
|
||||||
|
error.code = message.error.code;
|
||||||
|
error.data = message.error.data;
|
||||||
|
error.method = pending.method;
|
||||||
|
error.params = pending.params;
|
||||||
|
pending.reject(error);
|
||||||
|
} else {
|
||||||
|
pending.resolve(message.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
|
||||||
|
const handler = this.requestHandlers.get(message.method);
|
||||||
|
if (!handler) {
|
||||||
|
this.writeMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: message.id,
|
||||||
|
error: { code: -32601, message: `No handler for ${message.method}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = handler instanceof Set
|
||||||
|
? await this.dispatchRequestHandlers(handler, message.params)
|
||||||
|
: await handler(message.params);
|
||||||
|
this.writeMessage({ jsonrpc: '2.0', id: message.id, result });
|
||||||
|
} catch (error) {
|
||||||
|
this.writeMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: message.id,
|
||||||
|
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method) {
|
||||||
|
this.emit(message.method, message.params);
|
||||||
|
this.emit('notification', { method: message.method, params: message.params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAll(error) {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchRequestHandlers(handlers, params) {
|
||||||
|
let fallbackResult = null;
|
||||||
|
let sawHandler = false;
|
||||||
|
for (const handler of Array.from(handlers).reverse()) {
|
||||||
|
sawHandler = true;
|
||||||
|
const result = await handler(params);
|
||||||
|
const outcome = result?.outcome?.outcome;
|
||||||
|
if (outcome !== 'cancelled') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
fallbackResult = result;
|
||||||
|
}
|
||||||
|
if (sawHandler && fallbackResult) {
|
||||||
|
return fallbackResult;
|
||||||
|
}
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HermesConnectionManager {
|
||||||
|
constructor() {
|
||||||
|
this.connections = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConnection(cwd) {
|
||||||
|
const key = cwd || process.cwd();
|
||||||
|
let connection = this.connections.get(key);
|
||||||
|
if (!connection) {
|
||||||
|
connection = new AcpClient({ cwd: key });
|
||||||
|
connection.on('close', () => {
|
||||||
|
this.connections.delete(key);
|
||||||
|
});
|
||||||
|
this.connections.set(key, connection);
|
||||||
|
}
|
||||||
|
await connection.initialize();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll() {
|
||||||
|
for (const connection of this.connections.values()) {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
this.connections.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesConnectionManager = new HermesConnectionManager();
|
||||||
|
|
||||||
|
export {
|
||||||
|
AcpClient,
|
||||||
|
HermesConnectionManager,
|
||||||
|
hermesConnectionManager,
|
||||||
|
};
|
||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
spawnOpenCode,
|
spawnOpenCode,
|
||||||
abortOpenCodeSession,
|
abortOpenCodeSession,
|
||||||
} from './opencode-cli.js';
|
} from './opencode-cli.js';
|
||||||
|
import {
|
||||||
|
spawnHermes,
|
||||||
|
abortHermesSession,
|
||||||
|
} from './hermes-cli.js';
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import {
|
import {
|
||||||
stripAnsiSequences,
|
stripAnsiSequences,
|
||||||
@@ -118,6 +122,7 @@ const wss = createWebSocketServer(server, {
|
|||||||
codex: queryCodex,
|
codex: queryCodex,
|
||||||
gemini: spawnGemini,
|
gemini: spawnGemini,
|
||||||
opencode: spawnOpenCode,
|
opencode: spawnOpenCode,
|
||||||
|
hermes: spawnHermes,
|
||||||
},
|
},
|
||||||
abortFns: {
|
abortFns: {
|
||||||
claude: abortClaudeSDKSession,
|
claude: abortClaudeSDKSession,
|
||||||
@@ -125,6 +130,7 @@ const wss = createWebSocketServer(server, {
|
|||||||
codex: abortCodexSession,
|
codex: abortCodexSession,
|
||||||
gemini: abortGeminiSession,
|
gemini: abortGeminiSession,
|
||||||
opencode: abortOpenCodeSession,
|
opencode: abortOpenCodeSession,
|
||||||
|
hermes: abortHermesSession,
|
||||||
},
|
},
|
||||||
resolveToolApproval,
|
resolveToolApproval,
|
||||||
getPendingApprovalsForSession,
|
getPendingApprovalsForSession,
|
||||||
|
|||||||
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import spawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||||
|
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||||
|
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export class HermesProviderAuth implements IProviderAuth {
|
||||||
|
private checkInstalled(): boolean {
|
||||||
|
const cliPath = process.env.HERMES_CLI_PATH || 'hermes acp';
|
||||||
|
const [command, ...args] = cliPath.trim().split(/\s+/);
|
||||||
|
try {
|
||||||
|
const result = spawn.sync(command || 'hermes', [...args, '--version'], { stdio: 'ignore', timeout: 5000 });
|
||||||
|
return result.error ? false : result.status === 0 || result.status === null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<ProviderAuthStatus> {
|
||||||
|
const installed = this.checkInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
installed: false,
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: null,
|
||||||
|
error: 'Hermes is not installed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await this.checkCredentials();
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
installed,
|
||||||
|
authenticated: credentials.authenticated,
|
||||||
|
email: credentials.email,
|
||||||
|
method: credentials.method ?? 'managed_by_hermes',
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
|
||||||
|
if (this.hasKnownProviderEnv(process.env)) {
|
||||||
|
return { authenticated: true, email: 'API Key Auth', method: 'env' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesHome = path.join(os.homedir(), '.hermes');
|
||||||
|
try {
|
||||||
|
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
|
||||||
|
if (
|
||||||
|
readOptionalString(authJson?.apiKey)
|
||||||
|
|| readOptionalString(authJson?.api_key)
|
||||||
|
|| readOptionalString(authJson?.token)
|
||||||
|
|| readOptionalString(authJson?.access_token)
|
||||||
|
|| readOptionalString(authJson?.refresh_token)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
|
||||||
|
method: 'credentials_file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to dotenv check.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
|
||||||
|
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
|
||||||
|
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
|
||||||
|
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
|
||||||
|
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticated: false, email: null, method: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEnvFile(content: string): Record<string, string> {
|
||||||
|
const parsed: Record<string, string> = {};
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separatorIndex = line.indexOf('=');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = line.slice(0, separatorIndex).trim();
|
||||||
|
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
if (key && value) {
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
|
||||||
|
const keys = [
|
||||||
|
'HERMES_API_KEY',
|
||||||
|
'NOUS_API_KEY',
|
||||||
|
'OPENROUTER_API_KEY',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GLM_API_KEY',
|
||||||
|
'KIMI_API_KEY',
|
||||||
|
'MINIMAX_API_KEY',
|
||||||
|
'MINIMAX_CN_API_KEY',
|
||||||
|
'HF_TOKEN',
|
||||||
|
'NVIDIA_API_KEY',
|
||||||
|
'ARCEEAI_API_KEY',
|
||||||
|
'OLLAMA_API_KEY',
|
||||||
|
'KILOCODE_API_KEY',
|
||||||
|
'GITHUB_TOKEN',
|
||||||
|
];
|
||||||
|
return keys.some((key) => Boolean(env[key]?.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||||
|
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
AppError,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
readStringArray,
|
||||||
|
readStringRecord,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const yamlScalar = (value: unknown): string => {
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseYamlScalar = (value: string): unknown => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||||
|
|| (trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\s+#.*$/, '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIndent = (line: string): number => line.match(/^\s*/)?.[0].length ?? 0;
|
||||||
|
|
||||||
|
const parseYamlArray = (
|
||||||
|
lines: string[],
|
||||||
|
startIndex: number,
|
||||||
|
indent: number,
|
||||||
|
): { value: unknown[]; nextIndex: number } => {
|
||||||
|
const value: unknown[] = [];
|
||||||
|
let index = startIndex;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index];
|
||||||
|
if (!line.trim()) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (getIndent(line) !== indent || !line.trimStart().startsWith('- ')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value.push(parseYamlScalar(line.trimStart().slice(2)));
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return { value, nextIndex: index };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseYamlMap = (
|
||||||
|
lines: string[],
|
||||||
|
startIndex: number,
|
||||||
|
indent: number,
|
||||||
|
): { value: Record<string, unknown>; nextIndex: number } => {
|
||||||
|
const value: Record<string, unknown> = {};
|
||||||
|
let index = startIndex;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index];
|
||||||
|
if (!line.trim()) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentIndent = getIndent(line);
|
||||||
|
if (currentIndent < indent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentIndent > indent) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = line.slice(indent).match(/^([^:#]+):(?:\s*(.*))?$/);
|
||||||
|
if (!match) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = match[1].trim();
|
||||||
|
const raw = match[2]?.trim() ?? '';
|
||||||
|
if (raw) {
|
||||||
|
value[key] = parseYamlScalar(raw);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLine = lines[index + 1];
|
||||||
|
if (nextLine && getIndent(nextLine) > indent && nextLine.trimStart().startsWith('- ')) {
|
||||||
|
const parsed = parseYamlArray(lines, index + 1, getIndent(nextLine));
|
||||||
|
value[key] = parsed.value;
|
||||||
|
index = parsed.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseYamlMap(lines, index + 1, indent + 2);
|
||||||
|
value[key] = parsed.value;
|
||||||
|
index = parsed.nextIndex;
|
||||||
|
}
|
||||||
|
return { value, nextIndex: index };
|
||||||
|
};
|
||||||
|
|
||||||
|
const readYamlConfig = async (filePath: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await readFile(filePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMcpServers = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||||
|
const content = await readYamlConfig(filePath);
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||||
|
if (sectionIndex === -1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const parsed = parseYamlMap(lines, sectionIndex + 1, 2);
|
||||||
|
return readObjectRecord(parsed.value) ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeYamlMap = (value: Record<string, unknown>, indent = 0): string[] => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [key, rawValue] of Object.entries(value)) {
|
||||||
|
if (rawValue === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prefix = `${' '.repeat(indent)}${key}:`;
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
lines.push(prefix);
|
||||||
|
for (const item of rawValue) {
|
||||||
|
lines.push(`${' '.repeat(indent + 2)}- ${yamlScalar(item)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nested = readObjectRecord(rawValue);
|
||||||
|
if (nested) {
|
||||||
|
lines.push(prefix);
|
||||||
|
lines.push(...serializeYamlMap(nested, indent + 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(`${prefix} ${yamlScalar(rawValue)}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceMcpServersSection = (content: string, servers: Record<string, unknown>): string => {
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||||
|
const serialized = ['mcp_servers:', ...serializeYamlMap(servers, 2)];
|
||||||
|
|
||||||
|
if (sectionIndex === -1) {
|
||||||
|
const prefix = content.trimEnd();
|
||||||
|
return `${prefix ? `${prefix}\n\n` : ''}${serialized.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = sectionIndex + 1;
|
||||||
|
while (endIndex < lines.length) {
|
||||||
|
const line = lines[endIndex];
|
||||||
|
if (line.trim() && getIndent(line) === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
endIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.splice(sectionIndex, endIndex - sectionIndex, ...serialized);
|
||||||
|
return `${lines.join('\n').trimEnd()}\n`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeMcpServers = async (filePath: string, servers: Record<string, unknown>): Promise<void> => {
|
||||||
|
const content = await readYamlConfig(filePath);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, replaceMcpServersSection(content, servers), 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HermesMcpProvider extends McpProvider {
|
||||||
|
constructor() {
|
||||||
|
super('hermes', ['user', 'project'], ['stdio', 'http']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||||
|
const filePath = scope === 'user'
|
||||||
|
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||||
|
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||||
|
return readMcpServers(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async writeScopedServers(
|
||||||
|
scope: McpScope,
|
||||||
|
workspacePath: string,
|
||||||
|
servers: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = scope === 'user'
|
||||||
|
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||||
|
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||||
|
await writeMcpServers(filePath, servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||||
|
if (input.transport === 'stdio') {
|
||||||
|
if (!input.command?.trim()) {
|
||||||
|
throw new AppError('command is required for stdio MCP servers.', {
|
||||||
|
code: 'MCP_COMMAND_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: input.command,
|
||||||
|
args: input.args ?? [],
|
||||||
|
env: input.env ?? {},
|
||||||
|
cwd: input.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.url?.trim()) {
|
||||||
|
throw new AppError('url is required for http/sse MCP servers.', {
|
||||||
|
code: 'MCP_URL_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: input.transport,
|
||||||
|
url: input.url,
|
||||||
|
headers: input.headers ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeServerConfig(scope: McpScope, name: string, rawConfig: unknown): ProviderMcpServer | null {
|
||||||
|
const config = readObjectRecord(rawConfig);
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof config.command === 'string') {
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
name,
|
||||||
|
scope,
|
||||||
|
transport: 'stdio',
|
||||||
|
command: config.command,
|
||||||
|
args: readStringArray(config.args),
|
||||||
|
env: readStringRecord(config.env),
|
||||||
|
cwd: readOptionalString(config.cwd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof config.url === 'string') {
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
name,
|
||||||
|
scope,
|
||||||
|
transport: 'http',
|
||||||
|
url: config.url,
|
||||||
|
headers: readStringRecord(config.headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { readOptionalString } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||||
|
|
||||||
|
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: HERMES_CONFIGURED_MODEL,
|
||||||
|
label: 'Use Hermes default',
|
||||||
|
description: 'Uses the provider and model selected in Hermes.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripScalar(raw: string): string | null {
|
||||||
|
let value = raw.trim();
|
||||||
|
// Drop an unquoted trailing comment.
|
||||||
|
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||||
|
const comment = value.search(/\s#/);
|
||||||
|
if (comment >= 0) {
|
||||||
|
value = value.slice(0, comment).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
return value.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
|
||||||
|
|
||||||
|
// Minimal, indentation-aware reader for the flat `key: value` and one-level
|
||||||
|
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
|
||||||
|
// Avoids the fragile single-regex lookahead that could terminate a section
|
||||||
|
// early and silently miss the configured model.
|
||||||
|
export function readYamlPath(content: string, pathParts: string[]): string | null {
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||||
|
const match = line.match(re);
|
||||||
|
if (match) return stripScalar(match[1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [section, key] = pathParts;
|
||||||
|
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
|
||||||
|
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
|
||||||
|
let sectionIndent: number | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||||
|
|
||||||
|
if (sectionIndent === null) {
|
||||||
|
const match = line.match(sectionRe);
|
||||||
|
if (match) sectionIndent = match[1].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left the nested block once indentation returns to the section level or less.
|
||||||
|
if (indentOf(line) <= sectionIndent) {
|
||||||
|
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(keyRe);
|
||||||
|
if (match) return stripScalar(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HermesProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
const activeModel = await this.readConfiguredModel();
|
||||||
|
if (!activeModel) {
|
||||||
|
return HERMES_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: HERMES_CONFIGURED_MODEL,
|
||||||
|
label: 'Use Hermes default',
|
||||||
|
description: `Uses the provider and model selected in Hermes. Current config: ${activeModel}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||||
|
const configured = await this.readConfiguredModel();
|
||||||
|
return { model: configured ?? HERMES_CONFIGURED_MODEL };
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
if (input.model === HERMES_CONFIGURED_MODEL) {
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
supported: true,
|
||||||
|
changed: false,
|
||||||
|
model: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'hermes',
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
supported: false,
|
||||||
|
changed: false,
|
||||||
|
model: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readConfiguredModel(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
|
||||||
|
return readOptionalString(readYamlPath(content, ['model', 'default']))
|
||||||
|
?? readOptionalString(readYamlPath(content, ['model']))
|
||||||
|
?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import fsSync from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
import { normalizeSessionName } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type HermesSessionRow = {
|
||||||
|
id: string;
|
||||||
|
cwd: string | null;
|
||||||
|
title: string | null;
|
||||||
|
started_at: number | null;
|
||||||
|
ended_at: number | null;
|
||||||
|
message_count: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||||
|
|
||||||
|
function unixSecondsToIso(value: number | null | undefined): string {
|
||||||
|
if (!value || !Number.isFinite(value)) {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
return new Date(value * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHermesDatabase(): Database.Database | null {
|
||||||
|
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HermesSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'hermes' as const;
|
||||||
|
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const db = openHermesDatabase();
|
||||||
|
if (!db) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = since
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||||
|
FROM sessions
|
||||||
|
WHERE COALESCE(ended_at, started_at) >= ?
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||||
|
`).all(Math.floor(since.getTime() / 1000)) as HermesSessionRow[]
|
||||||
|
: db.prepare(`
|
||||||
|
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||||
|
`).all() as HermesSessionRow[];
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (this.upsertRow(row)) {
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (path.resolve(filePath) !== HERMES_DB_PATH) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = openHermesDatabase();
|
||||||
|
if (!db) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get() as HermesSessionRow | undefined;
|
||||||
|
return row && this.upsertRow(row) ? row.id : null;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertRow(row: HermesSessionRow): boolean {
|
||||||
|
if (!row.id || !row.cwd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsDb.createSession(
|
||||||
|
row.id,
|
||||||
|
this.provider,
|
||||||
|
row.cwd,
|
||||||
|
normalizeSessionName(row.title ?? undefined, 'Untitled Hermes Session'),
|
||||||
|
unixSecondsToIso(row.started_at),
|
||||||
|
unixSecondsToIso(row.ended_at ?? row.started_at),
|
||||||
|
HERMES_DB_PATH,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import fsSync from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
createNormalizedMessage,
|
||||||
|
generateMessageId,
|
||||||
|
normalizeProviderTimestamp,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
sliceTailPage,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'hermes';
|
||||||
|
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||||
|
|
||||||
|
type HermesMessageRow = {
|
||||||
|
id: number;
|
||||||
|
role: string;
|
||||||
|
content: string | null;
|
||||||
|
tool_call_id: string | null;
|
||||||
|
tool_calls: string | null;
|
||||||
|
tool_name: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
reasoning: string | null;
|
||||||
|
reasoning_content: string | null;
|
||||||
|
finish_reason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatContent(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUpdateType(raw: AnyRecord): string {
|
||||||
|
return readOptionalString(raw.type)
|
||||||
|
?? readOptionalString(raw.kind)
|
||||||
|
?? readOptionalString(raw.sessionUpdate)
|
||||||
|
?? readOptionalString(raw.session_update)
|
||||||
|
?? readOptionalString(raw.update)
|
||||||
|
?? readOptionalString(raw.event)
|
||||||
|
?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
|
||||||
|
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextContent(value: unknown): string | null {
|
||||||
|
const direct = readOptionalString(value);
|
||||||
|
if (direct !== undefined) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const parts = value
|
||||||
|
.map((entry) => readTextContent(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry?.trim()));
|
||||||
|
return parts.length > 0 ? parts.join('') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = readObjectRecord(value);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedContent = record.content;
|
||||||
|
const nestedText = nestedContent === value ? null : readTextContent(nestedContent);
|
||||||
|
|
||||||
|
return readOptionalString(record.text)
|
||||||
|
?? readOptionalString(record.content)
|
||||||
|
?? nestedText
|
||||||
|
?? readOptionalString(record.delta)
|
||||||
|
?? readOptionalString(record.rawOutput)
|
||||||
|
?? readOptionalString(record.raw_output)
|
||||||
|
?? readOptionalString(record.output)
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolPayload(raw: AnyRecord): AnyRecord {
|
||||||
|
return readObjectRecord(raw.toolCall)
|
||||||
|
?? readObjectRecord(raw.tool_call)
|
||||||
|
?? readObjectRecord(raw.tool)
|
||||||
|
?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
|
||||||
|
const envelope = readObjectRecord(rawMessage);
|
||||||
|
if (!envelope) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedUpdate = readObjectRecord(envelope.update);
|
||||||
|
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
|
||||||
|
|
||||||
|
const type = readUpdateType(raw);
|
||||||
|
const eventSessionId = readEventSessionId(raw, sessionId);
|
||||||
|
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
|
||||||
|
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
|
||||||
|
|
||||||
|
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
|
||||||
|
const content = readTextContent(raw.content)
|
||||||
|
?? readOptionalString(raw.text)
|
||||||
|
?? readOptionalString(raw.delta)
|
||||||
|
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||||
|
?? '';
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: history ? 'text' : 'stream_delta',
|
||||||
|
role: history ? 'assistant' : undefined,
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
|
||||||
|
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
|
||||||
|
const content = readTextContent(raw.content)
|
||||||
|
?? readOptionalString(raw.text)
|
||||||
|
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||||
|
?? '';
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
|
||||||
|
role: history || role === 'user' ? role : undefined,
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
|
||||||
|
const content = readTextContent(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
|
||||||
|
const tool = readToolPayload(raw);
|
||||||
|
const toolId = readOptionalString(raw.toolCallId)
|
||||||
|
?? readOptionalString(raw.tool_call_id)
|
||||||
|
?? readOptionalString(raw.toolId)
|
||||||
|
?? readOptionalString(tool.toolCallId)
|
||||||
|
?? readOptionalString(tool.tool_call_id)
|
||||||
|
?? readOptionalString(tool.toolId)
|
||||||
|
?? readOptionalString(tool.id)
|
||||||
|
?? baseId;
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: readOptionalString(raw.toolName)
|
||||||
|
?? readOptionalString(raw.tool_name)
|
||||||
|
?? readOptionalString(raw.title)
|
||||||
|
?? readOptionalString(raw.name)
|
||||||
|
?? readOptionalString(tool?.name)
|
||||||
|
?? readOptionalString(tool?.title)
|
||||||
|
?? 'Tool',
|
||||||
|
toolInput: raw.rawInput
|
||||||
|
?? raw.raw_input
|
||||||
|
?? raw.input
|
||||||
|
?? raw.arguments
|
||||||
|
?? raw.params
|
||||||
|
?? tool?.rawInput
|
||||||
|
?? tool?.raw_input
|
||||||
|
?? tool?.input
|
||||||
|
?? tool?.arguments
|
||||||
|
?? {},
|
||||||
|
toolId,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
|
||||||
|
const tool = readToolPayload(raw);
|
||||||
|
const content = readTextContent(raw.content)
|
||||||
|
?? readTextContent(raw.rawOutput)
|
||||||
|
?? readTextContent(raw.raw_output)
|
||||||
|
?? readTextContent(raw.output)
|
||||||
|
?? readTextContent(raw.result)
|
||||||
|
?? readTextContent(tool.rawOutput)
|
||||||
|
?? readTextContent(tool.raw_output)
|
||||||
|
?? readTextContent(tool.output)
|
||||||
|
?? readTextContent(tool.result)
|
||||||
|
?? '';
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: readOptionalString(raw.toolCallId)
|
||||||
|
?? readOptionalString(raw.tool_call_id)
|
||||||
|
?? readOptionalString(raw.toolId)
|
||||||
|
?? readOptionalString(tool.toolCallId)
|
||||||
|
?? readOptionalString(tool.tool_call_id)
|
||||||
|
?? readOptionalString(tool.toolId)
|
||||||
|
?? readOptionalString(tool.id)
|
||||||
|
?? '',
|
||||||
|
content: content || formatContent(raw.delta ?? ''),
|
||||||
|
isError: Boolean(raw.error) || raw.status === 'error' || raw.status === 'failed',
|
||||||
|
toolUseResult: raw.result ?? raw.output ?? raw.rawOutput ?? raw.raw_output ?? tool.result ?? tool.output ?? tool.rawOutput ?? tool.raw_output,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'plan') {
|
||||||
|
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'status',
|
||||||
|
text: 'plan',
|
||||||
|
summary: content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'error',
|
||||||
|
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(value: string | null): unknown[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
|
||||||
|
const normalized: NormalizedMessage[] = [];
|
||||||
|
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ? AND active = 1
|
||||||
|
ORDER BY timestamp ASC, id ASC
|
||||||
|
`).all(sessionId) as HermesMessageRow[];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const timestamp = new Date(row.timestamp * 1000).toISOString();
|
||||||
|
const baseId = `hermes-${sessionId}-${row.id}`;
|
||||||
|
|
||||||
|
const reasoning = row.reasoning_content || row.reasoning;
|
||||||
|
if (reasoning?.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}-thinking`,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: reasoning,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolCall of parseJsonArray(row.tool_calls)) {
|
||||||
|
const call = readObjectRecord(toolCall);
|
||||||
|
const fn = readObjectRecord(call?.function);
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
|
||||||
|
toolInput: fn?.arguments ?? call?.arguments ?? {},
|
||||||
|
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.role === 'tool') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}-result`,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: row.tool_call_id ?? '',
|
||||||
|
content: row.content ?? '',
|
||||||
|
isError: row.finish_reason === 'error',
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.content?.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: row.role === 'user' ? 'user' : 'assistant',
|
||||||
|
content: row.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HermesSessionsProvider implements IProviderSessions {
|
||||||
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||||
|
return normalizeHermesEvent(rawMessage, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
|
||||||
|
const { limit = null, offset = 0 } = options;
|
||||||
|
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||||
|
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
|
||||||
|
|
||||||
|
const start = Math.max(0, offset);
|
||||||
|
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||||
|
const page = sliceTailPage(messages, pageLimit, start);
|
||||||
|
return {
|
||||||
|
messages: page.page,
|
||||||
|
total: messages.length,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
offset: start,
|
||||||
|
limit: pageLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type {
|
||||||
|
ProviderSkillRegistryActionResult,
|
||||||
|
ProviderSkillRegistryInstallInput,
|
||||||
|
ProviderSkillRegistrySearchOptions,
|
||||||
|
ProviderSkillRegistrySearchResult,
|
||||||
|
ProviderSkillSource,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { AppError, addUniqueProviderSkillSource, readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const HERMES_COMMAND =
|
||||||
|
(process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes').trim().split(/\s+/)[0] || 'hermes';
|
||||||
|
const HERMES_SKILLS_TIMEOUT_MS = 45_000;
|
||||||
|
const HERMES_SKILLS_MAX_BUFFER = 1024 * 1024 * 8;
|
||||||
|
|
||||||
|
function normalizeSearchResult(value: unknown): ProviderSkillRegistrySearchResult | null {
|
||||||
|
const record = readObjectRecord(value);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = readOptionalString(record.name);
|
||||||
|
const identifier = readOptionalString(record.identifier);
|
||||||
|
if (!name || !identifier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
identifier,
|
||||||
|
source: readOptionalString(record.source) ?? undefined,
|
||||||
|
trustLevel: readOptionalString(record.trust_level) ?? readOptionalString(record.trustLevel) ?? undefined,
|
||||||
|
description: readOptionalString(record.description) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HermesSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('hermes');
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRegistry(
|
||||||
|
query: string,
|
||||||
|
options: ProviderSkillRegistrySearchOptions = {},
|
||||||
|
): Promise<ProviderSkillRegistrySearchResult[]> {
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['skills', 'search', normalizedQuery, '--json'];
|
||||||
|
const source = options.source?.trim();
|
||||||
|
if (source) {
|
||||||
|
args.push('--source', source);
|
||||||
|
}
|
||||||
|
if (options.limit && Number.isFinite(options.limit)) {
|
||||||
|
args.push('--limit', String(Math.max(1, Math.min(Math.floor(options.limit), 50))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.runHermes(args);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result.stdout);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.map(normalizeSearchResult).filter((entry): entry is ProviderSkillRegistrySearchResult => Boolean(entry))
|
||||||
|
: [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new AppError('Hermes returned invalid skill search JSON.', {
|
||||||
|
code: 'HERMES_SKILL_SEARCH_PARSE_FAILED',
|
||||||
|
statusCode: 502,
|
||||||
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installRegistrySkill(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
if (!identifier) {
|
||||||
|
throw new AppError('identifier is required.', {
|
||||||
|
code: 'HERMES_SKILL_IDENTIFIER_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['skills', 'install', identifier, '--yes'];
|
||||||
|
if (input.category?.trim()) {
|
||||||
|
args.push('--category', input.category.trim());
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
args.push('--name', input.name.trim());
|
||||||
|
}
|
||||||
|
if (input.force) {
|
||||||
|
args.push('--force');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.runHermes(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallRegistrySkill(name: string): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const normalizedName = name.trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
throw new AppError('name is required.', {
|
||||||
|
code: 'HERMES_SKILL_NAME_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.runHermes(['skills', 'uninstall', normalizedName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkRegistryUpdates(): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
return this.runHermes(['skills', 'check']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
return this.runHermes(['skills', 'update']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async auditRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
return this.runHermes(['skills', 'audit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
const sources: ProviderSkillSource[] = [];
|
||||||
|
const seenRootDirs = new Set<string>();
|
||||||
|
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'repo',
|
||||||
|
rootDir: path.join(workspacePath, '.hermes', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||||
|
return {
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
recursive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runHermes(args: string[]): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(HERMES_COMMAND, args, {
|
||||||
|
timeout: HERMES_SKILLS_TIMEOUT_MS,
|
||||||
|
maxBuffer: HERMES_SKILLS_MAX_BUFFER,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
return { ok: true, stdout, stderr };
|
||||||
|
} catch (error) {
|
||||||
|
const maybeError = error as Error & {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
code?: number | string;
|
||||||
|
};
|
||||||
|
throw new AppError(maybeError.stderr || maybeError.message || 'Hermes skill command failed.', {
|
||||||
|
code: 'HERMES_SKILL_COMMAND_FAILED',
|
||||||
|
statusCode: 502,
|
||||||
|
details: {
|
||||||
|
exitCode: maybeError.code,
|
||||||
|
stdout: maybeError.stdout,
|
||||||
|
stderr: maybeError.stderr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { HermesProviderAuth } from '@/modules/providers/list/hermes/hermes-auth.provider.js';
|
||||||
|
import { HermesMcpProvider } from '@/modules/providers/list/hermes/hermes-mcp.provider.js';
|
||||||
|
import { HermesProviderModels } from '@/modules/providers/list/hermes/hermes-models.provider.js';
|
||||||
|
import { HermesSessionSynchronizer } from '@/modules/providers/list/hermes/hermes-session-synchronizer.provider.js';
|
||||||
|
import { HermesSessionsProvider } from '@/modules/providers/list/hermes/hermes-sessions.provider.js';
|
||||||
|
import { HermesSkillsProvider } from '@/modules/providers/list/hermes/hermes-skills.provider.js';
|
||||||
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
export class HermesProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new HermesProviderModels();
|
||||||
|
readonly mcp = new HermesMcpProvider();
|
||||||
|
readonly auth: IProviderAuth = new HermesProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new HermesSkillsProvider();
|
||||||
|
readonly sessions: IProviderSessions = new HermesSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new HermesSessionSynchronizer();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('hermes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
|||||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||||
|
import { HermesProvider } from '@/modules/providers/list/hermes/hermes.provider.js';
|
||||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||||
import type { IProvider } from '@/shared/interfaces.js';
|
import type { IProvider } from '@/shared/interfaces.js';
|
||||||
import type { LLMProvider } from '@/shared/types.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
@@ -13,6 +14,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
|||||||
cursor: new CursorProvider(),
|
cursor: new CursorProvider(),
|
||||||
gemini: new GeminiProvider(),
|
gemini: new GeminiProvider(),
|
||||||
opencode: new OpenCodeProvider(),
|
opencode: new OpenCodeProvider(),
|
||||||
|
hermes: new HermesProvider(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -279,6 +279,48 @@ const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateI
|
|||||||
return { entries };
|
return { entries };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseSkillRegistryLimit = (value: unknown): number => {
|
||||||
|
const raw = readOptionalQueryString(value);
|
||||||
|
if (!raw) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new AppError('limit must be a valid integer.', {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(parsed, 50));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSkillRegistryInstallPayload = (payload: unknown) => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new AppError('Request body must be an object.', {
|
||||||
|
code: 'INVALID_REQUEST_BODY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = payload as Record<string, unknown>;
|
||||||
|
const identifier = readOptionalQueryString(body.identifier);
|
||||||
|
if (!identifier) {
|
||||||
|
throw new AppError('identifier is required.', {
|
||||||
|
code: 'SKILL_IDENTIFIER_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
category: readOptionalQueryString(body.category),
|
||||||
|
name: readOptionalQueryString(body.name),
|
||||||
|
force: body.force === true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const parseProvider = (value: unknown): LLMProvider => {
|
const parseProvider = (value: unknown): LLMProvider => {
|
||||||
const normalized = normalizeProviderParam(value);
|
const normalized = normalizeProviderParam(value);
|
||||||
if (
|
if (
|
||||||
@@ -287,6 +329,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|
|||||||
|| normalized === 'cursor'
|
|| normalized === 'cursor'
|
||||||
|| normalized === 'gemini'
|
|| normalized === 'gemini'
|
||||||
|| normalized === 'opencode'
|
|| normalized === 'opencode'
|
||||||
|
|| normalized === 'hermes'
|
||||||
) {
|
) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -441,6 +484,77 @@ router.delete(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:provider/skills/registry/search',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const query = readOptionalQueryString(req.query.query);
|
||||||
|
if (!query) {
|
||||||
|
throw new AppError('query is required.', {
|
||||||
|
code: 'SKILL_SEARCH_QUERY_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await providerSkillsService.searchSkillRegistry(provider, query, {
|
||||||
|
source: readOptionalQueryString(req.query.source),
|
||||||
|
limit: parseSkillRegistryLimit(req.query.limit),
|
||||||
|
});
|
||||||
|
res.json(createApiSuccessResponse({ provider, results }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:provider/skills/registry/install',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.installRegistrySkill(
|
||||||
|
provider,
|
||||||
|
parseSkillRegistryInstallPayload(req.body),
|
||||||
|
);
|
||||||
|
res.status(201).json(createApiSuccessResponse({ provider, result }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:provider/skills/registry/check',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.checkRegistryUpdates(provider);
|
||||||
|
res.json(createApiSuccessResponse({ provider, result }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:provider/skills/registry/update',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.updateRegistrySkills(provider);
|
||||||
|
res.json(createApiSuccessResponse({ provider, result }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:provider/skills/registry/audit',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.auditRegistrySkills(provider);
|
||||||
|
res.json(createApiSuccessResponse({ provider, result }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:provider/skills/registry/:name',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.uninstallRegistrySkill(
|
||||||
|
provider,
|
||||||
|
readPathParam(req.params.name, 'name'),
|
||||||
|
);
|
||||||
|
res.json(createApiSuccessResponse({ provider, result }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// ----------------- MCP routes -----------------
|
// ----------------- MCP routes -----------------
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/mcp/servers',
|
'/:provider/mcp/servers',
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsPermissionRequests: false,
|
supportsPermissionRequests: false,
|
||||||
supportsTokenUsage: true,
|
supportsTokenUsage: true,
|
||||||
},
|
},
|
||||||
|
hermes: {
|
||||||
|
provider: 'hermes',
|
||||||
|
permissionModes: ['default'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: true,
|
||||||
|
supportsTokenUsage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const sessionSynchronizerService = {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
gemini: 0,
|
gemini: 0,
|
||||||
opencode: 0,
|
opencode: 0,
|
||||||
|
hermes: 0,
|
||||||
};
|
};
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
|||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provider: 'hermes',
|
||||||
|
rootPath: path.join(os.homedir(), '.hermes'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const WATCHER_IGNORED_PATTERNS = [
|
const WATCHER_IGNORED_PATTERNS = [
|
||||||
@@ -81,6 +85,10 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
|||||||
return path.basename(filePath) === 'opencode.db';
|
return path.basename(filePath) === 'opencode.db';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'hermes') {
|
||||||
|
return path.basename(filePath) === 'state.db';
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'gemini') {
|
if (provider === 'gemini') {
|
||||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,29 @@ import type {
|
|||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
ProviderSkillRemoveInput,
|
ProviderSkillRemoveInput,
|
||||||
|
ProviderSkillRegistryActionResult,
|
||||||
|
ProviderSkillRegistryInstallInput,
|
||||||
|
ProviderSkillRegistrySearchOptions,
|
||||||
|
ProviderSkillRegistrySearchResult,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const getProviderSkills = (providerName: string) => providerRegistry.resolveProvider(providerName).skills;
|
||||||
|
|
||||||
|
const requireSkillRegistryMethod = <TMethod extends keyof ReturnType<typeof getProviderSkills>>(
|
||||||
|
providerName: string,
|
||||||
|
methodName: TMethod,
|
||||||
|
): NonNullable<ReturnType<typeof getProviderSkills>[TMethod]> => {
|
||||||
|
const skills = getProviderSkills(providerName);
|
||||||
|
const method = skills[methodName];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new AppError(`${providerName} does not support skill registry operations.`, {
|
||||||
|
code: 'PROVIDER_SKILL_REGISTRY_UNSUPPORTED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return method as NonNullable<ReturnType<typeof getProviderSkills>[TMethod]>;
|
||||||
|
};
|
||||||
|
|
||||||
export const providerSkillsService = {
|
export const providerSkillsService = {
|
||||||
/**
|
/**
|
||||||
@@ -14,8 +36,7 @@ export const providerSkillsService = {
|
|||||||
providerName: string,
|
providerName: string,
|
||||||
options?: ProviderSkillListOptions,
|
options?: ProviderSkillListOptions,
|
||||||
): Promise<ProviderSkill[]> {
|
): Promise<ProviderSkill[]> {
|
||||||
const provider = providerRegistry.resolveProvider(providerName);
|
return getProviderSkills(providerName).listSkills(options);
|
||||||
return provider.skills.listSkills(options);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,8 +46,44 @@ export const providerSkillsService = {
|
|||||||
providerName: string,
|
providerName: string,
|
||||||
input: ProviderSkillCreateInput,
|
input: ProviderSkillCreateInput,
|
||||||
): Promise<ProviderSkill[]> {
|
): Promise<ProviderSkill[]> {
|
||||||
const provider = providerRegistry.resolveProvider(providerName);
|
return getProviderSkills(providerName).addSkills(input);
|
||||||
return provider.skills.addSkills(input);
|
},
|
||||||
|
|
||||||
|
async searchSkillRegistry(
|
||||||
|
providerName: string,
|
||||||
|
query: string,
|
||||||
|
options?: ProviderSkillRegistrySearchOptions,
|
||||||
|
): Promise<ProviderSkillRegistrySearchResult[]> {
|
||||||
|
const searchRegistry = requireSkillRegistryMethod(providerName, 'searchRegistry');
|
||||||
|
return searchRegistry.call(getProviderSkills(providerName), query, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
async installRegistrySkill(
|
||||||
|
providerName: string,
|
||||||
|
input: ProviderSkillRegistryInstallInput,
|
||||||
|
): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const installRegistrySkill = requireSkillRegistryMethod(providerName, 'installRegistrySkill');
|
||||||
|
return installRegistrySkill.call(getProviderSkills(providerName), input);
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstallRegistrySkill(providerName: string, name: string): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const uninstallRegistrySkill = requireSkillRegistryMethod(providerName, 'uninstallRegistrySkill');
|
||||||
|
return uninstallRegistrySkill.call(getProviderSkills(providerName), name);
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkRegistryUpdates(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const checkRegistryUpdates = requireSkillRegistryMethod(providerName, 'checkRegistryUpdates');
|
||||||
|
return checkRegistryUpdates.call(getProviderSkills(providerName));
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const updateRegistrySkills = requireSkillRegistryMethod(providerName, 'updateRegistrySkills');
|
||||||
|
return updateRegistrySkills.call(getProviderSkills(providerName));
|
||||||
|
},
|
||||||
|
|
||||||
|
async auditRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||||
|
const auditRegistrySkills = requireSkillRegistryMethod(providerName, 'auditRegistrySkills');
|
||||||
|
return auditRegistrySkills.call(getProviderSkills(providerName));
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeProviderSkill(
|
async removeProviderSkill(
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
|||||||
workspacePath,
|
workspacePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(globalResult.length, 5);
|
assert.equal(globalResult.length, 6);
|
||||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||||
|
|
||||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||||
@@ -356,6 +356,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
|||||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
|
const hermesProject = await fs.readFile(path.join(workspacePath, '.hermes', 'config.yaml'), 'utf8');
|
||||||
|
assert.match(hermesProject, /^mcp_servers:\n/m);
|
||||||
|
assert.match(hermesProject, /^\s+global-http:\n/m);
|
||||||
|
assert.match(hermesProject, /^\s+url: "https:\/\/global\.example\.com\/mcp"\n/m);
|
||||||
|
|
||||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
@@ -377,4 +382,3 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
|||||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ function resolveResumeSessionId(
|
|||||||
return resolvedSessionId;
|
return resolvedSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHermesShellCommand(): string {
|
||||||
|
return (process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0] || 'hermes';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||||
*/
|
*/
|
||||||
@@ -161,6 +167,14 @@ function buildShellCommand(
|
|||||||
return initialCommand || 'opencode';
|
return initialCommand || 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'hermes') {
|
||||||
|
const command = initialCommand || getHermesShellCommand();
|
||||||
|
if (resumeSessionId) {
|
||||||
|
return `${command} --resume "${resumeSessionId}"`;
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
const command = initialCommand || 'claude';
|
const command = initialCommand || 'claude';
|
||||||
if (resumeSessionId) {
|
if (resumeSessionId) {
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
@@ -481,6 +495,8 @@ export function handleShellConnection(
|
|||||||
? 'Gemini'
|
? 'Gemini'
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? 'OpenCode'
|
? 'OpenCode'
|
||||||
|
: provider === 'hermes'
|
||||||
|
? 'Hermes'
|
||||||
: 'Claude';
|
: 'Claude';
|
||||||
welcomeMsg = hasSession && resumeSessionId
|
welcomeMsg = hasSession && resumeSessionId
|
||||||
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { spawnCursor } from '../cursor-cli.js';
|
|||||||
import { queryCodex } from '../openai-codex.js';
|
import { queryCodex } from '../openai-codex.js';
|
||||||
import { spawnGemini } from '../gemini-cli.js';
|
import { spawnGemini } from '../gemini-cli.js';
|
||||||
import { spawnOpenCode } from '../opencode-cli.js';
|
import { spawnOpenCode } from '../opencode-cli.js';
|
||||||
|
import { spawnHermes } from '../hermes-cli.js';
|
||||||
import { Octokit } from '@octokit/rest';
|
import { Octokit } from '@octokit/rest';
|
||||||
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
|
||||||
import { IS_PLATFORM } from '../constants/config.js';
|
import { IS_PLATFORM } from '../constants/config.js';
|
||||||
import { normalizeProjectPath } from '../shared/utils.js';
|
import { normalizeProjectPath } from '../shared/utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to authenticate agent API requests.
|
* Middleware to authenticate agent API requests.
|
||||||
@@ -636,7 +638,7 @@ class ResponseCollector {
|
|||||||
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
||||||
* - Fallback for PR title if no commits are made
|
* - Fallback for PR title if no commits are made
|
||||||
*
|
*
|
||||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
|
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes'
|
||||||
* Default: 'claude'
|
* Default: 'claude'
|
||||||
*
|
*
|
||||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||||
@@ -754,7 +756,7 @@ class ResponseCollector {
|
|||||||
* Input Validations (400 Bad Request):
|
* Input Validations (400 Bad Request):
|
||||||
* - Either githubUrl OR projectPath must be provided (not neither)
|
* - Either githubUrl OR projectPath must be provided (not neither)
|
||||||
* - message must be non-empty string
|
* - message must be non-empty string
|
||||||
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
|
* - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'hermes'
|
||||||
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||||
* - branchName must pass Git naming rules (if provided)
|
* - branchName must pass Git naming rules (if provided)
|
||||||
*
|
*
|
||||||
@@ -862,8 +864,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'message is required' });
|
return res.status(400).json({ error: 'message is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'].includes(provider)) {
|
||||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", "opencode", or "hermes"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate GitHub branch/PR creation requirements
|
// Validate GitHub branch/PR creation requirements
|
||||||
@@ -996,6 +998,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
sessionId: sessionId || null,
|
sessionId: sessionId || null,
|
||||||
model: model || opencodeModels.DEFAULT
|
model: model || opencodeModels.DEFAULT
|
||||||
}, writer);
|
}, writer);
|
||||||
|
} else if (provider === 'hermes') {
|
||||||
|
console.log('Starting Hermes ACP session');
|
||||||
|
|
||||||
|
await spawnHermes(message.trim(), {
|
||||||
|
projectPath: finalProjectPath,
|
||||||
|
cwd: finalProjectPath,
|
||||||
|
sessionId: sessionId || null,
|
||||||
|
model: model === HERMES_CONFIGURED_MODEL ? undefined : model
|
||||||
|
}, writer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle GitHub branch and PR creation after successful agent completion
|
// Handle GitHub branch and PR creation after successful agent completion
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname);
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
|
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "hermes"];
|
||||||
|
|
||||||
const MODEL_PROVIDER_LABELS = {
|
const MODEL_PROVIDER_LABELS = {
|
||||||
claude: "Claude",
|
claude: "Claude",
|
||||||
@@ -23,6 +23,7 @@ const MODEL_PROVIDER_LABELS = {
|
|||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
gemini: "Gemini",
|
gemini: "Gemini",
|
||||||
opencode: "OpenCode",
|
opencode: "OpenCode",
|
||||||
|
hermes: "Hermes",
|
||||||
};
|
};
|
||||||
|
|
||||||
const readModelProvider = (value) => {
|
const readModelProvider = (value) => {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type {
|
|||||||
McpScope,
|
McpScope,
|
||||||
NormalizedMessage,
|
NormalizedMessage,
|
||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
|
ProviderSkillRegistryActionResult,
|
||||||
|
ProviderSkillRegistryInstallInput,
|
||||||
|
ProviderSkillRegistrySearchOptions,
|
||||||
|
ProviderSkillRegistrySearchResult,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
ProviderAuthStatus,
|
ProviderAuthStatus,
|
||||||
ProviderChangeActiveModelInput,
|
ProviderChangeActiveModelInput,
|
||||||
@@ -116,6 +120,21 @@ export interface IProviderSkills {
|
|||||||
removeSkill(
|
removeSkill(
|
||||||
input: ProviderSkillRemoveInput,
|
input: ProviderSkillRemoveInput,
|
||||||
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
|
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
|
||||||
|
|
||||||
|
searchRegistry?(
|
||||||
|
query: string,
|
||||||
|
options?: ProviderSkillRegistrySearchOptions,
|
||||||
|
): Promise<ProviderSkillRegistrySearchResult[]>;
|
||||||
|
|
||||||
|
installRegistrySkill?(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult>;
|
||||||
|
|
||||||
|
uninstallRegistrySkill?(name: string): Promise<ProviderSkillRegistryActionResult>;
|
||||||
|
|
||||||
|
checkRegistryUpdates?(): Promise<ProviderSkillRegistryActionResult>;
|
||||||
|
|
||||||
|
updateRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
|
||||||
|
|
||||||
|
auditRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
83
server/shared/tool-approval-registry.js
Normal file
83
server/shared/tool-approval-registry.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const pendingApprovals = new Map();
|
||||||
|
const APPROVAL_MAX_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
// Drop approvals whose run died without resolving them (WS disconnect, process
|
||||||
|
// crash) so their captured payloads/closures don't accumulate unbounded.
|
||||||
|
function sweepExpiredApprovals(now = Date.now()) {
|
||||||
|
for (const [requestId, entry] of pendingApprovals) {
|
||||||
|
const receivedAt = entry.receivedAt instanceof Date ? entry.receivedAt.getTime() : 0;
|
||||||
|
if (receivedAt && now - receivedAt > APPROVAL_MAX_AGE_MS) {
|
||||||
|
pendingApprovals.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApprovalsForSession(sessionId) {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [requestId, entry] of pendingApprovals) {
|
||||||
|
if (entry.sessionId === sessionId) {
|
||||||
|
pendingApprovals.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerApproval(requestId, { resolver, sessionId = null, provider = null, meta = {} } = {}) {
|
||||||
|
if (!requestId || typeof resolver !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sweepExpiredApprovals();
|
||||||
|
|
||||||
|
pendingApprovals.set(requestId, {
|
||||||
|
resolver,
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
meta,
|
||||||
|
receivedAt: meta.receivedAt || meta._receivedAt || new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterApproval(requestId) {
|
||||||
|
pendingApprovals.delete(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolApproval(requestId, decision) {
|
||||||
|
const entry = pendingApprovals.get(requestId);
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.resolver(decision);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingApprovalsForSession(sessionId) {
|
||||||
|
const pending = [];
|
||||||
|
for (const [requestId, entry] of pendingApprovals.entries()) {
|
||||||
|
if (entry.sessionId !== sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.push({
|
||||||
|
requestId,
|
||||||
|
toolName: entry.meta.toolName || entry.meta._toolName || 'UnknownTool',
|
||||||
|
input: entry.meta.input ?? entry.meta._input,
|
||||||
|
context: entry.meta.context ?? entry.meta._context,
|
||||||
|
sessionId,
|
||||||
|
provider: entry.provider,
|
||||||
|
receivedAt: entry.receivedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
registerApproval,
|
||||||
|
unregisterApproval,
|
||||||
|
resolveToolApproval,
|
||||||
|
getPendingApprovalsForSession,
|
||||||
|
clearApprovalsForSession,
|
||||||
|
};
|
||||||
@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
|||||||
* Use this as the source of truth whenever a function or payload needs to identify
|
* Use this as the source of truth whenever a function or payload needs to identify
|
||||||
* a specific LLM integration.
|
* a specific LLM integration.
|
||||||
*/
|
*/
|
||||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'hermes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One selectable model row in a provider model catalog.
|
* One selectable model row in a provider model catalog.
|
||||||
@@ -365,6 +365,32 @@ export type ProviderSkillRemoveInput = {
|
|||||||
directoryName: string;
|
directoryName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistrySearchOptions = {
|
||||||
|
source?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistrySearchResult = {
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
source?: string;
|
||||||
|
trustLevel?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistryInstallInput = {
|
||||||
|
identifier: string;
|
||||||
|
category?: string;
|
||||||
|
name?: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistryActionResult = {
|
||||||
|
ok: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalized skill record returned by provider skill adapters.
|
* Normalized skill record returned by provider skill adapters.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
|
|||||||
codexModel: string;
|
codexModel: string;
|
||||||
geminiModel: string;
|
geminiModel: string;
|
||||||
opencodeModel: string;
|
opencodeModel: string;
|
||||||
|
hermesModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canAbortSession: boolean;
|
canAbortSession: boolean;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
@@ -336,6 +337,8 @@ export function useChatComposerState({
|
|||||||
? geminiModel
|
? geminiModel
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? opencodeModel
|
? opencodeModel
|
||||||
|
: provider === 'hermes'
|
||||||
|
? undefined
|
||||||
: claudeModel,
|
: claudeModel,
|
||||||
tokenUsage: tokenBudget,
|
tokenUsage: tokenBudget,
|
||||||
};
|
};
|
||||||
@@ -703,6 +706,8 @@ export function useChatComposerState({
|
|||||||
? 'gemini-settings'
|
? 'gemini-settings'
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? 'opencode-settings'
|
? 'opencode-settings'
|
||||||
|
: provider === 'hermes'
|
||||||
|
? 'hermes-settings'
|
||||||
: 'claude-settings';
|
: 'claude-settings';
|
||||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
@@ -729,6 +734,8 @@ export function useChatComposerState({
|
|||||||
? geminiModel
|
? geminiModel
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? opencodeModel
|
? opencodeModel
|
||||||
|
: provider === 'hermes'
|
||||||
|
? undefined
|
||||||
: claudeModel;
|
: claudeModel;
|
||||||
|
|
||||||
// One message shape for every provider. The backend resolves the
|
// One message shape for every provider. The backend resolves the
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
|||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
gemini: 'gemini-3.1-pro-preview',
|
gemini: 'gemini-3.1-pro-preview',
|
||||||
opencode: 'anthropic/claude-sonnet-4-5',
|
opencode: 'anthropic/claude-sonnet-4-5',
|
||||||
|
hermes: '__hermes_configured_model__',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
|||||||
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||||
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
opencode: ['default'],
|
opencode: ['default'],
|
||||||
|
hermes: ['default'],
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderCapabilities = {
|
type ProviderCapabilities = {
|
||||||
@@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
|
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
|
||||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||||
});
|
});
|
||||||
|
const [hermesModel, setHermesModel] = useState<string>(() => {
|
||||||
|
return localStorage.getItem('hermes-model') || FALLBACK_DEFAULT_MODEL.hermes;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend-owned capability matrix keyed by provider. Drives the permission
|
* Backend-owned capability matrix keyed by provider. Drives the permission
|
||||||
@@ -141,12 +146,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenCodeModel(model);
|
if (targetProvider === 'opencode') {
|
||||||
localStorage.setItem('opencode-model', model);
|
setOpenCodeModel(model);
|
||||||
|
localStorage.setItem('opencode-model', model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProvider === 'hermes') {
|
||||||
|
setHermesModel(model);
|
||||||
|
localStorage.setItem('hermes-model', model);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
||||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||||
const requestId = providerModelsRequestIdRef.current + 1;
|
const requestId = providerModelsRequestIdRef.current + 1;
|
||||||
providerModelsRequestIdRef.current = requestId;
|
providerModelsRequestIdRef.current = requestId;
|
||||||
const isHardRefresh = options.bypassCache === true;
|
const isHardRefresh = options.bypassCache === true;
|
||||||
@@ -324,6 +337,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.opencode, opencodeModel]);
|
}, [providerModelCatalog.opencode, opencodeModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hermes = providerModelCatalog.hermes;
|
||||||
|
if (hermes) {
|
||||||
|
const next = pickStoredOrCurrent('hermes-model', hermesModel, hermes);
|
||||||
|
if (next !== hermesModel) {
|
||||||
|
setHermesModel(next);
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('hermes-model') !== next) {
|
||||||
|
localStorage.setItem('hermes-model', next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [providerModelCatalog.hermes, hermesModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession?.id) {
|
if (!selectedSession?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -391,6 +417,15 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
model: string,
|
model: string,
|
||||||
sessionId?: string | null,
|
sessionId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
|
if (targetProvider === 'hermes') {
|
||||||
|
setStoredProviderModel(targetProvider, model);
|
||||||
|
return {
|
||||||
|
scope: 'default' as const,
|
||||||
|
changed: false,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||||
if (!normalizedSessionId) {
|
if (!normalizedSessionId) {
|
||||||
setStoredProviderModel(targetProvider, model);
|
setStoredProviderModel(targetProvider, model);
|
||||||
@@ -434,6 +469,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
setOpenCodeModel,
|
setOpenCodeModel,
|
||||||
|
hermesModel,
|
||||||
|
setHermesModel,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
setPermissionMode,
|
setPermissionMode,
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ function ChatInterface({
|
|||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
setOpenCodeModel,
|
setOpenCodeModel,
|
||||||
|
hermesModel,
|
||||||
|
setHermesModel,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
@@ -201,6 +203,7 @@ function ChatInterface({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
|
hermesModel,
|
||||||
isLoading: isProcessing,
|
isLoading: isProcessing,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -293,7 +296,9 @@ function ChatInterface({
|
|||||||
? t('messageTypes.gemini')
|
? t('messageTypes.gemini')
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||||
: t('messageTypes.claude');
|
: provider === 'hermes'
|
||||||
|
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||||
|
: t('messageTypes.claude');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
@@ -334,6 +339,8 @@ function ChatInterface({
|
|||||||
setGeminiModel={setGeminiModel}
|
setGeminiModel={setGeminiModel}
|
||||||
opencodeModel={opencodeModel}
|
opencodeModel={opencodeModel}
|
||||||
setOpenCodeModel={setOpenCodeModel}
|
setOpenCodeModel={setOpenCodeModel}
|
||||||
|
hermesModel={hermesModel}
|
||||||
|
setHermesModel={setHermesModel}
|
||||||
providerModelCatalog={providerModelCatalog}
|
providerModelCatalog={providerModelCatalog}
|
||||||
providerModelsLoading={providerModelsLoading}
|
providerModelsLoading={providerModelsLoading}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
@@ -425,7 +432,9 @@ function ChatInterface({
|
|||||||
? t('messageTypes.gemini')
|
? t('messageTypes.gemini')
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||||
: t('messageTypes.claude'),
|
: provider === 'hermes'
|
||||||
|
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||||
|
: t('messageTypes.claude'),
|
||||||
})}
|
})}
|
||||||
isTextareaExpanded={isTextareaExpanded}
|
isTextareaExpanded={isTextareaExpanded}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
|
|||||||
setGeminiModel: (model: string) => void;
|
setGeminiModel: (model: string) => void;
|
||||||
opencodeModel: string;
|
opencodeModel: string;
|
||||||
setOpenCodeModel: (model: string) => void;
|
setOpenCodeModel: (model: string) => void;
|
||||||
|
hermesModel: string;
|
||||||
|
setHermesModel: (model: string) => void;
|
||||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||||
providerModelsLoading: boolean;
|
providerModelsLoading: boolean;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
@@ -89,6 +91,8 @@ function ChatMessagesPane({
|
|||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
setOpenCodeModel,
|
setOpenCodeModel,
|
||||||
|
hermesModel,
|
||||||
|
setHermesModel,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelsLoading,
|
providerModelsLoading,
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
@@ -177,6 +181,8 @@ function ChatMessagesPane({
|
|||||||
setGeminiModel={setGeminiModel}
|
setGeminiModel={setGeminiModel}
|
||||||
opencodeModel={opencodeModel}
|
opencodeModel={opencodeModel}
|
||||||
setOpenCodeModel={setOpenCodeModel}
|
setOpenCodeModel={setOpenCodeModel}
|
||||||
|
hermesModel={hermesModel}
|
||||||
|
setHermesModel={setHermesModel}
|
||||||
providerModelCatalog={providerModelCatalog}
|
providerModelCatalog={providerModelCatalog}
|
||||||
providerModelsLoading={providerModelsLoading}
|
providerModelsLoading={providerModelsLoading}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
|||||||
codex: 'Codex',
|
codex: 'Codex',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
opencode: 'OpenCode',
|
opencode: 'OpenCode',
|
||||||
|
hermes: 'Hermes',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
? t('messageTypes.gemini')
|
? t('messageTypes.gemini')
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||||
|
: provider === 'hermes'
|
||||||
|
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
|
||||||
: t('messageTypes.claude'))}
|
: t('messageTypes.claude'))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default MessageComponent;
|
export default MessageComponent;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
|
|||||||
{ id: "gemini", name: "Google" },
|
{ id: "gemini", name: "Google" },
|
||||||
{ id: "cursor", name: "Cursor" },
|
{ id: "cursor", name: "Cursor" },
|
||||||
{ id: "opencode", name: "OpenCode" },
|
{ id: "opencode", name: "OpenCode" },
|
||||||
|
{ id: "hermes", name: "Hermes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOD_KEY =
|
const MOD_KEY =
|
||||||
@@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = {
|
|||||||
setGeminiModel: (model: string) => void;
|
setGeminiModel: (model: string) => void;
|
||||||
opencodeModel: string;
|
opencodeModel: string;
|
||||||
setOpenCodeModel: (model: string) => void;
|
setOpenCodeModel: (model: string) => void;
|
||||||
|
hermesModel: string;
|
||||||
|
setHermesModel: (model: string) => void;
|
||||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||||
providerModelsLoading: boolean;
|
providerModelsLoading: boolean;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
@@ -79,11 +82,13 @@ function getCurrentModel(
|
|||||||
co: string,
|
co: string,
|
||||||
g: string,
|
g: string,
|
||||||
o: string,
|
o: string,
|
||||||
|
h: string,
|
||||||
) {
|
) {
|
||||||
if (p === "claude") return c;
|
if (p === "claude") return c;
|
||||||
if (p === "codex") return co;
|
if (p === "codex") return co;
|
||||||
if (p === "gemini") return g;
|
if (p === "gemini") return g;
|
||||||
if (p === "opencode") return o;
|
if (p === "opencode") return o;
|
||||||
|
if (p === "hermes") return h;
|
||||||
return cu;
|
return cu;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) {
|
|||||||
if (p === "cursor") return "Cursor";
|
if (p === "cursor") return "Cursor";
|
||||||
if (p === "codex") return "Codex";
|
if (p === "codex") return "Codex";
|
||||||
if (p === "opencode") return "OpenCode";
|
if (p === "opencode") return "OpenCode";
|
||||||
|
if (p === "hermes") return "Hermes";
|
||||||
return "Gemini";
|
return "Gemini";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
|
|||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
setOpenCodeModel,
|
setOpenCodeModel,
|
||||||
|
hermesModel,
|
||||||
|
setHermesModel,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelsLoading,
|
providerModelsLoading,
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
|
hermesModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentModelLabel = useMemo(() => {
|
const currentModelLabel = useMemo(() => {
|
||||||
@@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({
|
|||||||
} else if (providerId === "opencode") {
|
} else if (providerId === "opencode") {
|
||||||
setOpenCodeModel(modelValue);
|
setOpenCodeModel(modelValue);
|
||||||
localStorage.setItem("opencode-model", modelValue);
|
localStorage.setItem("opencode-model", modelValue);
|
||||||
|
} else if (providerId === "hermes") {
|
||||||
|
setHermesModel(modelValue);
|
||||||
|
localStorage.setItem("hermes-model", modelValue);
|
||||||
} else {
|
} else {
|
||||||
setCursorModel(modelValue);
|
setCursorModel(modelValue);
|
||||||
localStorage.setItem("cursor-model", modelValue);
|
localStorage.setItem("cursor-model", modelValue);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
|
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
@@ -277,15 +289,11 @@ export default function ProviderSelectionEmptyState({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate">{model.label}</div>
|
<div className="truncate">{model.label}</div>
|
||||||
{/*
|
{model.description && (
|
||||||
// * Temporarly commented out because the description of models from claude
|
|
||||||
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
|
||||||
*/}
|
|
||||||
{/* {model.description && (
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{model.description}
|
{model.description}
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||||
@@ -319,6 +327,10 @@ export default function ProviderSelectionEmptyState({
|
|||||||
model: opencodeModel,
|
model: opencodeModel,
|
||||||
defaultValue: "Ready with OpenCode {{model}}",
|
defaultValue: "Ready with OpenCode {{model}}",
|
||||||
}),
|
}),
|
||||||
|
hermes: t("providerSelection.readyPrompt.hermes", {
|
||||||
|
model: provider === "hermes" ? currentModelLabel : hermesModel,
|
||||||
|
defaultValue: "Ready with Hermes {{model}}",
|
||||||
|
}),
|
||||||
}[provider]
|
}[provider]
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { CodeEditorFile } from '../types/types';
|
import type { CodeEditorFile } from '../types/types';
|
||||||
import { isBinaryFile } from '../utils/binaryFile';
|
import { isBinaryFile } from '../utils/binaryFile';
|
||||||
|
import { getPreviewKind } from '../utils/previewableFile';
|
||||||
|
|
||||||
type UseCodeEditorDocumentParams = {
|
type UseCodeEditorDocumentParams = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
|
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||||
|
// editor shows an inline preview instead of the generic binary placeholder.
|
||||||
|
const previewKind = getPreviewKind(file.name);
|
||||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||||
// propagate the identifier.
|
// propagate the identifier.
|
||||||
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBinary(false);
|
setIsBinary(false);
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||||
|
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||||
|
// Clear any buffer left over from a previously opened text file so a
|
||||||
|
// stray save can't write stale content over the binary file.
|
||||||
|
if (getPreviewKind(file.name)) {
|
||||||
|
setContent('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file is binary by extension
|
// Check if file is binary by extension
|
||||||
if (isBinaryFile(file.name)) {
|
if (isBinaryFile(file.name)) {
|
||||||
|
setContent('');
|
||||||
setIsBinary(true);
|
setIsBinary(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
// Preview-only and binary files have no editable text buffer; never write
|
||||||
|
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||||
|
if (previewKind || isBinaryFile(fileName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath, fileProjectId]);
|
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Some binary files can't be edited as text, but the browser can still render
|
||||||
|
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||||
|
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||||
|
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||||
|
|
||||||
|
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||||
|
|
||||||
|
// Single source of truth: every extension the browser can preview, mapped to the
|
||||||
|
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||||
|
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||||
|
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||||
|
// absent and keep the binary fallback.
|
||||||
|
const EXTENSION_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif',
|
||||||
|
apng: 'image/apng',
|
||||||
|
// PDF
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Video
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogv: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/x-m4v',
|
||||||
|
// Audio
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
const kindForMime = (mime: string): PreviewKind | null => {
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||||
|
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||||
|
return mime ? kindForMime(mime) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||||
|
// Returns undefined for non-previewable extensions.
|
||||||
|
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||||
|
EXTENSION_MIME[extensionOf(filename)];
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||||
@@ -11,11 +12,13 @@ import type { CodeEditorFile } from '../types/types';
|
|||||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||||
import { getEditorStyles } from '../utils/editorStyles';
|
import { getEditorStyles } from '../utils/editorStyles';
|
||||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||||
|
|
||||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||||
|
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||||
|
|
||||||
type CodeEditorProps = {
|
type CodeEditorProps = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -58,6 +61,8 @@ export default function CodeEditor({
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
} = useCodeEditorDocument({
|
} = useCodeEditorDocument({
|
||||||
@@ -70,6 +75,29 @@ export default function CodeEditor({
|
|||||||
return extension === 'md' || extension === 'markdown';
|
return extension === 'md' || extension === 'markdown';
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
|
const isHtmlPreviewFile = useMemo(() => {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
return extension === 'html' || extension === 'htm';
|
||||||
|
}, [file.name]);
|
||||||
|
|
||||||
|
const openHtmlPreview = useCallback(() => {
|
||||||
|
const previewWindow = window.open('', '_blank');
|
||||||
|
if (!previewWindow) return;
|
||||||
|
|
||||||
|
previewWindow.opener = null;
|
||||||
|
previewWindow.document.title = file.name;
|
||||||
|
previewWindow.document.body.style.margin = '0';
|
||||||
|
|
||||||
|
const iframe = previewWindow.document.createElement('iframe');
|
||||||
|
iframe.title = file.name;
|
||||||
|
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||||
|
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||||
|
|
||||||
|
iframe.srcdoc = content;
|
||||||
|
|
||||||
|
previewWindow.document.body.appendChild(iframe);
|
||||||
|
}, [content, file.name]);
|
||||||
|
|
||||||
const minimapExtension = useMemo(
|
const minimapExtension = useMemo(
|
||||||
() => (
|
() => (
|
||||||
createMinimapExtension({
|
createMinimapExtension({
|
||||||
@@ -162,6 +190,30 @@ export default function CodeEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||||
|
// instead of showing the generic "cannot be displayed" placeholder.
|
||||||
|
if (previewKind) {
|
||||||
|
return (
|
||||||
|
<CodeEditorMediaPreview
|
||||||
|
file={file}
|
||||||
|
kind={previewKind}
|
||||||
|
projectId={fileProjectId}
|
||||||
|
isSidebar={isSidebar}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onClose={onClose}
|
||||||
|
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||||
|
labels={{
|
||||||
|
loading: t('filePreview.loading', 'Loading preview...'),
|
||||||
|
error: t('filePreview.error', 'Unable to display this file.'),
|
||||||
|
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||||
|
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||||
|
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||||
|
close: t('actions.close', 'Close'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Binary file display
|
// Binary file display
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
@@ -197,10 +249,12 @@ export default function CodeEditor({
|
|||||||
isSidebar={isSidebar}
|
isSidebar={isSidebar}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isMarkdownFile={isMarkdownFile}
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||||
markdownPreview={markdownPreview}
|
markdownPreview={markdownPreview}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveSuccess={saveSuccess}
|
saveSuccess={saveSuccess}
|
||||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||||
|
onOpenHtmlPreview={openHtmlPreview}
|
||||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -210,6 +264,7 @@ export default function CodeEditor({
|
|||||||
showingChanges: t('header.showingChanges'),
|
showingChanges: t('header.showingChanges'),
|
||||||
editMarkdown: t('actions.editMarkdown'),
|
editMarkdown: t('actions.editMarkdown'),
|
||||||
previewMarkdown: t('actions.previewMarkdown'),
|
previewMarkdown: t('actions.previewMarkdown'),
|
||||||
|
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||||
settings: t('toolbar.settings'),
|
settings: t('toolbar.settings'),
|
||||||
download: t('actions.download'),
|
download: t('actions.download'),
|
||||||
save: t('actions.save'),
|
save: t('actions.save'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
|
||||||
type CodeEditorHeaderProps = {
|
type CodeEditorHeaderProps = {
|
||||||
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
|
|||||||
isSidebar: boolean;
|
isSidebar: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isMarkdownFile: boolean;
|
isMarkdownFile: boolean;
|
||||||
|
isHtmlPreviewFile: boolean;
|
||||||
markdownPreview: boolean;
|
markdownPreview: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveSuccess: boolean;
|
saveSuccess: boolean;
|
||||||
onToggleMarkdownPreview: () => void;
|
onToggleMarkdownPreview: () => void;
|
||||||
|
onOpenHtmlPreview: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
|
|||||||
showingChanges: string;
|
showingChanges: string;
|
||||||
editMarkdown: string;
|
editMarkdown: string;
|
||||||
previewMarkdown: string;
|
previewMarkdown: string;
|
||||||
|
previewHtml: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
download: string;
|
download: string;
|
||||||
save: string;
|
save: string;
|
||||||
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
|
|||||||
isSidebar,
|
isSidebar,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isMarkdownFile,
|
isMarkdownFile,
|
||||||
|
isHtmlPreviewFile,
|
||||||
markdownPreview,
|
markdownPreview,
|
||||||
saving,
|
saving,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
onToggleMarkdownPreview,
|
onToggleMarkdownPreview,
|
||||||
|
onOpenHtmlPreview,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isHtmlPreviewFile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenHtmlPreview}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
title={labels.previewHtml}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { authenticatedFetch } from '../../../../utils/api';
|
||||||
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||||
|
|
||||||
|
type CodeEditorMediaPreviewProps = {
|
||||||
|
file: CodeEditorFile;
|
||||||
|
kind: PreviewKind;
|
||||||
|
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||||
|
// for older callers, mirroring useCodeEditorDocument.
|
||||||
|
projectId?: string;
|
||||||
|
isSidebar: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
labels: {
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
openInNewTab: string;
|
||||||
|
fullscreen: string;
|
||||||
|
exitFullscreen: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||||
|
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||||
|
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||||
|
|
||||||
|
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||||
|
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||||
|
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||||
|
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodeEditorMediaPreview({
|
||||||
|
file,
|
||||||
|
kind,
|
||||||
|
projectId,
|
||||||
|
isSidebar,
|
||||||
|
isFullscreen,
|
||||||
|
onClose,
|
||||||
|
onToggleFullscreen,
|
||||||
|
labels,
|
||||||
|
}: CodeEditorMediaPreviewProps) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||||
|
// this so a blob from a previously-opened file can never show under the new
|
||||||
|
// file (the editor reuses this component instance across files).
|
||||||
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||||
|
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setUrl(null);
|
||||||
|
setLoadedKey(null);
|
||||||
|
setError(labels.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setUrl(null);
|
||||||
|
|
||||||
|
// The content endpoint requires the auth header, so we fetch the bytes
|
||||||
|
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||||
|
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||||
|
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||||
|
// Content-Type from the server, but supply an extension-specific
|
||||||
|
// default when it is missing or generic (application/octet-stream),
|
||||||
|
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||||
|
const fallbackMime = getPreviewMimeType(file.name);
|
||||||
|
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||||
|
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||||
|
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||||
|
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||||
|
// really a PDF and pin the type to application/pdf. That forces the
|
||||||
|
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||||
|
// executing scripts in the app's origin.
|
||||||
|
if (!(await looksLikePdf(blob))) {
|
||||||
|
throw new Error('File is not a valid PDF');
|
||||||
|
}
|
||||||
|
outType = 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||||
|
objectUrl = URL.createObjectURL(typed);
|
||||||
|
|
||||||
|
// The cleanup may have already run (deps changed during an await), in
|
||||||
|
// which case it revoked nothing because objectUrl was still null. Don't
|
||||||
|
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setLoadedKey(sourceKey);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading preview:', loadError);
|
||||||
|
setError(labels.error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMedia();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||||
|
|
||||||
|
// Only expose the blob once it matches the file currently being shown, so a
|
||||||
|
// stale URL from the previous file is never rendered during a switch.
|
||||||
|
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||||
|
|
||||||
|
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||||
|
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||||
|
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||||
|
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||||
|
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||||
|
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
|
if (!currentUrl) return null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'pdf':
|
||||||
|
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||||
|
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||||
|
// viewer). Script execution is instead prevented upstream by validating
|
||||||
|
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||||
|
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||||
|
{labels.error}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||||
|
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||||
|
<audio src={currentUrl} controls className="w-full">
|
||||||
|
{labels.error}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewBody = (
|
||||||
|
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentUrl && renderMedia()}
|
||||||
|
|
||||||
|
{!loading && !currentUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">{error || labels.error}</p>
|
||||||
|
<p className="break-all text-xs">{file.path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{canOpenInNewTab && currentUrl && (
|
||||||
|
<a
|
||||||
|
href={currentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.openInNewTab}
|
||||||
|
title={labels.openInNewTab}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!isSidebar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.close}
|
||||||
|
title={labels.close}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSidebar) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClassName = isFullscreen
|
||||||
|
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||||
|
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||||
|
|
||||||
|
const innerClassName = isFullscreen
|
||||||
|
? 'bg-background flex flex-col w-full h-full'
|
||||||
|
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className={innerClassName}>
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
14
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type HermesLogoProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={`${className} block object-contain`}
|
||||||
|
src="/icons/hermes-agent.png"
|
||||||
|
alt="Hermes"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
|
|||||||
import CodexLogo from './CodexLogo';
|
import CodexLogo from './CodexLogo';
|
||||||
import CursorLogo from './CursorLogo';
|
import CursorLogo from './CursorLogo';
|
||||||
import GeminiLogo from './GeminiLogo';
|
import GeminiLogo from './GeminiLogo';
|
||||||
|
import HermesLogo from './HermesLogo';
|
||||||
import OpenCodeLogo from './OpenCodeLogo';
|
import OpenCodeLogo from './OpenCodeLogo';
|
||||||
|
|
||||||
type SessionProviderLogoProps = {
|
type SessionProviderLogoProps = {
|
||||||
@@ -30,5 +31,9 @@ export default function SessionProviderLogo({
|
|||||||
return <OpenCodeLogo className={className} />;
|
return <OpenCodeLogo className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'hermes') {
|
||||||
|
return <HermesLogo className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <ClaudeLogo className={className} />;
|
return <ClaudeLogo className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
|||||||
codex: 'Codex',
|
codex: 'Codex',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
opencode: 'OpenCode',
|
opencode: 'OpenCode',
|
||||||
|
hermes: 'Hermes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||||
@@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
|||||||
codex: ['user', 'project'],
|
codex: ['user', 'project'],
|
||||||
gemini: ['user', 'project'],
|
gemini: ['user', 'project'],
|
||||||
opencode: ['user', 'project'],
|
opencode: ['user', 'project'],
|
||||||
|
hermes: ['user', 'project'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||||
@@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
|||||||
codex: ['stdio', 'http'],
|
codex: ['stdio', 'http'],
|
||||||
gemini: ['stdio', 'http', 'sse'],
|
gemini: ['stdio', 'http', 'sse'],
|
||||||
opencode: ['stdio', 'http'],
|
opencode: ['stdio', 'http'],
|
||||||
|
hermes: ['stdio', 'http'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
||||||
@@ -34,6 +37,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
|||||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||||
|
hermes: 'bg-emerald-700 text-white hover:bg-emerald-800 dark:bg-emerald-600 dark:hover:bg-emerald-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||||
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
|||||||
codex: true,
|
codex: true,
|
||||||
gemini: true,
|
gemini: true,
|
||||||
opencode: false,
|
opencode: false,
|
||||||
|
hermes: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
type ProviderAuthStatusPayload = {
|
type ProviderAuthStatusPayload = {
|
||||||
|
installed?: boolean;
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
method?: string | null;
|
method?: string | null;
|
||||||
@@ -34,6 +35,7 @@ const toProviderAuthStatus = (
|
|||||||
payload: ProviderAuthStatusPayload,
|
payload: ProviderAuthStatusPayload,
|
||||||
fallbackError: string | null = null,
|
fallbackError: string | null = null,
|
||||||
): ProviderAuthStatus => ({
|
): ProviderAuthStatus => ({
|
||||||
|
installed: Boolean(payload.installed),
|
||||||
authenticated: Boolean(payload.authenticated),
|
authenticated: Boolean(payload.authenticated),
|
||||||
email: payload.email ?? null,
|
email: payload.email ?? null,
|
||||||
method: payload.method ?? null,
|
method: payload.method ?? null,
|
||||||
@@ -78,6 +80,7 @@ export function useProviderAuthStatus(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const status: ProviderAuthStatus = {
|
const status: ProviderAuthStatus = {
|
||||||
|
installed: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
method: null,
|
method: null,
|
||||||
@@ -95,6 +98,7 @@ export function useProviderAuthStatus(
|
|||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
console.error(`Error checking ${provider} auth status:`, caughtError);
|
console.error(`Error checking ${provider} auth status:`, caughtError);
|
||||||
const status: ProviderAuthStatus = {
|
const status: ProviderAuthStatus = {
|
||||||
|
installed: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
method: null,
|
method: null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LLMProvider } from '../../types/app';
|
import type { LLMProvider } from '../../types/app';
|
||||||
|
|
||||||
export type ProviderAuthStatus = {
|
export type ProviderAuthStatus = {
|
||||||
|
installed: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
method: string | null;
|
method: string | null;
|
||||||
@@ -10,7 +11,7 @@ export type ProviderAuthStatus = {
|
|||||||
|
|
||||||
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||||
|
|
||||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||||
|
|
||||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||||
claude: '/api/providers/claude/auth/status',
|
claude: '/api/providers/claude/auth/status',
|
||||||
@@ -18,12 +19,14 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
|||||||
codex: '/api/providers/codex/auth/status',
|
codex: '/api/providers/codex/auth/status',
|
||||||
gemini: '/api/providers/gemini/auth/status',
|
gemini: '/api/providers/gemini/auth/status',
|
||||||
opencode: '/api/providers/opencode/auth/status',
|
opencode: '/api/providers/opencode/auth/status',
|
||||||
|
hermes: '/api/providers/hermes/auth/status',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||||
claude: { authenticated: false, email: null, method: null, error: null, loading },
|
claude: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
cursor: { authenticated: false, email: null, method: null, error: null, loading },
|
cursor: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
codex: { authenticated: false, email: null, method: null, error: null, loading },
|
codex: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
gemini: { authenticated: false, email: null, method: null, error: null, loading },
|
gemini: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
opencode: { authenticated: false, email: null, method: null, error: null, loading },
|
opencode: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
|
hermes: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type ProviderLoginModalProps = {
|
|||||||
provider?: LLMProvider;
|
provider?: LLMProvider;
|
||||||
onComplete?: (exitCode: number) => void;
|
onComplete?: (exitCode: number) => void;
|
||||||
customCommand?: string;
|
customCommand?: string;
|
||||||
|
customTitle?: string;
|
||||||
isAuthenticated?: boolean;
|
isAuthenticated?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ const getProviderCommand = ({
|
|||||||
return 'opencode auth login';
|
return 'opencode auth login';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'hermes') {
|
||||||
|
return 'hermes model';
|
||||||
|
}
|
||||||
|
|
||||||
return 'gemini status';
|
return 'gemini status';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ const getProviderTitle = (provider: LLMProvider) => {
|
|||||||
if (provider === 'cursor') return 'Cursor CLI Login';
|
if (provider === 'cursor') return 'Cursor CLI Login';
|
||||||
if (provider === 'codex') return 'Codex CLI Login';
|
if (provider === 'codex') return 'Codex CLI Login';
|
||||||
if (provider === 'opencode') return 'OpenCode CLI Login';
|
if (provider === 'opencode') return 'OpenCode CLI Login';
|
||||||
|
if (provider === 'hermes') return 'Hermes Agent Setup';
|
||||||
return 'Gemini CLI Configuration';
|
return 'Gemini CLI Configuration';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ export default function ProviderLoginModal({
|
|||||||
provider = 'claude',
|
provider = 'claude',
|
||||||
onComplete,
|
onComplete,
|
||||||
customCommand,
|
customCommand,
|
||||||
|
customTitle,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
}: ProviderLoginModalProps) {
|
}: ProviderLoginModalProps) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -65,7 +72,7 @@ export default function ProviderLoginModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
|
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
|
||||||
const title = getProviderTitle(provider);
|
const title = customTitle || getProviderTitle(provider);
|
||||||
|
|
||||||
const handleComplete = (exitCode: number) => {
|
const handleComplete = (exitCode: number) => {
|
||||||
onComplete?.(exitCode);
|
onComplete?.(exitCode);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
|
|||||||
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||||
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
||||||
|
const [loginCommand, setLoginCommand] = useState<string | undefined>(undefined);
|
||||||
|
const [loginTitle, setLoginTitle] = useState<string | undefined>(undefined);
|
||||||
const {
|
const {
|
||||||
providerAuthStatus,
|
providerAuthStatus,
|
||||||
checkProviderAuthStatus,
|
checkProviderAuthStatus,
|
||||||
@@ -231,8 +233,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
const openLoginForProvider = useCallback((provider: AgentProvider, customCommand?: string, customTitle?: string) => {
|
||||||
setLoginProvider(provider);
|
setLoginProvider(provider);
|
||||||
|
setLoginCommand(customCommand);
|
||||||
|
setLoginTitle(customTitle);
|
||||||
setShowLoginModal(true);
|
setShowLoginModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -417,6 +421,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
showLoginModal,
|
showLoginModal,
|
||||||
setShowLoginModal,
|
setShowLoginModal,
|
||||||
loginProvider,
|
loginProvider,
|
||||||
|
loginCommand,
|
||||||
|
loginTitle,
|
||||||
handleLoginComplete,
|
handleLoginComplete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
showLoginModal,
|
showLoginModal,
|
||||||
setShowLoginModal,
|
setShowLoginModal,
|
||||||
loginProvider,
|
loginProvider,
|
||||||
|
loginCommand,
|
||||||
|
loginTitle,
|
||||||
handleLoginComplete,
|
handleLoginComplete,
|
||||||
} = useSettingsController({
|
} = useSettingsController({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -232,6 +234,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
onClose={() => setShowLoginModal(false)}
|
onClose={() => setShowLoginModal(false)}
|
||||||
provider={loginProvider || 'claude'}
|
provider={loginProvider || 'claude'}
|
||||||
onComplete={handleLoginComplete}
|
onComplete={handleLoginComplete}
|
||||||
|
customCommand={loginCommand}
|
||||||
|
customTitle={loginTitle}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
|||||||
|
|
||||||
type AgentConfig = {
|
type AgentConfig = {
|
||||||
name: string;
|
name: string;
|
||||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
|
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'emerald';
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||||
@@ -36,6 +36,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
|||||||
name: 'OpenCode',
|
name: 'OpenCode',
|
||||||
color: 'zinc',
|
color: 'zinc',
|
||||||
},
|
},
|
||||||
|
hermes: {
|
||||||
|
name: 'Hermes',
|
||||||
|
color: 'emerald',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
@@ -54,6 +58,9 @@ const colorClasses = {
|
|||||||
zinc: {
|
zinc: {
|
||||||
dot: 'bg-zinc-500',
|
dot: 'bg-zinc-500',
|
||||||
},
|
},
|
||||||
|
emerald: {
|
||||||
|
dot: 'bg-emerald-600',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function AgentListItem({
|
export default function AgentListItem({
|
||||||
@@ -65,6 +72,7 @@ export default function AgentListItem({
|
|||||||
}: AgentListItemProps) {
|
}: AgentListItemProps) {
|
||||||
const config = agentConfig[agentId];
|
const config = agentConfig[agentId];
|
||||||
const colors = colorClasses[config.color];
|
const colors = colorClasses[config.color];
|
||||||
|
const isReady = agentId !== 'hermes' && authStatus.authenticated;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -80,7 +88,7 @@ export default function AgentListItem({
|
|||||||
<div className="flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate text-xs font-medium">{config.name}</span>
|
<span className="truncate text-xs font-medium">{config.name}</span>
|
||||||
{authStatus.authenticated && (
|
{isReady && (
|
||||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,10 +108,10 @@ export default function AgentListItem({
|
|||||||
>
|
>
|
||||||
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>{config.name}</span>
|
<span>{config.name}</span>
|
||||||
{authStatus.authenticated ? (
|
{isReady ? (
|
||||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||||
) : authStatus.loading ? (
|
) : authStatus.loading ? (
|
||||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
|
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-muted-foreground/30" />
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,29 +29,33 @@ export default function AgentsSettingsTab({
|
|||||||
), [selectedAgent]);
|
), [selectedAgent]);
|
||||||
|
|
||||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||||
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||||
claude: {
|
claude: {
|
||||||
authStatus: providerAuthStatus.claude,
|
authStatus: providerAuthStatus.claude,
|
||||||
onLogin: () => onProviderLogin('claude'),
|
onLogin: (customCommand, customTitle) => onProviderLogin('claude', customCommand, customTitle),
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
authStatus: providerAuthStatus.cursor,
|
authStatus: providerAuthStatus.cursor,
|
||||||
onLogin: () => onProviderLogin('cursor'),
|
onLogin: (customCommand, customTitle) => onProviderLogin('cursor', customCommand, customTitle),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
authStatus: providerAuthStatus.codex,
|
authStatus: providerAuthStatus.codex,
|
||||||
onLogin: () => onProviderLogin('codex'),
|
onLogin: (customCommand, customTitle) => onProviderLogin('codex', customCommand, customTitle),
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
authStatus: providerAuthStatus.gemini,
|
authStatus: providerAuthStatus.gemini,
|
||||||
onLogin: () => onProviderLogin('gemini'),
|
onLogin: (customCommand, customTitle) => onProviderLogin('gemini', customCommand, customTitle),
|
||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
authStatus: providerAuthStatus.opencode,
|
authStatus: providerAuthStatus.opencode,
|
||||||
onLogin: () => onProviderLogin('opencode'),
|
onLogin: (customCommand, customTitle) => onProviderLogin('opencode', customCommand, customTitle),
|
||||||
|
},
|
||||||
|
hermes: {
|
||||||
|
authStatus: providerAuthStatus.hermes,
|
||||||
|
onLogin: (customCommand, customTitle) => onProviderLogin('hermes', customCommand, customTitle),
|
||||||
},
|
},
|
||||||
}), [
|
}), [
|
||||||
onProviderLogin,
|
onProviderLogin,
|
||||||
@@ -60,6 +64,7 @@ export default function AgentsSettingsTab({
|
|||||||
providerAuthStatus.cursor,
|
providerAuthStatus.cursor,
|
||||||
providerAuthStatus.gemini,
|
providerAuthStatus.gemini,
|
||||||
providerAuthStatus.opencode,
|
providerAuthStatus.opencode,
|
||||||
|
providerAuthStatus.hermes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
|||||||
codex: 'Codex',
|
codex: 'Codex',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
opencode: 'OpenCode',
|
opencode: 'OpenCode',
|
||||||
|
hermes: 'Hermes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AgentSelectorSection({
|
export default function AgentSelectorSection({
|
||||||
@@ -25,7 +26,8 @@ export default function AgentSelectorSection({
|
|||||||
agent === 'claude' ? 'bg-blue-500' :
|
agent === 'claude' ? 'bg-blue-500' :
|
||||||
agent === 'cursor' ? 'bg-purple-500' :
|
agent === 'cursor' ? 'bg-purple-500' :
|
||||||
agent === 'gemini' ? 'bg-indigo-500' :
|
agent === 'gemini' ? 'bg-indigo-500' :
|
||||||
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
|
agent === 'opencode' ? 'bg-zinc-500' :
|
||||||
|
agent === 'hermes' ? 'bg-emerald-600' : 'bg-foreground/60';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pill
|
<Pill
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { LogIn } from 'lucide-react';
|
import {
|
||||||
|
KeyRound,
|
||||||
|
Layers3,
|
||||||
|
LogIn,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Badge, Button } from '../../../../../../../shared/view/ui';
|
import { Badge, Button } from '../../../../../../../shared/view/ui';
|
||||||
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
|
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
|
||||||
@@ -7,7 +13,7 @@ import type { AgentProvider, AuthStatus } from '../../../../../types/types';
|
|||||||
type AccountContentProps = {
|
type AccountContentProps = {
|
||||||
agent: AgentProvider;
|
agent: AgentProvider;
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
onLogin: () => void;
|
onLogin: (customCommand?: string, customTitle?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AgentVisualConfig = {
|
type AgentVisualConfig = {
|
||||||
@@ -63,11 +69,49 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
|||||||
subtextClass: 'text-zinc-700 dark:text-zinc-300',
|
subtextClass: 'text-zinc-700 dark:text-zinc-300',
|
||||||
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||||
},
|
},
|
||||||
|
hermes: {
|
||||||
|
name: 'Hermes',
|
||||||
|
description: 'Nous Research Hermes Agent',
|
||||||
|
bgClass: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||||
|
borderClass: 'border-emerald-200 dark:border-emerald-800',
|
||||||
|
textClass: 'text-emerald-950 dark:text-emerald-100',
|
||||||
|
subtextClass: 'text-emerald-700 dark:text-emerald-300',
|
||||||
|
buttonClass: 'bg-emerald-700 hover:bg-emerald-800 active:bg-emerald-900',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HermesAction = {
|
||||||
|
label: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
description: string;
|
||||||
|
command: string;
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hermesActions: HermesAction[] = [
|
||||||
|
{
|
||||||
|
label: 'Model',
|
||||||
|
buttonLabel: 'Configure',
|
||||||
|
description: 'Choose the provider and model Hermes should use.',
|
||||||
|
command: 'hermes model',
|
||||||
|
title: 'Configure Hermes Model',
|
||||||
|
icon: Layers3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Credentials',
|
||||||
|
buttonLabel: 'Manage',
|
||||||
|
description: 'Update credential pools and API keys.',
|
||||||
|
command: 'hermes auth',
|
||||||
|
title: 'Hermes Credentials',
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const config = agentConfig[agent];
|
const config = agentConfig[agent];
|
||||||
|
const isHermes = agent === 'hermes';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -84,74 +128,121 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
||||||
<div className="space-y-4">
|
{isHermes ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-4">
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<div className={`font-medium ${config.textClass}`}>
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
{t('agents.connectionStatus')}
|
{t('agents.hermes.configuration.title', { defaultValue: 'Hermes configuration' })}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-sm ${config.subtextClass}`}>
|
<div className={`text-sm ${config.subtextClass}`}>
|
||||||
|
{t('agents.hermes.configuration.description', {
|
||||||
|
defaultValue: 'Models and credentials are managed by Hermes.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border/60 bg-background/70">
|
||||||
|
{hermesActions.map((action, index) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.command}
|
||||||
|
className={`flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between ${
|
||||||
|
index > 0 ? 'border-t border-border/60' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">{action.label}</div>
|
||||||
|
<div className="mt-0.5 text-sm text-muted-foreground">{action.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={index === 0 ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={index === 0 ? `${config.buttonClass} w-full text-white sm:w-auto` : 'w-full sm:w-auto'}
|
||||||
|
onClick={() => onLogin(action.command, action.title)}
|
||||||
|
>
|
||||||
|
{action.buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
|
{t('agents.connectionStatus')}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${config.subtextClass}`}>
|
||||||
|
{authStatus.loading ? (
|
||||||
|
t('agents.authStatus.checkingAuth')
|
||||||
|
) : authStatus.authenticated ? (
|
||||||
|
t('agents.authStatus.loggedInAs', {
|
||||||
|
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
t('agents.authStatus.notConnected')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
{authStatus.loading ? (
|
{authStatus.loading ? (
|
||||||
t('agents.authStatus.checkingAuth')
|
<Badge variant="secondary" className="bg-muted">
|
||||||
|
{t('agents.authStatus.checking')}
|
||||||
|
</Badge>
|
||||||
) : authStatus.authenticated ? (
|
) : authStatus.authenticated ? (
|
||||||
t('agents.authStatus.loggedInAs', {
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
|
{t('agents.authStatus.connected')}
|
||||||
})
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
t('agents.authStatus.notConnected')
|
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{t('agents.authStatus.disconnected')}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
{authStatus.loading ? (
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{t('agents.authStatus.checking')}
|
|
||||||
</Badge>
|
|
||||||
) : authStatus.authenticated ? (
|
|
||||||
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
|
||||||
{t('agents.authStatus.connected')}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
|
||||||
{t('agents.authStatus.disconnected')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authStatus.method !== 'api_key' && (
|
{authStatus.method !== 'api_key' && (
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className={`font-medium ${config.textClass}`}>
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-sm ${config.subtextClass}`}>
|
<div className={`text-sm ${config.subtextClass}`}>
|
||||||
{authStatus.authenticated
|
{authStatus.authenticated
|
||||||
? t('agents.login.reAuthDescription')
|
? t('agents.login.reAuthDescription')
|
||||||
: t('agents.login.description', { agent: config.name })}
|
: t('agents.login.description', { agent: config.name })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => onLogin()}
|
||||||
|
className={`${config.buttonClass} text-white`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
|
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={onLogin}
|
|
||||||
className={`${config.buttonClass} text-white`}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<LogIn className="mr-2 h-4 w-4" />
|
|
||||||
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{authStatus.error && (
|
{authStatus.error && (
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<div className="text-sm text-red-600 dark:text-red-400">
|
<div className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t('agents.error', { error: authStatus.error })}
|
{t('agents.error', { error: authStatus.error })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
|
|
||||||
export type AgentContext = {
|
export type AgentContext = {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
onLogin: () => void;
|
onLogin: (customCommand?: string, customTitle?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
|
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
|
||||||
@@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
|
|||||||
|
|
||||||
export type AgentsSettingsTabProps = {
|
export type AgentsSettingsTabProps = {
|
||||||
providerAuthStatus: ProviderAuthStatusByProvider;
|
providerAuthStatus: ProviderAuthStatusByProvider;
|
||||||
onProviderLogin: (provider: AgentProvider) => void;
|
onProviderLogin: (provider: AgentProvider, customCommand?: string, customTitle?: string) => void;
|
||||||
claudePermissions: ClaudePermissionsState;
|
claudePermissions: ClaudePermissionsState;
|
||||||
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
||||||
cursorPermissions: CursorPermissionsState;
|
cursorPermissions: CursorPermissionsState;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ITerminalOptions } from '@xterm/xterm';
|
import type { ITerminalOptions } from '@xterm/xterm';
|
||||||
|
|
||||||
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
|
||||||
export const SHELL_RESTART_DELAY_MS = 200;
|
export const SHELL_RESTART_DELAY_MS = 200;
|
||||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
clearTerminalScreen: () => void;
|
clearTerminalScreen: () => void;
|
||||||
setAuthUrl: (nextAuthUrl: string) => void;
|
|
||||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function useShellConnection({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -100,14 +98,8 @@ export function useShellConnection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'auth_url' || message.type === 'url_open') {
|
|
||||||
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
|
||||||
if (nextAuthUrl) {
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
[handleProcessCompletion, onOutputRef, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
const connectWebSocket = useCallback(
|
||||||
@@ -133,7 +125,6 @@ export function useShellConnection({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
setAuthUrl('');
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const currentTerminal = terminalRef.current;
|
const currentTerminal = terminalRef.current;
|
||||||
@@ -196,7 +187,6 @@ export function useShellConnection({
|
|||||||
isPlainShellRef,
|
isPlainShellRef,
|
||||||
selectedProjectRef,
|
selectedProjectRef,
|
||||||
selectedSessionRef,
|
selectedSessionRef,
|
||||||
setAuthUrl,
|
|
||||||
terminalRef,
|
terminalRef,
|
||||||
wsRef,
|
wsRef,
|
||||||
],
|
],
|
||||||
@@ -225,8 +215,7 @@ export function useShellConnection({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
forceRestartOnInitRef.current = false;
|
||||||
setAuthUrl('');
|
}, [clearTerminalScreen, closeSocket]);
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
||||||
import { useShellConnection } from './useShellConnection';
|
import { useShellConnection } from './useShellConnection';
|
||||||
import { useShellTerminal } from './useShellTerminal';
|
import { useShellTerminal } from './useShellTerminal';
|
||||||
|
|
||||||
@@ -22,15 +23,11 @@ export function useShellRuntime({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const [authUrl, setAuthUrl] = useState('');
|
|
||||||
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
const initialCommandRef = useRef(initialCommand);
|
const initialCommandRef = useRef(initialCommand);
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
const authUrlRef = useRef('');
|
|
||||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||||
|
|
||||||
// Keep mutable values in refs so websocket handlers always read current data.
|
// Keep mutable values in refs so websocket handlers always read current data.
|
||||||
@@ -42,12 +39,6 @@ export function useShellRuntime({
|
|||||||
onProcessCompleteRef.current = onProcessComplete;
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||||
|
|
||||||
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
|
||||||
authUrlRef.current = nextAuthUrl;
|
|
||||||
setAuthUrl(nextAuthUrl);
|
|
||||||
setAuthUrlVersion((previous) => previous + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const activeSocket = wsRef.current;
|
const activeSocket = wsRef.current;
|
||||||
if (!activeSocket) {
|
if (!activeSocket) {
|
||||||
@@ -64,32 +55,6 @@ export function useShellRuntime({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popup = window.open(url, '_blank');
|
|
||||||
if (popup) {
|
|
||||||
try {
|
|
||||||
popup.opener = null;
|
|
||||||
} catch {
|
|
||||||
// Ignore cross-origin restrictions when trying to null opener.
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return copyTextToClipboard(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -98,10 +63,6 @@ export function useShellRuntime({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +79,6 @@ export function useShellRuntime({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
setAuthUrl: setCurrentAuthUrl,
|
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,11 +116,7 @@ export function useShellRuntime({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
CODEX_DEVICE_AUTH_URL,
|
|
||||||
TERMINAL_INIT_DELAY_MS,
|
TERMINAL_INIT_DELAY_MS,
|
||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
import {
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
installMobileTerminalSelection,
|
||||||
|
type MobileTerminalSelectionManager,
|
||||||
|
} from '../utils/mobileTerminalSelection';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
|
|
||||||
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
|
|||||||
selectedProject: Project | null | undefined;
|
selectedProject: Project | null | undefined;
|
||||||
minimal: boolean;
|
minimal: boolean;
|
||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
|
||||||
isPlainShellRef: MutableRefObject<boolean>;
|
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,14 +44,11 @@ export function useShellTerminal({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
authUrlRef,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const resizeTimeoutRef = useRef<number | null>(null);
|
const resizeTimeoutRef = useRef<number | null>(null);
|
||||||
|
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
const hasSelectedProject = Boolean(selectedProject);
|
||||||
|
|
||||||
@@ -70,6 +66,11 @@ export function useShellTerminal({
|
|||||||
}, [terminalRef]);
|
}, [terminalRef]);
|
||||||
|
|
||||||
const disposeTerminal = useCallback(() => {
|
const disposeTerminal = useCallback(() => {
|
||||||
|
if (mobileSelectionRef.current) {
|
||||||
|
mobileSelectionRef.current.dispose();
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.dispose();
|
terminalRef.current.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -80,7 +81,8 @@ export function useShellTerminal({
|
|||||||
}, [fitAddonRef, terminalRef]);
|
}, [fitAddonRef, terminalRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
const terminalContainer = terminalContainerRef.current;
|
||||||
|
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,28 @@ export function useShellTerminal({
|
|||||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
nextTerminal.open(terminalContainer);
|
||||||
|
mobileSelectionRef.current = installMobileTerminalSelection(
|
||||||
|
nextTerminal,
|
||||||
|
terminalContainer,
|
||||||
|
{
|
||||||
|
onFontSizeChange: (fontSize) => {
|
||||||
|
nextTerminal.options.fontSize = fontSize;
|
||||||
|
|
||||||
|
const currentFitAddon = fitAddonRef.current;
|
||||||
|
if (currentFitAddon) {
|
||||||
|
currentFitAddon.fit();
|
||||||
|
sendSocketMessage(wsRef.current, {
|
||||||
|
type: 'resize',
|
||||||
|
cols: nextTerminal.cols,
|
||||||
|
rows: nextTerminal.rows,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextTerminal.refresh(0, nextTerminal.rows - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const copyTerminalSelection = async () => {
|
const copyTerminalSelection = async () => {
|
||||||
const selection = nextTerminal.getSelection();
|
const selection = nextTerminal.getSelection();
|
||||||
@@ -133,29 +156,9 @@ export function useShellTerminal({
|
|||||||
void copyTextToClipboard(selection);
|
void copyTextToClipboard(selection);
|
||||||
};
|
};
|
||||||
|
|
||||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
|
||||||
? CODEX_DEVICE_AUTH_URL
|
|
||||||
: authUrlRef.current;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === 'keydown' &&
|
|
||||||
minimal &&
|
|
||||||
isPlainShellRef.current &&
|
|
||||||
activeAuthUrl &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
event.key?.toLowerCase() === 'c'
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void copyAuthUrlToClipboard(activeAuthUrl);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === 'keydown' &&
|
event.type === 'keydown' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -240,10 +243,10 @@ export function useShellTerminal({
|
|||||||
}, TERMINAL_RESIZE_DELAY_MS);
|
}, TERMINAL_RESIZE_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
@@ -254,16 +257,12 @@ export function useShellTerminal({
|
|||||||
disposeTerminal();
|
disposeTerminal();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
authUrlRef,
|
|
||||||
closeSocket,
|
closeSocket,
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
disposeTerminal,
|
disposeTerminal,
|
||||||
fitAddonRef,
|
fitAddonRef,
|
||||||
initialCommandRef,
|
|
||||||
isPlainShellRef,
|
|
||||||
isRestarting,
|
isRestarting,
|
||||||
minimal,
|
|
||||||
hasSelectedProject,
|
hasSelectedProject,
|
||||||
|
minimal,
|
||||||
selectedProjectKey,
|
selectedProjectKey,
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
|
|||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
|
||||||
|
|
||||||
export type ShellInitMessage = {
|
export type ShellInitMessage = {
|
||||||
type: 'init';
|
type: 'init';
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
|
|||||||
wsRef: MutableRefObject<WebSocket | null>;
|
wsRef: MutableRefObject<WebSocket | null>;
|
||||||
terminalRef: MutableRefObject<Terminal | null>;
|
terminalRef: MutableRefObject<Terminal | null>;
|
||||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||||
authUrlRef: MutableRefObject<string>;
|
|
||||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
import type { ProjectSession } from '../../../types/app';
|
import type { ProjectSession } from '../../../types/app';
|
||||||
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
|
|
||||||
|
|
||||||
export function isCodexLoginCommand(command: string | null | undefined): boolean {
|
|
||||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
|
|
||||||
if (isCodexLoginCommand(command)) {
|
|
||||||
return CODEX_DEVICE_AUTH_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return authUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
|||||||
return session.__provider === 'cursor'
|
return session.__provider === 'cursor'
|
||||||
? session.name || 'Untitled Session'
|
? session.name || 'Untitled Session'
|
||||||
: session.summary || 'New Session';
|
: session.summary || 'New Session';
|
||||||
}
|
}
|
||||||
|
|||||||
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,12 +59,8 @@ export default function Shell({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
} = useShellRuntime({
|
} = useShellRuntime({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -243,15 +239,7 @@ export default function Shell({
|
|||||||
if (minimal) {
|
if (minimal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellMinimalView
|
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
||||||
terminalContainerRef={terminalContainerRef}
|
|
||||||
authUrl={authUrl}
|
|
||||||
authUrlVersion={authUrlVersion}
|
|
||||||
initialCommand={initialCommand}
|
|
||||||
isConnected={isConnected}
|
|
||||||
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
|
||||||
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
|
||||||
/>
|
|
||||||
<TerminalShortcutsPanel
|
<TerminalShortcutsPanel
|
||||||
wsRef={wsRef}
|
wsRef={wsRef}
|
||||||
terminalRef={terminalRef}
|
terminalRef={terminalRef}
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import type { AuthCopyStatus } from '../../types/types';
|
|
||||||
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
|
||||||
|
|
||||||
type ShellMinimalViewProps = {
|
type ShellMinimalViewProps = {
|
||||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||||
authUrl: string;
|
|
||||||
authUrlVersion: number;
|
|
||||||
initialCommand: string | null | undefined;
|
|
||||||
isConnected: boolean;
|
|
||||||
openAuthUrlInBrowser: (url: string) => boolean;
|
|
||||||
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ShellMinimalView({
|
export default function ShellMinimalView({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
authUrl,
|
|
||||||
authUrlVersion,
|
|
||||||
initialCommand,
|
|
||||||
isConnected,
|
|
||||||
openAuthUrlInBrowser,
|
|
||||||
copyAuthUrlToClipboard,
|
|
||||||
}: ShellMinimalViewProps) {
|
}: ShellMinimalViewProps) {
|
||||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
|
|
||||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
|
||||||
|
|
||||||
const displayAuthUrl = useMemo(
|
|
||||||
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
|
|
||||||
[authUrl, initialCommand],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
|
|
||||||
useEffect(() => {
|
|
||||||
setAuthUrlCopyStatus('idle');
|
|
||||||
setIsAuthPanelHidden(false);
|
|
||||||
}, [authUrlVersion, displayAuthUrl, isConnected]);
|
|
||||||
|
|
||||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
|
||||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
|
||||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-gray-900">
|
<div className="relative h-full w-full bg-gray-900">
|
||||||
<div
|
<div
|
||||||
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
|
|||||||
className="h-full w-full focus:outline-none"
|
className="h-full w-full focus:outline-none"
|
||||||
style={{ outline: 'none' }}
|
style={{ outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMobileAuthPanel && (
|
|
||||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(true)}
|
|
||||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Hide
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayAuthUrl}
|
|
||||||
readOnly
|
|
||||||
onClick={(event) => event.currentTarget.select()}
|
|
||||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
aria-label="Authentication URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openAuthUrlInBrowser(displayAuthUrl);
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Open URL
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
|
||||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showMobileAuthPanelToggle && (
|
|
||||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAuthPanelHidden(false)}
|
|
||||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Show login URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
|
|||||||
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
||||||
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
||||||
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
||||||
|
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ARROW_ICONS = {
|
const ARROW_ICONS = {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import type {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillCreatePayload,
|
ProviderSkillCreatePayload,
|
||||||
|
ProviderSkillRegistryActionResponse,
|
||||||
|
ProviderSkillRegistryResult,
|
||||||
|
ProviderSkillRegistrySearchResponse,
|
||||||
ProviderSkillsResponse,
|
ProviderSkillsResponse,
|
||||||
SkillsProject,
|
SkillsProject,
|
||||||
SkillsProvider,
|
SkillsProvider,
|
||||||
@@ -197,6 +200,50 @@ const saveProviderSkills = async (
|
|||||||
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
|
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchProviderSkillRegistry = async (
|
||||||
|
provider: SkillsProvider,
|
||||||
|
query: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<ProviderSkillRegistryResult[]> => {
|
||||||
|
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||||
|
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`);
|
||||||
|
const data = await toResponseJson<ApiResponse<ProviderSkillRegistrySearchResponse>>(response);
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(getApiErrorMessage(data, 'Failed to search skill registry'));
|
||||||
|
}
|
||||||
|
return data.data.results || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const runProviderSkillRegistryAction = async (
|
||||||
|
provider: SkillsProvider,
|
||||||
|
action: 'install' | 'check' | 'update' | 'audit',
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
): Promise<ProviderSkillRegistryActionResponse['result']> => {
|
||||||
|
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(getApiErrorMessage(data, `Failed to run ${action}`));
|
||||||
|
}
|
||||||
|
return data.data.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uninstallProviderSkillRegistrySkill = async (
|
||||||
|
provider: SkillsProvider,
|
||||||
|
name: string,
|
||||||
|
): Promise<ProviderSkillRegistryActionResponse['result']> => {
|
||||||
|
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(getApiErrorMessage(data, 'Failed to uninstall skill'));
|
||||||
|
}
|
||||||
|
return data.data.result;
|
||||||
|
};
|
||||||
|
|
||||||
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
|
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
|
||||||
const projectKey = projects.map((project) => project.path).sort().join('|');
|
const projectKey = projects.map((project) => project.path).sort().join('|');
|
||||||
return `${provider}:${projectKey}`;
|
return `${provider}:${projectKey}`;
|
||||||
@@ -221,6 +268,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
|||||||
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
|
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||||
|
const [registryResults, setRegistryResults] = useState<ProviderSkillRegistryResult[]>([]);
|
||||||
|
const [registryError, setRegistryError] = useState<string | null>(null);
|
||||||
|
const [registryStatus, setRegistryStatus] = useState<string | null>(null);
|
||||||
|
const [registryBusyKey, setRegistryBusyKey] = useState<string | null>(null);
|
||||||
const activeLoadIdRef = useRef(0);
|
const activeLoadIdRef = useRef(0);
|
||||||
|
|
||||||
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
|
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
|
||||||
@@ -250,7 +301,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
|||||||
setIsLoadingProjectScopes(false);
|
setIsLoadingProjectScopes(false);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
|
// Build the authoritative list from the fresh fetches only. The cache still
|
||||||
|
// feeds instant display above, but seeding the merge from it would let
|
||||||
|
// skills deleted out-of-band survive the union and never get pruned.
|
||||||
|
let nextSkills: ProviderSkill[] = [];
|
||||||
let firstError: string | null = null;
|
let firstError: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -319,12 +373,86 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
|||||||
}
|
}
|
||||||
}, [refreshSkills, selectedProvider]);
|
}, [refreshSkills, selectedProvider]);
|
||||||
|
|
||||||
|
const searchRegistry = useCallback(async (query: string) => {
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
setRegistryResults([]);
|
||||||
|
setRegistryError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegistryBusyKey('search');
|
||||||
|
setRegistryError(null);
|
||||||
|
setRegistryStatus(null);
|
||||||
|
try {
|
||||||
|
setRegistryResults(await searchProviderSkillRegistry(selectedProvider, normalizedQuery, 12));
|
||||||
|
} catch (error) {
|
||||||
|
setRegistryError(error instanceof Error ? error.message : 'Failed to search skill registry');
|
||||||
|
} finally {
|
||||||
|
setRegistryBusyKey((current) => (current === 'search' ? null : current));
|
||||||
|
}
|
||||||
|
}, [selectedProvider]);
|
||||||
|
|
||||||
|
const installRegistrySkill = useCallback(async (identifier: string) => {
|
||||||
|
setRegistryBusyKey(`install:${identifier}`);
|
||||||
|
setRegistryError(null);
|
||||||
|
setRegistryStatus(null);
|
||||||
|
try {
|
||||||
|
await runProviderSkillRegistryAction(selectedProvider, 'install', { identifier });
|
||||||
|
clearProviderSkillCache(selectedProvider);
|
||||||
|
await refreshSkills({ force: true });
|
||||||
|
setRegistryStatus('Skill installed.');
|
||||||
|
} catch (error) {
|
||||||
|
setRegistryError(error instanceof Error ? error.message : 'Failed to install skill');
|
||||||
|
} finally {
|
||||||
|
setRegistryBusyKey((current) => (current === `install:${identifier}` ? null : current));
|
||||||
|
}
|
||||||
|
}, [refreshSkills, selectedProvider]);
|
||||||
|
|
||||||
|
const uninstallRegistrySkill = useCallback(async (name: string) => {
|
||||||
|
setRegistryBusyKey(`uninstall:${name}`);
|
||||||
|
setRegistryError(null);
|
||||||
|
setRegistryStatus(null);
|
||||||
|
try {
|
||||||
|
await uninstallProviderSkillRegistrySkill(selectedProvider, name);
|
||||||
|
clearProviderSkillCache(selectedProvider);
|
||||||
|
await refreshSkills({ force: true });
|
||||||
|
setRegistryStatus('Skill uninstalled.');
|
||||||
|
} catch (error) {
|
||||||
|
setRegistryError(error instanceof Error ? error.message : 'Failed to uninstall skill');
|
||||||
|
} finally {
|
||||||
|
setRegistryBusyKey((current) => (current === `uninstall:${name}` ? null : current));
|
||||||
|
}
|
||||||
|
}, [refreshSkills, selectedProvider]);
|
||||||
|
|
||||||
|
const runRegistryMaintenance = useCallback(async (action: 'check' | 'update' | 'audit') => {
|
||||||
|
setRegistryBusyKey(action);
|
||||||
|
setRegistryError(null);
|
||||||
|
setRegistryStatus(null);
|
||||||
|
try {
|
||||||
|
const result = await runProviderSkillRegistryAction(selectedProvider, action);
|
||||||
|
if (action === 'update' || action === 'audit') {
|
||||||
|
clearProviderSkillCache(selectedProvider);
|
||||||
|
await refreshSkills({ force: true });
|
||||||
|
}
|
||||||
|
setRegistryStatus((result.stdout || result.stderr || `${action} completed.`).trim());
|
||||||
|
} catch (error) {
|
||||||
|
setRegistryError(error instanceof Error ? error.message : `Failed to run ${action}`);
|
||||||
|
} finally {
|
||||||
|
setRegistryBusyKey((current) => (current === action ? null : current));
|
||||||
|
}
|
||||||
|
}, [refreshSkills, selectedProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshSkills();
|
void refreshSkills();
|
||||||
}, [refreshSkills]);
|
}, [refreshSkills]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSaveStatus(null);
|
setSaveStatus(null);
|
||||||
|
setRegistryResults([]);
|
||||||
|
setRegistryError(null);
|
||||||
|
setRegistryStatus(null);
|
||||||
|
setRegistryBusyKey(null);
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -342,7 +470,15 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
|
|||||||
isLoadingProjectScopes,
|
isLoadingProjectScopes,
|
||||||
loadError,
|
loadError,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
|
registryResults,
|
||||||
|
registryError,
|
||||||
|
registryStatus,
|
||||||
|
registryBusyKey,
|
||||||
addSkills,
|
addSkills,
|
||||||
refreshSkills,
|
refreshSkills,
|
||||||
|
searchRegistry,
|
||||||
|
installRegistrySkill,
|
||||||
|
uninstallRegistrySkill,
|
||||||
|
runRegistryMaintenance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ export type ProviderSkillsResponse = {
|
|||||||
skills: Array<Partial<ProviderSkill>>;
|
skills: Array<Partial<ProviderSkill>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistryResult = {
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
source?: string;
|
||||||
|
trustLevel?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistrySearchResponse = {
|
||||||
|
provider: SkillsProvider;
|
||||||
|
results: ProviderSkillRegistryResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRegistryActionResponse = {
|
||||||
|
provider: SkillsProvider;
|
||||||
|
result: {
|
||||||
|
ok: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiSuccessResponse<T> = {
|
export type ApiSuccessResponse<T> = {
|
||||||
success: true;
|
success: true;
|
||||||
data: T;
|
data: T;
|
||||||
|
|||||||
@@ -2,20 +2,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Compass,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
FileText,
|
FileText,
|
||||||
FileUp,
|
FileUp,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import {
|
import {
|
||||||
|
ActionMenu,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -23,6 +27,9 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Input,
|
Input,
|
||||||
} from '../../../shared/view/ui';
|
} from '../../../shared/view/ui';
|
||||||
import { useProviderSkills } from '../hooks/useProviderSkills';
|
import { useProviderSkills } from '../hooks/useProviderSkills';
|
||||||
@@ -62,6 +69,7 @@ const PROVIDER_NAMES: Record<SkillsProvider, string> = {
|
|||||||
cursor: 'Cursor',
|
cursor: 'Cursor',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
opencode: 'OpenCode',
|
opencode: 'OpenCode',
|
||||||
|
hermes: 'Hermes',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
|
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
|
||||||
@@ -69,8 +77,30 @@ const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string>
|
|||||||
codex: '~/.agents/skills/<skill-name>/SKILL.md',
|
codex: '~/.agents/skills/<skill-name>/SKILL.md',
|
||||||
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
|
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
|
||||||
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
|
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
|
||||||
|
hermes: '~/.hermes/skills/<skill-name>/SKILL.md',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HERMES_SKILL_ACTIONS = [
|
||||||
|
{
|
||||||
|
label: 'Check Updates',
|
||||||
|
description: 'Check installed hub skills.',
|
||||||
|
action: 'check' as const,
|
||||||
|
icon: RefreshCw,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Update Hub Skills',
|
||||||
|
description: 'Apply available hub updates.',
|
||||||
|
action: 'update' as const,
|
||||||
|
icon: Upload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Audit Installed',
|
||||||
|
description: 'Re-scan installed hub skills.',
|
||||||
|
action: 'audit' as const,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const SCOPE_LABELS: Record<SkillsScope, string> = {
|
const SCOPE_LABELS: Record<SkillsScope, string> = {
|
||||||
user: 'User',
|
user: 'User',
|
||||||
plugin: 'Plugin',
|
plugin: 'Plugin',
|
||||||
@@ -209,13 +239,23 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
isLoadingProjectScopes,
|
isLoadingProjectScopes,
|
||||||
loadError,
|
loadError,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
|
registryResults,
|
||||||
|
registryError,
|
||||||
|
registryStatus,
|
||||||
|
registryBusyKey,
|
||||||
addSkills,
|
addSkills,
|
||||||
refreshSkills,
|
refreshSkills,
|
||||||
|
searchRegistry,
|
||||||
|
installRegistrySkill,
|
||||||
|
runRegistryMaintenance,
|
||||||
} = useProviderSkills({ selectedProvider, currentProjects });
|
} = useProviderSkills({ selectedProvider, currentProjects });
|
||||||
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
|
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [registryQuery, setRegistryQuery] = useState('');
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [addMode, setAddMode] = useState<'upload' | 'hub'>('upload');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -227,6 +267,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
setRegistryQuery('');
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
setAddMode('upload');
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -354,6 +397,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
})));
|
})));
|
||||||
await addSkills({ entries });
|
await addSkills({ entries });
|
||||||
setQueuedFiles([]);
|
setQueuedFiles([]);
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -361,6 +405,230 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
}
|
}
|
||||||
}, [addSkills, queuedFiles]);
|
}, [addSkills, queuedFiles]);
|
||||||
|
|
||||||
|
const uploadPanel = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
||||||
|
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-dashed p-4 transition-colors sm:p-5',
|
||||||
|
isDragActive
|
||||||
|
? 'border-foreground/40 bg-muted/35'
|
||||||
|
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,text/markdown"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleDrop(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleFolderSelection(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
|
||||||
|
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Upload standalone definitions or choose a full folder to include scripts, references, and assets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
Choose Files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FolderUp className="h-4 w-4" />
|
||||||
|
Choose Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queuedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{queuedFiles.map((queuedFile) => (
|
||||||
|
<div
|
||||||
|
key={queuedFile.id}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{queuedFile.kind === 'folder'
|
||||||
|
? `${queuedFile.files.length} files`
|
||||||
|
: 'Markdown file'}
|
||||||
|
{' · '}
|
||||||
|
{formatFileSize(queuedFile.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleUploadInstall()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hermesHubPanel = selectedProvider === 'hermes' ? (
|
||||||
|
<div className="flex min-h-full flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={registryQuery}
|
||||||
|
onChange={(event) => setRegistryQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
void searchRegistry(registryQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search Hermes skills..."
|
||||||
|
aria-label="Search Hermes skills registry"
|
||||||
|
className="h-9 w-full pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
|
||||||
|
onClick={() => void searchRegistry(registryQuery)}
|
||||||
|
>
|
||||||
|
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<ActionMenu
|
||||||
|
label="Maintenance"
|
||||||
|
icon={Wrench}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
triggerClassName="w-full sm:w-auto"
|
||||||
|
items={HERMES_SKILL_ACTIONS.map((action) => ({
|
||||||
|
key: action.action,
|
||||||
|
label: action.label,
|
||||||
|
description: action.description,
|
||||||
|
icon: action.icon,
|
||||||
|
loading: registryBusyKey === action.action,
|
||||||
|
disabled: Boolean(registryBusyKey && registryBusyKey !== action.action),
|
||||||
|
onSelect: () => void runRegistryMaintenance(action.action),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{registryResults.length > 0 && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{registryResults.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.identifier}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{result.name}</span>
|
||||||
|
{result.source && (
|
||||||
|
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
|
||||||
|
)}
|
||||||
|
{result.trustLevel && (
|
||||||
|
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
|
||||||
|
{result.description && (
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={registryBusyKey === `install:${result.identifier}`}
|
||||||
|
onClick={() => void installRegistrySkill(result.identifier)}
|
||||||
|
>
|
||||||
|
{registryBusyKey === `install:${result.identifier}`
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Upload className="h-4 w-4" />}
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{registryResults.length === 0 && (
|
||||||
|
<div className="flex min-h-[220px] flex-1 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center">
|
||||||
|
<div className="max-w-sm space-y-2">
|
||||||
|
<Compass className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<div className="text-sm font-medium text-foreground">Search the Hermes Skills Hub</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Find installable Hermes skills by name, provider, source, or task.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
@@ -376,160 +644,114 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
onClick={() => void refreshSkills({ force: true })}
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={isLoading || isLoadingProjectScopes}
|
onClick={() => {
|
||||||
>
|
setAddMode('upload');
|
||||||
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
setIsAddDialogOpen(true);
|
||||||
Refresh
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Skill
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void refreshSkills({ force: true })}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={isLoading || isLoadingProjectScopes}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
|
<DialogContent
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
wrapperClassName="z-[10000]"
|
||||||
<div className="text-sm font-medium text-foreground">Upload Skills</div>
|
className="flex h-[calc(100vh-2rem)] max-h-[760px] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden p-0 sm:h-[720px]"
|
||||||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
|
>
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
<DialogTitle>Add {providerName} Skill</DialogTitle>
|
||||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
<div className="flex-shrink-0 border-b border-border/60 px-4 py-4">
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
</div>
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||||
</CardHeader>
|
{addMode === 'hub' ? <Compass className="h-4 w-4" /> : <FileUp className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
<CardContent className="space-y-4 p-4">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="space-y-4">
|
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div>
|
||||||
<div
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
{...getRootProps()}
|
{selectedProvider === 'hermes'
|
||||||
className={cn(
|
? 'Upload a local skill or install one from the Hermes Skills Hub.'
|
||||||
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
|
: 'Upload a markdown skill file or a complete skill folder.'}
|
||||||
isDragActive
|
|
||||||
? 'border-foreground/40 bg-muted/35'
|
|
||||||
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".md,text/markdown"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleDrop(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={folderInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleFolderSelection(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
|
|
||||||
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<FileUp className="h-4 w-4" />
|
|
||||||
Choose Files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<FolderUp className="h-4 w-4" />
|
|
||||||
Choose Folder
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{queuedFiles.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{queuedFiles.map((queuedFile) => (
|
|
||||||
<div
|
|
||||||
key={queuedFile.id}
|
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{queuedFile.kind === 'folder'
|
|
||||||
? `${queuedFile.files.length} files`
|
|
||||||
: 'Markdown file'}
|
|
||||||
{' · '}
|
|
||||||
{formatFileSize(queuedFile.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleUploadInstall()}
|
variant="ghost"
|
||||||
disabled={isSubmitting}
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close add skill dialog"
|
||||||
|
onClick={() => setIsAddDialogOpen(false)}
|
||||||
>
|
>
|
||||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
<X className="h-4 w-4" />
|
||||||
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProvider === 'hermes' && (
|
||||||
|
<div className="mt-4 inline-flex rounded-lg border border-border/70 bg-muted/20 p-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addMode === 'upload' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shadow-none"
|
||||||
|
onClick={() => setAddMode('upload')}
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addMode === 'hub' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shadow-none"
|
||||||
|
onClick={() => setAddMode('hub')}
|
||||||
|
>
|
||||||
|
<Compass className="h-4 w-4" />
|
||||||
|
Skills Hub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(submitError || loadError) && (
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
{addMode === 'hub' && hermesHubPanel ? hermesHubPanel : uploadPanel}
|
||||||
{submitError || loadError}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{saveStatus === 'success' && (
|
<div className="min-h-14 flex-shrink-0 border-t border-border/60 px-4 py-3">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
{(submitError || loadError || registryError || registryStatus || saveStatus === 'success') && (
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<div className={cn(
|
||||||
Skills saved successfully.
|
'rounded-lg border px-3 py-2 text-sm',
|
||||||
</div>
|
submitError || loadError || registryError
|
||||||
)}
|
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
|
||||||
</CardContent>
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||||
</Card>
|
)}>
|
||||||
|
{submitError || loadError || registryError || registryStatus || 'Skills saved successfully.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{saveStatus === 'success' && !isAddDialogOpen && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Skills saved successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
||||||
<CardHeader className="border-b border-border/60">
|
<CardHeader className="border-b border-border/60">
|
||||||
|
|||||||
@@ -32,5 +32,10 @@
|
|||||||
"binaryFile": {
|
"binaryFile": {
|
||||||
"title": "Binary File",
|
"title": "Binary File",
|
||||||
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
||||||
|
},
|
||||||
|
"filePreview": {
|
||||||
|
"loading": "Loading preview...",
|
||||||
|
"error": "Unable to display this file.",
|
||||||
|
"openInNewTab": "Open in new tab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
/* The app shell is a fixed inset-0 container (see AppContent), so the
|
||||||
|
document itself never needs to scroll. Clipping it removes the phantom
|
||||||
|
full-height page scrollbar and disables the browser pull-to-refresh
|
||||||
|
gesture that reloads the page when scrolling up on mobile. */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Root element with safe area padding for PWA */
|
/* Root element with safe area padding for PWA */
|
||||||
|
|||||||
189
src/shared/view/ui/ActionMenu.tsx
Normal file
189
src/shared/view/ui/ActionMenu.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||||
|
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
|
||||||
|
|
||||||
|
export type ActionMenuItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
isDanger?: boolean;
|
||||||
|
showDividerBefore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActionMenuProps = {
|
||||||
|
label: string;
|
||||||
|
items: ActionMenuItem[];
|
||||||
|
icon?: LucideIcon;
|
||||||
|
ariaLabel?: string;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
className?: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActionMenu({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
icon: TriggerIcon,
|
||||||
|
ariaLabel,
|
||||||
|
align = 'right',
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'sm',
|
||||||
|
className,
|
||||||
|
triggerClassName,
|
||||||
|
disabled,
|
||||||
|
}: ActionMenuProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
// Whether closing should move focus back to the trigger. Set for keyboard
|
||||||
|
// (Escape) and item selection, but left false for outside pointer clicks so
|
||||||
|
// focus is not stolen from wherever the user clicked.
|
||||||
|
const restoreFocusRef = React.useRef(false);
|
||||||
|
const wasOpenRef = React.useRef(false);
|
||||||
|
const menuId = React.useId();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (rootRef.current && !rootRef.current.contains(target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
restoreFocusRef.current = true;
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Move focus into the menu on open and back to the trigger on a keyboard or
|
||||||
|
// selection close, so keyboard and screen-reader navigation match the menu role.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
wasOpenRef.current = true;
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
|
||||||
|
(firstItem ?? menu)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasOpenRef.current) {
|
||||||
|
wasOpenRef.current = false;
|
||||||
|
if (restoreFocusRef.current) {
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
restoreFocusRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const runItem = (item: ActionMenuItem) => {
|
||||||
|
if (item.disabled || item.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreFocusRef.current = true;
|
||||||
|
setIsOpen(false);
|
||||||
|
item.onSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={cn('relative inline-flex', className)}>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={triggerClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel || label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={isOpen ? menuId : undefined}
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
id={menuId}
|
||||||
|
role="menu"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
align === 'right' ? 'right-0' : 'left-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.key}>
|
||||||
|
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={item.disabled || item.loading}
|
||||||
|
onClick={() => runItem(item)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
|
||||||
|
'focus:bg-accent focus:outline-none',
|
||||||
|
item.disabled || item.loading
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: item.isDanger
|
||||||
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
|
||||||
|
: 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.loading ? (
|
||||||
|
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium leading-5">{item.label}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,12 +92,22 @@ DialogTrigger.displayName = 'DialogTrigger';
|
|||||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
onEscapeKeyDown?: () => void;
|
onEscapeKeyDown?: () => void;
|
||||||
onPointerDownOutside?: () => void;
|
onPointerDownOutside?: () => void;
|
||||||
|
overlayClassName?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||||
({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onEscapeKeyDown,
|
||||||
|
onPointerDownOutside,
|
||||||
|
overlayClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
const { open, onOpenChange, triggerRef } = useDialog();
|
const { open, onOpenChange, triggerRef } = useDialog();
|
||||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||||
@@ -171,10 +181,10 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50">
|
<div className={cn('fixed inset-0 z-50', wrapperClassName)}>
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"
|
className={cn('fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm', overlayClassName)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPointerDownOutside?.();
|
onPointerDownOutside?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
|
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
|
||||||
|
export { default as ActionMenu } from './ActionMenu';
|
||||||
|
export type { ActionMenuItem } from './ActionMenu';
|
||||||
export { Badge, badgeVariants } from './Badge';
|
export { Badge, badgeVariants } from './Badge';
|
||||||
export { Button, buttonVariants } from './Button';
|
export { Button, buttonVariants } from './Button';
|
||||||
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';
|
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ function hasServerEchoForLocalUser(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assistantEchoFingerprint(message: NormalizedMessage): string | null {
|
||||||
|
const content = (message.content || '').trim();
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return content.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
|
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
|
||||||
const timeA = readMessageTime(a) ?? 0;
|
const timeA = readMessageTime(a) ?? 0;
|
||||||
const timeB = readMessageTime(b) ?? 0;
|
const timeB = readMessageTime(b) ?? 0;
|
||||||
@@ -248,7 +256,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
|
|||||||
serverMessages: NormalizedMessage[],
|
serverMessages: NormalizedMessage[],
|
||||||
realtimeMessages: NormalizedMessage[],
|
realtimeMessages: NormalizedMessage[],
|
||||||
): boolean {
|
): boolean {
|
||||||
const assistantText = (message.content || '').trim();
|
const assistantText = assistantEchoFingerprint(message);
|
||||||
if (!assistantText) {
|
if (!assistantText) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -264,7 +272,7 @@ function isAssistantTextEchoedInSameTurnOnServer(
|
|||||||
.some((serverMessage) =>
|
.some((serverMessage) =>
|
||||||
serverMessage.kind === 'text'
|
serverMessage.kind === 'text'
|
||||||
&& serverMessage.role === 'assistant'
|
&& serverMessage.role === 'assistant'
|
||||||
&& (serverMessage.content || '').trim() === assistantText,
|
&& assistantEchoFingerprint(serverMessage) === assistantText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +289,9 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
|
|||||||
const prev = out[out.length - 1];
|
const prev = out[out.length - 1];
|
||||||
if (prev) {
|
if (prev) {
|
||||||
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
|
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
|
||||||
const ps = (prev.content || '').trim();
|
const ps = assistantEchoFingerprint(prev);
|
||||||
const ms = (m.content || '').trim();
|
const ms = assistantEchoFingerprint(m);
|
||||||
if (ps.length > 0 && ps === ms) {
|
if (ps && ps === ms) {
|
||||||
out[out.length - 1] = m;
|
out[out.length - 1] = m;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -294,8 +302,12 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
|
|||||||
&& prev.role === 'assistant'
|
&& prev.role === 'assistant'
|
||||||
&& m.role === 'assistant'
|
&& m.role === 'assistant'
|
||||||
) {
|
) {
|
||||||
const ms = (m.content || '').trim();
|
const ps = assistantEchoFingerprint(prev);
|
||||||
if (ms.length > 0 && ms === (prev.content || '').trim()) {
|
const ms = assistantEchoFingerprint(m);
|
||||||
|
if (ms && ms === ps) {
|
||||||
|
if ((m.content || '').length > (prev.content || '').length) {
|
||||||
|
out[out.length - 1] = m;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
|
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes';
|
||||||
|
|
||||||
export type ProviderModelOption = {
|
export type ProviderModelOption = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user