[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
45
components/ai/toolArtifacts/TerminalArtifactCard.test.tsx
Normal file
45
components/ai/toolArtifacts/TerminalArtifactCard.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { TerminalArtifactCard } from './TerminalArtifactCard.tsx';
|
||||
|
||||
test('TerminalArtifactCard renders terminal context summary', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TerminalArtifactCard
|
||||
artifact={{
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
preview: 'alpha\nbeta\ngamma',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /prod/);
|
||||
assert.match(html, /lines 101-103 \/ 120/);
|
||||
assert.doesNotMatch(html, /alpha/);
|
||||
});
|
||||
|
||||
test('TerminalArtifactCard renders terminal read errors', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TerminalArtifactCard
|
||||
artifact={{
|
||||
kind: 'error',
|
||||
message: 'Terminal context reader is unavailable.',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /Terminal read failed/);
|
||||
assert.match(html, /Terminal context reader is unavailable/);
|
||||
});
|
||||
73
components/ai/toolArtifacts/TerminalArtifactCard.tsx
Normal file
73
components/ai/toolArtifacts/TerminalArtifactCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AlertCircle, SquareTerminal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { TerminalToolArtifact } from './terminalToolArtifact';
|
||||
|
||||
interface TerminalArtifactCardProps {
|
||||
artifact: TerminalToolArtifact;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatLineRange(artifact: Extract<TerminalToolArtifact, { kind: 'terminal.context' }>): string {
|
||||
if (artifact.returnedLines === 0) {
|
||||
return `0 / ${artifact.totalLines} lines`;
|
||||
}
|
||||
return `lines ${artifact.startLine + 1}-${artifact.endLine + 1} / ${artifact.totalLines}`;
|
||||
}
|
||||
|
||||
function formatSubtitle(artifact: Extract<TerminalToolArtifact, { kind: 'terminal.context' }>): string {
|
||||
const parts = [
|
||||
formatLineRange(artifact),
|
||||
artifact.source,
|
||||
artifact.hasMoreBefore || artifact.hasMoreAfter ? 'more available' : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
export const TerminalArtifactCard = React.forwardRef<HTMLDivElement, TerminalArtifactCardProps>(({
|
||||
artifact,
|
||||
className,
|
||||
}, ref) => {
|
||||
if (artifact.kind === 'error') {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-2',
|
||||
className,
|
||||
)} ref={ref}>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-destructive/10 text-destructive">
|
||||
<AlertCircle size={15} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
Terminal read failed
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{artifact.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = artifact.label || artifact.sessionId;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2',
|
||||
className,
|
||||
)} ref={ref}>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-500">
|
||||
<SquareTerminal size={15} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
{title}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{formatSubtitle(artifact)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TerminalArtifactCard.displayName = 'TerminalArtifactCard';
|
||||
38
components/ai/toolArtifacts/TerminalArtifactToolResult.tsx
Normal file
38
components/ai/toolArtifacts/TerminalArtifactToolResult.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
||||
import { formatTerminalToolTooltip } from './formatTerminalToolTooltip';
|
||||
import { TerminalArtifactCard } from './TerminalArtifactCard';
|
||||
import type { TerminalToolArtifact } from './terminalToolArtifact';
|
||||
|
||||
interface TerminalArtifactToolResultProps {
|
||||
artifact: TerminalToolArtifact;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalArtifactToolResult: React.FC<TerminalArtifactToolResultProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const tooltip = formatTerminalToolTooltip(toolName, args, result, isError);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TerminalArtifactCard artifact={artifact} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-md whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
58
components/ai/toolArtifacts/VaultArtifactCard.test.tsx
Normal file
58
components/ai/toolArtifacts/VaultArtifactCard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider.tsx';
|
||||
import { VaultArtifactCard } from './VaultArtifactCard.tsx';
|
||||
|
||||
test('VaultArtifactCard renders note artifact title', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
group: 'ops/prod',
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /Runbook/);
|
||||
assert.match(html, /ops\/prod/);
|
||||
});
|
||||
|
||||
test('VaultArtifactCard does not render a clickable note without navigation wiring', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(html, /<button/);
|
||||
});
|
||||
|
||||
test('VaultArtifactCard renders host batch summary', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.hosts.batch',
|
||||
addedCount: 2,
|
||||
preview: [{ label: 'Web', hostname: '10.0.0.1' }],
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /Added 2 hosts/);
|
||||
assert.match(html, /Web/);
|
||||
});
|
||||
211
components/ai/toolArtifacts/VaultArtifactCard.tsx
Normal file
211
components/ai/toolArtifacts/VaultArtifactCard.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
import {
|
||||
canNavigateVaultArtifact,
|
||||
navigateVaultArtifact,
|
||||
useVaultArtifactNavigation,
|
||||
} from './VaultArtifactNavigationContext';
|
||||
import { VaultArtifactIcon } from './vaultArtifactPresentation';
|
||||
|
||||
interface VaultArtifactCardProps {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getArtifactPresentation(
|
||||
artifact: VaultToolArtifact,
|
||||
t: (key: string, values?: Record<string, unknown>) => string,
|
||||
): {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
clickable: boolean;
|
||||
} {
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
return {
|
||||
title: artifact.title,
|
||||
subtitle: artifact.group ?? t('ai.chat.artifact.noteFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.host':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.port
|
||||
? `${artifact.hostname}:${artifact.port}`
|
||||
: artifact.hostname,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.hosts.batch': {
|
||||
const preview = artifact.preview
|
||||
.slice(0, 2)
|
||||
.map((host) => host.label || host.hostname)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
title: artifact.dryRun
|
||||
? t('ai.chat.artifact.hostsPreview', { count: artifact.addedCount })
|
||||
: t('ai.chat.artifact.hostsAdded', { count: artifact.addedCount }),
|
||||
subtitle: preview || t('ai.chat.artifact.openHosts'),
|
||||
clickable: true,
|
||||
};
|
||||
}
|
||||
case 'vault.summary':
|
||||
return {
|
||||
title: artifact.section === 'notes'
|
||||
? t('ai.chat.artifact.notesSummary', { count: artifact.count })
|
||||
: artifact.section === 'hosts'
|
||||
? t('ai.chat.artifact.hostsSummary', { count: artifact.count })
|
||||
: artifact.section === 'snippets'
|
||||
? t('ai.chat.artifact.snippetsSummary', { count: artifact.count })
|
||||
: t('ai.chat.artifact.scriptsSummary', { count: artifact.count }),
|
||||
subtitle: artifact.section === 'notes'
|
||||
? t('ai.chat.artifact.openNotes')
|
||||
: artifact.section === 'hosts'
|
||||
? t('ai.chat.artifact.openHosts')
|
||||
: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.snippet':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.package || t('ai.chat.artifact.snippetFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.language
|
||||
? t('ai.chat.artifact.scriptLanguage', { language: artifact.language })
|
||||
: artifact.package || t('ai.chat.artifact.scriptFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.snippet.deleted':
|
||||
return {
|
||||
title: t('ai.chat.artifact.snippetDeleted'),
|
||||
subtitle: artifact.snippetId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.script.deleted':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptDeleted'),
|
||||
subtitle: artifact.scriptId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.snippet.run':
|
||||
return {
|
||||
title: t('ai.chat.artifact.snippetRan'),
|
||||
subtitle: artifact.command || artifact.snippetId,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.run':
|
||||
return {
|
||||
title: artifact.status
|
||||
? t('ai.chat.artifact.scriptRunStatus', { status: artifact.status })
|
||||
: t('ai.chat.artifact.scriptStarted'),
|
||||
subtitle: artifact.runId,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.runs':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptRunsSummary', { count: artifact.count }),
|
||||
subtitle: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.action':
|
||||
return {
|
||||
title: artifact.action === 'stop'
|
||||
? t('ai.chat.artifact.scriptRunStopped')
|
||||
: artifact.action === 'pause'
|
||||
? t('ai.chat.artifact.scriptRunPaused')
|
||||
: t('ai.chat.artifact.scriptRunResumed'),
|
||||
subtitle: artifact.runId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.script.reference':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptReference'),
|
||||
subtitle: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
title: t('ai.chat.artifact.failed'),
|
||||
subtitle: artifact.message,
|
||||
clickable: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: '',
|
||||
clickable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VaultArtifactCard: React.FC<VaultArtifactCardProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const navigation = useVaultArtifactNavigation();
|
||||
const presentation = getArtifactPresentation(artifact, t);
|
||||
const canNavigate = presentation.clickable && canNavigateVaultArtifact(artifact, navigation);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!canNavigate || !navigation) return;
|
||||
navigateVaultArtifact(artifact, navigation);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<VaultArtifactIcon artifact={artifact} toolName={toolName} />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
{presentation.title}
|
||||
</div>
|
||||
{presentation.subtitle && (
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{presentation.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canNavigate && (
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canNavigate) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
presentation.clickable
|
||||
? 'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2 text-left'
|
||||
: 'flex items-center gap-2 px-1 py-0.5',
|
||||
className,
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2',
|
||||
'text-left transition-colors hover:bg-muted/20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createVaultArtifactNavigationActions,
|
||||
navigateVaultArtifact,
|
||||
} from './VaultArtifactNavigationContext.tsx';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
test('clicking an existing note artifact opens that note', () => {
|
||||
const openedNotes: string[] = [];
|
||||
const unavailable: Array<{ title: string; message: string }> = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [{ id: 'note-1', title: 'Runbook', content: '', createdAt: 1, updatedAt: 1 }],
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
t,
|
||||
onOpenVaultNote: (noteId) => openedNotes.push(noteId),
|
||||
onOpenVaultHost: () => {},
|
||||
onOpenVaultSection: () => {},
|
||||
onUnavailable: (message, title) => unavailable.push({ title, message }),
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedNotes, ['note-1']);
|
||||
assert.deepEqual(unavailable, []);
|
||||
});
|
||||
|
||||
test('clicking an existing snippet artifact opens that snippet', () => {
|
||||
const openedSnippets: string[] = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [],
|
||||
hosts: [],
|
||||
snippets: [{ id: 'snippet-1', label: 'Restart nginx', command: 'sudo systemctl restart nginx' }],
|
||||
t,
|
||||
onOpenVaultSnippet: (snippetId) => openedSnippets.push(snippetId),
|
||||
onUnavailable: (message, title) => { void message; void title; },
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.snippet',
|
||||
snippetId: 'snippet-1',
|
||||
label: 'Restart nginx',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedSnippets, ['snippet-1']);
|
||||
});
|
||||
|
||||
test('clicking a missing note artifact shows an unavailable message instead of opening', () => {
|
||||
const openedNotes: string[] = [];
|
||||
const unavailable: Array<{ title: string; message: string }> = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [],
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
t,
|
||||
onOpenVaultNote: (noteId) => openedNotes.push(noteId),
|
||||
onOpenVaultHost: () => {},
|
||||
onOpenVaultSection: () => {},
|
||||
onUnavailable: (message, title) => unavailable.push({ title, message }),
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.note',
|
||||
noteId: 'deleted-note',
|
||||
title: 'Deleted note',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedNotes, []);
|
||||
assert.deepEqual(unavailable, [{
|
||||
title: 'ai.chat.artifact.unavailableTitle',
|
||||
message: 'ai.chat.artifact.noteMissing',
|
||||
}]);
|
||||
});
|
||||
206
components/ai/toolArtifacts/VaultArtifactNavigationContext.tsx
Normal file
206
components/ai/toolArtifacts/VaultArtifactNavigationContext.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
import type { Host, Snippet, VaultNote } from '../../../types';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { toast } from '../../ui/toast';
|
||||
import type { VaultSummarySection, VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
export type VaultArtifactNavSection = Extract<VaultSummarySection, 'notes' | 'hosts' | 'snippets'>;
|
||||
|
||||
export interface VaultArtifactNavigationActions {
|
||||
openVaultNote?: (noteId: string) => void;
|
||||
openVaultHost?: (hostId: string) => void;
|
||||
openVaultSnippet?: (snippetId: string) => void;
|
||||
openVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
}
|
||||
|
||||
interface CreateVaultArtifactNavigationActionsOptions {
|
||||
notes: VaultNote[];
|
||||
hosts: Host[];
|
||||
snippets: Snippet[];
|
||||
t: (key: string) => string;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
onUnavailable: (message: string, title: string) => void;
|
||||
}
|
||||
|
||||
interface VaultArtifactNavigationProviderProps {
|
||||
notes: VaultNote[];
|
||||
hosts: Host[];
|
||||
snippets?: Snippet[];
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const VaultArtifactNavigationContext = createContext<VaultArtifactNavigationActions | null>(null);
|
||||
|
||||
export function createVaultArtifactNavigationActions({
|
||||
notes,
|
||||
hosts,
|
||||
snippets,
|
||||
t,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
onUnavailable,
|
||||
}: CreateVaultArtifactNavigationActionsOptions): VaultArtifactNavigationActions {
|
||||
const actions: VaultArtifactNavigationActions = {};
|
||||
|
||||
if (onOpenVaultNote) {
|
||||
actions.openVaultNote = (noteId: string) => {
|
||||
const exists = notes.some((note) => note.id === noteId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.noteMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultNote(noteId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultHost) {
|
||||
actions.openVaultHost = (hostId: string) => {
|
||||
const exists = hosts.some((host) => host.id === hostId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.hostMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultHost(hostId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultSnippet) {
|
||||
actions.openVaultSnippet = (snippetId: string) => {
|
||||
const exists = snippets.some((snippet) => snippet.id === snippetId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.snippetMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultSnippet(snippetId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultSection) {
|
||||
actions.openVaultSection = onOpenVaultSection;
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function VaultArtifactNavigationProvider({
|
||||
notes,
|
||||
hosts,
|
||||
snippets = [],
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
children,
|
||||
}: VaultArtifactNavigationProviderProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const onUnavailable = useCallback((message: string, title: string) => {
|
||||
toast.warning(message, title);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<VaultArtifactNavigationActions>(() => createVaultArtifactNavigationActions({
|
||||
notes,
|
||||
hosts,
|
||||
snippets,
|
||||
t,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
onUnavailable,
|
||||
}), [
|
||||
hosts,
|
||||
notes,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultSection,
|
||||
onOpenVaultSnippet,
|
||||
onUnavailable,
|
||||
snippets,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VaultArtifactNavigationContext.Provider value={value}>
|
||||
{children}
|
||||
</VaultArtifactNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVaultArtifactNavigation(): VaultArtifactNavigationActions | null {
|
||||
return useContext(VaultArtifactNavigationContext);
|
||||
}
|
||||
|
||||
export function navigateVaultArtifact(
|
||||
artifact: VaultToolArtifact,
|
||||
navigation: VaultArtifactNavigationActions,
|
||||
): void {
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
navigation.openVaultNote?.(artifact.noteId);
|
||||
break;
|
||||
case 'vault.host':
|
||||
navigation.openVaultHost?.(artifact.hostId);
|
||||
break;
|
||||
case 'vault.hosts.batch':
|
||||
navigation.openVaultSection?.('hosts');
|
||||
break;
|
||||
case 'vault.summary':
|
||||
if (artifact.section === 'scripts') {
|
||||
navigation.openVaultSection?.('snippets');
|
||||
} else {
|
||||
navigation.openVaultSection?.(artifact.section);
|
||||
}
|
||||
break;
|
||||
case 'vault.snippet':
|
||||
case 'vault.script':
|
||||
navigation.openVaultSnippet?.(artifact.kind === 'vault.snippet' ? artifact.snippetId : artifact.scriptId);
|
||||
break;
|
||||
case 'vault.snippet.run':
|
||||
navigation.openVaultSnippet?.(artifact.snippetId);
|
||||
break;
|
||||
case 'vault.script.run':
|
||||
navigation.openVaultSnippet?.(artifact.scriptId);
|
||||
break;
|
||||
case 'vault.script.reference':
|
||||
case 'vault.script.runs':
|
||||
navigation.openVaultSection?.('snippets');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function canNavigateVaultArtifact(
|
||||
artifact: VaultToolArtifact,
|
||||
navigation: VaultArtifactNavigationActions | null,
|
||||
): boolean {
|
||||
if (!navigation) return false;
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
return Boolean(navigation.openVaultNote);
|
||||
case 'vault.host':
|
||||
return Boolean(navigation.openVaultHost);
|
||||
case 'vault.hosts.batch':
|
||||
case 'vault.summary':
|
||||
case 'vault.script.reference':
|
||||
case 'vault.script.runs':
|
||||
return Boolean(navigation.openVaultSection);
|
||||
case 'vault.snippet':
|
||||
case 'vault.script':
|
||||
case 'vault.snippet.run':
|
||||
case 'vault.script.run':
|
||||
return Boolean(navigation.openVaultSnippet);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
38
components/ai/toolArtifacts/VaultArtifactToolResult.tsx
Normal file
38
components/ai/toolArtifacts/VaultArtifactToolResult.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
||||
import { VaultArtifactCard } from './VaultArtifactCard';
|
||||
import { formatVaultToolTooltip } from './formatVaultToolTooltip';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
interface VaultArtifactToolResultProps {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const VaultArtifactToolResult: React.FC<VaultArtifactToolResultProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const tooltip = formatVaultToolTooltip(toolName, args, result, isError);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<VaultArtifactCard artifact={artifact} toolName={toolName} className="cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-md whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
25
components/ai/toolArtifacts/formatTerminalToolTooltip.ts
Normal file
25
components/ai/toolArtifacts/formatTerminalToolTooltip.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
function serialize(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTerminalToolTooltip(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const sections = [
|
||||
`Tool: ${toolName}`,
|
||||
args ? `Args:\n${serialize(args)}` : null,
|
||||
result ? `Result:\n${serialize(result)}` : null,
|
||||
isError ? 'Status: error' : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
18
components/ai/toolArtifacts/formatVaultToolTooltip.test.ts
Normal file
18
components/ai/toolArtifacts/formatVaultToolTooltip.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatVaultToolTooltip } from './formatVaultToolTooltip.ts';
|
||||
|
||||
test('formatVaultToolTooltip joins tool name, args, and result with line breaks', () => {
|
||||
const text = formatVaultToolTooltip(
|
||||
'vault_notes_create',
|
||||
{ title: 'Runbook' },
|
||||
{ ok: true, note: { id: 'n1', title: 'Runbook' } },
|
||||
);
|
||||
|
||||
assert.match(text, /^vault_notes_create/);
|
||||
assert.match(text, /Arguments:/);
|
||||
assert.match(text, /"title": "Runbook"/);
|
||||
assert.match(text, /Result:/);
|
||||
assert.match(text, /"ok": true/);
|
||||
});
|
||||
37
components/ai/toolArtifacts/formatVaultToolTooltip.ts
Normal file
37
components/ai/toolArtifacts/formatVaultToolTooltip.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
function stringifyToolPayload(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatVaultToolTooltip(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const lines: string[] = [toolName];
|
||||
|
||||
if (args && Object.keys(args).length > 0) {
|
||||
lines.push('', 'Arguments:', stringifyToolPayload(args));
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
lines.push('', isError ? 'Error:' : 'Result:', stringifyToolPayload(result));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
82
components/ai/toolArtifacts/terminalToolArtifact.test.ts
Normal file
82
components/ai/toolArtifacts/terminalToolArtifact.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseTerminalToolArtifact } from './terminalToolArtifact.ts';
|
||||
|
||||
test('parseTerminalToolArtifact maps terminal context reads', () => {
|
||||
const artifact = parseTerminalToolArtifact('terminal_read_context', {
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
content: 'alpha\nbeta\ngamma',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: true,
|
||||
source: 'live',
|
||||
});
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: true,
|
||||
source: 'live',
|
||||
preview: 'alpha\nbeta\ngamma',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseTerminalToolArtifact maps errors', () => {
|
||||
const artifact = parseTerminalToolArtifact('terminal_read_context', {
|
||||
ok: false,
|
||||
error: 'Terminal session not found.',
|
||||
});
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'error',
|
||||
message: 'Terminal session not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseTerminalToolArtifact unwraps Claude MCP text result envelopes', () => {
|
||||
const artifact = parseTerminalToolArtifact('mcp__netcatty-remote-hosts__terminal_read_context', JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
content: 'alpha\nbeta',
|
||||
totalLines: 20,
|
||||
startLine: 19,
|
||||
endLine: 20,
|
||||
returnedLines: 2,
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 20,
|
||||
startLine: 19,
|
||||
endLine: 20,
|
||||
returnedLines: 2,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: undefined,
|
||||
preview: 'alpha\nbeta',
|
||||
});
|
||||
});
|
||||
81
components/ai/toolArtifacts/terminalToolArtifact.ts
Normal file
81
components/ai/toolArtifacts/terminalToolArtifact.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { normalizeArtifactToolName } from './toolArtifactNames';
|
||||
import { parseResultPayload } from './toolArtifactResultPayload';
|
||||
|
||||
export type TerminalToolArtifact =
|
||||
| {
|
||||
kind: 'terminal.context';
|
||||
sessionId: string;
|
||||
label?: string;
|
||||
range: string;
|
||||
totalLines: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
returnedLines: number;
|
||||
hasMoreBefore: boolean;
|
||||
hasMoreAfter: boolean;
|
||||
source?: string;
|
||||
preview: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const TERMINAL_ARTIFACT_TOOL_NAMES = new Set([
|
||||
'terminal_read_context',
|
||||
]);
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
export function parseTerminalToolArtifact(
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
): TerminalToolArtifact | null {
|
||||
const normalizedToolName = normalizeArtifactToolName(toolName);
|
||||
if (!normalizedToolName || !TERMINAL_ARTIFACT_TOOL_NAMES.has(normalizedToolName)) return null;
|
||||
|
||||
const payload = parseResultPayload(result);
|
||||
if (!payload) return null;
|
||||
|
||||
if (payload.ok === false || payload.isError === true || typeof payload.error === 'string') {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: readString(payload.error) ?? 'Terminal context read failed.',
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = readString(payload.sessionId);
|
||||
const content = typeof payload.content === 'string' ? payload.content : '';
|
||||
const totalLines = readNumber(payload.totalLines);
|
||||
const startLine = readNumber(payload.startLine);
|
||||
const endLine = readNumber(payload.endLine);
|
||||
const returnedLines = readNumber(payload.returnedLines);
|
||||
if (!sessionId || totalLines == null || startLine == null || endLine == null || returnedLines == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'terminal.context',
|
||||
sessionId,
|
||||
label: readString(payload.label),
|
||||
range: readString(payload.range) ?? 'viewport',
|
||||
totalLines,
|
||||
startLine,
|
||||
endLine,
|
||||
returnedLines,
|
||||
hasMoreBefore: readBoolean(payload.hasMoreBefore),
|
||||
hasMoreAfter: readBoolean(payload.hasMoreAfter),
|
||||
source: readString(payload.source),
|
||||
preview: content.split('\n').slice(0, 6).join('\n'),
|
||||
};
|
||||
}
|
||||
53
components/ai/toolArtifacts/toolArtifactNames.test.ts
Normal file
53
components/ai/toolArtifacts/toolArtifactNames.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
inferArtifactToolNameFromCliArgs,
|
||||
normalizeArtifactToolName,
|
||||
} from './toolArtifactNames.ts';
|
||||
|
||||
test('normalizeArtifactToolName unwraps MCP server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('mcp__netcatty__vault_notes_create'),
|
||||
'vault_notes_create',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('mcp__netcatty-remote-hosts__terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeArtifactToolName unwraps OpenCode server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_vault_notes_get'),
|
||||
'vault_notes_get',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_vault_hosts_list'),
|
||||
'vault_hosts_list',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeArtifactToolName unwraps Copilot server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts-vault_notes_list'),
|
||||
'vault_notes_list',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts-terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('inferArtifactToolNameFromCliArgs maps Netcatty CLI artifact commands', () => {
|
||||
assert.equal(
|
||||
inferArtifactToolNameFromCliArgs({
|
||||
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" vault host get --host-id host_1 --json'`,
|
||||
}),
|
||||
'host_get',
|
||||
);
|
||||
});
|
||||
105
components/ai/toolArtifacts/toolArtifactNames.ts
Normal file
105
components/ai/toolArtifacts/toolArtifactNames.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
const MCP_TOOL_NAME_PREFIX = 'mcp__';
|
||||
|
||||
const KNOWN_ARTIFACT_TOOL_NAMES = [
|
||||
'terminal_read_context',
|
||||
'vault_notes_create',
|
||||
'vault_notes_update',
|
||||
'vault_notes_get',
|
||||
'vault_notes_list',
|
||||
'vault_hosts_create',
|
||||
'vault_hosts_import',
|
||||
'vault_hosts_list',
|
||||
'host_get',
|
||||
'snippets_list',
|
||||
'snippets_get',
|
||||
'snippets_create',
|
||||
'snippets_update',
|
||||
'snippets_delete',
|
||||
'snippets_run',
|
||||
'scripts_list',
|
||||
'scripts_get',
|
||||
'scripts_create',
|
||||
'scripts_update',
|
||||
'scripts_delete',
|
||||
'scripts_run',
|
||||
'scripts_reference',
|
||||
'scripts_runs_list',
|
||||
'scripts_run_stop',
|
||||
'scripts_run_pause',
|
||||
'scripts_run_resume',
|
||||
'scripts_targets_set',
|
||||
] as const;
|
||||
|
||||
const CLI_ARTIFACT_TOOL_NAMES = new Map<string, string>([
|
||||
['vault host get', 'host_get'],
|
||||
]);
|
||||
|
||||
function readCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = args.command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
|
||||
const isShellWrap =
|
||||
raw.length >= 3 &&
|
||||
/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/.test(String(raw[0] ?? '')) &&
|
||||
/^-l?c$/.test(String(raw[1] ?? ''));
|
||||
|
||||
return isShellWrap
|
||||
? String(raw[raw.length - 1] ?? '') || null
|
||||
: raw.map((part) => String(part)).join(' ');
|
||||
}
|
||||
|
||||
function unwrapShellCommand(command: string): string {
|
||||
const strWrap = command.match(
|
||||
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
|
||||
);
|
||||
return strWrap ? strWrap[2] : command;
|
||||
}
|
||||
|
||||
function stripWrappingQuote(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length < 2) return trimmed;
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
return (first === last && (first === '"' || first === "'"))
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
export function normalizeArtifactToolName(toolName: string | undefined): string | undefined {
|
||||
const trimmed = toolName?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
if (trimmed.startsWith(MCP_TOOL_NAME_PREFIX)) {
|
||||
const segments = trimmed.split('__').filter(Boolean);
|
||||
return segments[segments.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
const prefixedArtifactToolName = KNOWN_ARTIFACT_TOOL_NAMES.find((candidate) => (
|
||||
trimmed.endsWith(`_${candidate}`) || trimmed.endsWith(`-${candidate}`)
|
||||
));
|
||||
if (prefixedArtifactToolName) return prefixedArtifactToolName;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function inferArtifactToolNameFromCliArgs(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const command = readCommandString(args);
|
||||
if (!command) return undefined;
|
||||
|
||||
const unwrapped = unwrapShellCommand(command);
|
||||
const cliMatch = unwrapped.match(/(?:^|\s|["'])(?:\S*\/)?netcatty-tool-cli(?:\.(?:cjs|cmd))?(?=["'\s]|$)([\s\S]*)$/);
|
||||
if (!cliMatch) return undefined;
|
||||
|
||||
const afterCli = stripWrappingQuote(cliMatch[1] ?? '').replace(/^["']?\s*/, '');
|
||||
const commandKey = afterCli
|
||||
.split(/\s+/)
|
||||
.filter((part) => part && !part.startsWith('-'))
|
||||
.slice(0, 3)
|
||||
.join(' ');
|
||||
|
||||
return CLI_ARTIFACT_TOOL_NAMES.get(commandKey);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseResultPayload } from './toolArtifactResultPayload.ts';
|
||||
|
||||
test('parseResultPayload unwraps MCP text content arrays', () => {
|
||||
assert.deepEqual(parseResultPayload(JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, value: 42 }),
|
||||
},
|
||||
])), {
|
||||
ok: true,
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps MCP content objects', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, label: 'prod' }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
label: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps Copilot content string objects', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
content: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
detailedContent: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
notes: [{ id: 'note-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps Copilot contents arrays', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, hosts: [{ id: 'host-1' }] }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
hosts: [{ id: 'host-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload preserves plain result objects', () => {
|
||||
const payload = { ok: true, name: 'plain' };
|
||||
assert.equal(parseResultPayload(payload), payload);
|
||||
});
|
||||
60
components/ai/toolArtifacts/toolArtifactResultPayload.ts
Normal file
60
components/ai/toolArtifacts/toolArtifactResultPayload.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return parseResultPayload(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeJson(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
}
|
||||
|
||||
function unwrapMcpTextEnvelope(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const textPart = value.find((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return record?.type === 'text' && typeof record.text === 'string';
|
||||
});
|
||||
const record = asRecord(textPart);
|
||||
return typeof record?.text === 'string' ? record.text : value;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (record?.type === 'text' && typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
if (Array.isArray(record?.content)) {
|
||||
return unwrapMcpTextEnvelope(record.content);
|
||||
}
|
||||
if (typeof record?.content === 'string' && looksLikeJson(record.content)) {
|
||||
return record.content;
|
||||
}
|
||||
if (typeof record?.detailedContent === 'string' && looksLikeJson(record.detailedContent)) {
|
||||
return record.detailedContent;
|
||||
}
|
||||
if (Array.isArray(record?.contents)) {
|
||||
return unwrapMcpTextEnvelope(record.contents);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseResultPayload(result: unknown): Record<string, unknown> | null {
|
||||
if (result == null) return null;
|
||||
const unwrapped = unwrapMcpTextEnvelope(result);
|
||||
|
||||
if (typeof unwrapped === 'string') {
|
||||
return parseJsonRecord(unwrapped);
|
||||
}
|
||||
|
||||
return asRecord(unwrapped);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveVaultArtifactVisualKind } from './vaultArtifactPresentation.tsx';
|
||||
|
||||
test('resolveVaultArtifactVisualKind distinguishes note vs host tools', () => {
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{ kind: 'vault.note', noteId: 'n1', title: 'Doc' },
|
||||
'vault_notes_create',
|
||||
),
|
||||
'noteCreate',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{ kind: 'vault.host', hostId: 'h1', label: 'Web', hostname: '10.0.0.1' },
|
||||
'host_get',
|
||||
),
|
||||
'host',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: 'vault_hosts_import',
|
||||
addedCount: 2,
|
||||
preview: [],
|
||||
},
|
||||
'vault_hosts_import',
|
||||
),
|
||||
'hostImport',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: 'vault_hosts_create',
|
||||
addedCount: 2,
|
||||
preview: [],
|
||||
},
|
||||
'vault_hosts_create',
|
||||
),
|
||||
'hostCreate',
|
||||
);
|
||||
});
|
||||
200
components/ai/toolArtifacts/vaultArtifactPresentation.tsx
Normal file
200
components/ai/toolArtifacts/vaultArtifactPresentation.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
FileCode,
|
||||
FilePenLine,
|
||||
FileText,
|
||||
FolderInput,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
ListChecks,
|
||||
NotebookPen,
|
||||
Pause,
|
||||
Play,
|
||||
Server,
|
||||
ServerCog,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
export type VaultArtifactVisualKind =
|
||||
| 'noteCreate'
|
||||
| 'noteUpdate'
|
||||
| 'noteRead'
|
||||
| 'noteList'
|
||||
| 'host'
|
||||
| 'hostCreate'
|
||||
| 'hostImport'
|
||||
| 'hostList'
|
||||
| 'snippet'
|
||||
| 'snippetCreate'
|
||||
| 'snippetUpdate'
|
||||
| 'snippetList'
|
||||
| 'snippetRun'
|
||||
| 'snippetDeleted'
|
||||
| 'script'
|
||||
| 'scriptCreate'
|
||||
| 'scriptUpdate'
|
||||
| 'scriptList'
|
||||
| 'scriptRun'
|
||||
| 'scriptDeleted'
|
||||
| 'scriptRuns'
|
||||
| 'scriptAction'
|
||||
| 'scriptReference'
|
||||
| 'error';
|
||||
|
||||
const ARTIFACT_ICON_SIZE = 18;
|
||||
|
||||
const VISUAL_STYLES: Record<VaultArtifactVisualKind, { wrapper: string; icon: string }> = {
|
||||
noteCreate: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
noteUpdate: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
noteRead: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/80' },
|
||||
noteList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
host: { wrapper: 'bg-emerald-500/12', icon: 'text-emerald-400' },
|
||||
hostCreate: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
hostImport: { wrapper: 'bg-amber-500/12', icon: 'text-amber-400' },
|
||||
hostList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
snippet: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
snippetCreate: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
snippetUpdate: { wrapper: 'bg-sky-500/10', icon: 'text-sky-300/90' },
|
||||
snippetList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
snippetRun: { wrapper: 'bg-sky-500/10', icon: 'text-sky-300/90' },
|
||||
snippetDeleted: { wrapper: 'bg-muted/25', icon: 'text-muted-foreground/60' },
|
||||
script: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
scriptCreate: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
scriptUpdate: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
scriptRun: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptDeleted: { wrapper: 'bg-muted/25', icon: 'text-muted-foreground/60' },
|
||||
scriptRuns: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
scriptAction: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptReference: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
error: { wrapper: 'bg-destructive/10', icon: 'text-destructive/80' },
|
||||
};
|
||||
|
||||
export function resolveVaultArtifactVisualKind(
|
||||
artifact: VaultToolArtifact,
|
||||
toolName?: string,
|
||||
): VaultArtifactVisualKind {
|
||||
if (artifact.kind === 'error') return 'error';
|
||||
|
||||
if (artifact.kind === 'vault.note') {
|
||||
if (toolName === 'vault_notes_create') return 'noteCreate';
|
||||
if (toolName === 'vault_notes_update') return 'noteUpdate';
|
||||
return 'noteRead';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.host') return 'host';
|
||||
|
||||
if (artifact.kind === 'vault.hosts.batch') {
|
||||
if (artifact.sourceTool === 'vault_hosts_import' || toolName === 'vault_hosts_import') {
|
||||
return 'hostImport';
|
||||
}
|
||||
return 'hostCreate';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.summary') {
|
||||
if (artifact.section === 'notes') return 'noteList';
|
||||
if (artifact.section === 'hosts') return 'hostList';
|
||||
if (artifact.section === 'snippets') return 'snippetList';
|
||||
return 'scriptList';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.snippet') {
|
||||
if (toolName === 'snippets_create') return 'snippetCreate';
|
||||
if (toolName === 'snippets_update') return 'snippetUpdate';
|
||||
return 'snippet';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.snippet.deleted') return 'snippetDeleted';
|
||||
if (artifact.kind === 'vault.snippet.run') return 'snippetRun';
|
||||
|
||||
if (artifact.kind === 'vault.script') {
|
||||
if (toolName === 'scripts_create') return 'scriptCreate';
|
||||
if (toolName === 'scripts_update' || toolName === 'scripts_targets_set') return 'scriptUpdate';
|
||||
return 'script';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.script.deleted') return 'scriptDeleted';
|
||||
if (artifact.kind === 'vault.script.run') return 'scriptRun';
|
||||
if (artifact.kind === 'vault.script.runs') return 'scriptRuns';
|
||||
if (artifact.kind === 'vault.script.action') return 'scriptAction';
|
||||
if (artifact.kind === 'vault.script.reference') return 'scriptReference';
|
||||
|
||||
return 'host';
|
||||
}
|
||||
|
||||
function renderVisualIcon(kind: VaultArtifactVisualKind): React.ReactNode {
|
||||
const className = VISUAL_STYLES[kind].icon;
|
||||
switch (kind) {
|
||||
case 'noteCreate':
|
||||
return <NotebookPen size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteUpdate':
|
||||
return <FilePenLine size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteRead':
|
||||
return <FileText size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteList':
|
||||
return <Library size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'host':
|
||||
return <Server size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostCreate':
|
||||
return <ServerCog size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostImport':
|
||||
return <FolderInput size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostList':
|
||||
return <LayoutGrid size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippet':
|
||||
case 'snippetCreate':
|
||||
case 'snippetUpdate':
|
||||
case 'snippetRun':
|
||||
return <Zap size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippetList':
|
||||
return <SquareTerminal size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippetDeleted':
|
||||
return <Trash2 size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'script':
|
||||
case 'scriptCreate':
|
||||
case 'scriptUpdate':
|
||||
case 'scriptReference':
|
||||
return <FileCode size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptList':
|
||||
case 'scriptRuns':
|
||||
return <ListChecks size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptRun':
|
||||
return <Play size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptDeleted':
|
||||
return <Trash2 size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptAction':
|
||||
return <Pause size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'error':
|
||||
return <AlertCircle size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
default:
|
||||
return <BookOpen size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function VaultArtifactIcon({
|
||||
artifact,
|
||||
toolName,
|
||||
}: {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName?: string;
|
||||
}) {
|
||||
const kind = resolveVaultArtifactVisualKind(artifact, toolName);
|
||||
const styles = VISUAL_STYLES[kind];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
||||
styles.wrapper,
|
||||
)}
|
||||
>
|
||||
{renderVisualIcon(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
141
components/ai/toolArtifacts/vaultToolArtifact.test.ts
Normal file
141
components/ai/toolArtifacts/vaultToolArtifact.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseVaultToolArtifact } from './vaultToolArtifact.ts';
|
||||
|
||||
test('parseVaultToolArtifact maps note create results', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_notes_create', {
|
||||
ok: true,
|
||||
note: { id: 'note-1', title: 'Runbook', group: 'ops/prod' },
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
group: 'ops/prod',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps single host create to host artifact', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_hosts_create', {
|
||||
ok: true,
|
||||
addedCount: 1,
|
||||
previewHosts: [
|
||||
{ id: 'host-1', label: 'Dokploy', hostname: '10.2.0.209' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.host',
|
||||
hostId: 'host-1',
|
||||
label: 'Dokploy',
|
||||
hostname: '10.2.0.209',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps host batch import results', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_hosts_create', {
|
||||
ok: true,
|
||||
addedCount: 2,
|
||||
previewHosts: [
|
||||
{ id: 'host-1', label: 'Web', hostname: '10.0.0.1' },
|
||||
{ id: 'host-2', hostname: 'db.internal' },
|
||||
],
|
||||
});
|
||||
assert.equal(artifact?.kind, 'vault.hosts.batch');
|
||||
if (artifact?.kind !== 'vault.hosts.batch') return;
|
||||
assert.equal(artifact.addedCount, 2);
|
||||
assert.equal(artifact.preview.length, 2);
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps host get results', () => {
|
||||
const artifact = parseVaultToolArtifact('host_get', {
|
||||
ok: true,
|
||||
host: { id: 'host-9', label: 'Prod', hostname: 'prod.example.com', port: 22 },
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.host',
|
||||
hostId: 'host-9',
|
||||
label: 'Prod',
|
||||
hostname: 'prod.example.com',
|
||||
port: 22,
|
||||
group: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps errors', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_notes_get', {
|
||||
ok: false,
|
||||
error: 'Vault note "missing" was not found.',
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'error',
|
||||
message: 'Vault note "missing" was not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps script create results', () => {
|
||||
const artifact = parseVaultToolArtifact('scripts_create', {
|
||||
ok: true,
|
||||
script: {
|
||||
id: 'script-1',
|
||||
label: 'Disk cleanup',
|
||||
language: 'javascript',
|
||||
package: 'maintenance',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.script',
|
||||
scriptId: 'script-1',
|
||||
label: 'Disk cleanup',
|
||||
package: 'maintenance',
|
||||
language: 'javascript',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps snippet list results', () => {
|
||||
const artifact = parseVaultToolArtifact('snippets_list', {
|
||||
ok: true,
|
||||
snippets: [{ id: 's1', label: 'Restart nginx' }],
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.summary',
|
||||
section: 'snippets',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps script run results', () => {
|
||||
const artifact = parseVaultToolArtifact('scripts_run', {
|
||||
ok: true,
|
||||
snippetId: 'script-1',
|
||||
runId: 'run-9',
|
||||
kind: 'script',
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.script.run',
|
||||
scriptId: 'script-1',
|
||||
runId: 'run-9',
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact unwraps Claude MCP text result envelopes', () => {
|
||||
const artifact = parseVaultToolArtifact('mcp__netcatty-remote-hosts__vault_notes_list', JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
notes: [
|
||||
{ id: 'note-1', title: 'Docker Compose' },
|
||||
{ id: 'note-2', title: 'Dokploy' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.summary',
|
||||
section: 'notes',
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
330
components/ai/toolArtifacts/vaultToolArtifact.ts
Normal file
330
components/ai/toolArtifacts/vaultToolArtifact.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { normalizeArtifactToolName } from './toolArtifactNames';
|
||||
import { parseResultPayload } from './toolArtifactResultPayload';
|
||||
|
||||
export type VaultSummarySection = 'notes' | 'hosts' | 'snippets' | 'scripts';
|
||||
|
||||
export type VaultToolArtifact =
|
||||
| {
|
||||
kind: 'vault.note';
|
||||
noteId: string;
|
||||
title: string;
|
||||
group?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.host';
|
||||
hostId: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
group?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.hosts.batch';
|
||||
sourceTool?: 'vault_hosts_create' | 'vault_hosts_import';
|
||||
addedCount: number;
|
||||
dryRun?: boolean;
|
||||
preview: Array<{ hostId?: string; label?: string; hostname?: string }>;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.summary';
|
||||
section: VaultSummarySection;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet';
|
||||
snippetId: string;
|
||||
label: string;
|
||||
package?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script';
|
||||
scriptId: string;
|
||||
label: string;
|
||||
package?: string;
|
||||
language?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet.deleted';
|
||||
snippetId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.deleted';
|
||||
scriptId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet.run';
|
||||
snippetId: string;
|
||||
command?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.run';
|
||||
scriptId: string;
|
||||
runId: string;
|
||||
status?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.runs';
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.action';
|
||||
action: 'stop' | 'pause' | 'resume';
|
||||
runId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.reference';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const VAULT_ARTIFACT_TOOL_NAMES = new Set([
|
||||
'vault_notes_create',
|
||||
'vault_notes_update',
|
||||
'vault_notes_get',
|
||||
'vault_notes_list',
|
||||
'vault_hosts_create',
|
||||
'vault_hosts_import',
|
||||
'vault_hosts_list',
|
||||
'host_get',
|
||||
'snippets_list',
|
||||
'snippets_get',
|
||||
'snippets_create',
|
||||
'snippets_update',
|
||||
'snippets_delete',
|
||||
'snippets_run',
|
||||
'scripts_list',
|
||||
'scripts_get',
|
||||
'scripts_create',
|
||||
'scripts_update',
|
||||
'scripts_delete',
|
||||
'scripts_run',
|
||||
'scripts_reference',
|
||||
'scripts_runs_list',
|
||||
'scripts_run_stop',
|
||||
'scripts_run_pause',
|
||||
'scripts_run_resume',
|
||||
'scripts_targets_set',
|
||||
]);
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseNoteArtifact(note: unknown): VaultToolArtifact | null {
|
||||
if (!note || typeof note !== 'object') return null;
|
||||
const record = note as Record<string, unknown>;
|
||||
const noteId = readString(record.id);
|
||||
const title = readString(record.title);
|
||||
if (!noteId || !title) return null;
|
||||
return {
|
||||
kind: 'vault.note',
|
||||
noteId,
|
||||
title,
|
||||
group: readString(record.group),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHostArtifact(host: unknown): VaultToolArtifact | null {
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
const record = host as Record<string, unknown>;
|
||||
const hostId = readString(record.id);
|
||||
const hostname = readString(record.hostname);
|
||||
if (!hostId || !hostname) return null;
|
||||
return {
|
||||
kind: 'vault.host',
|
||||
hostId,
|
||||
label: readString(record.label) ?? hostname,
|
||||
hostname,
|
||||
port: readNumber(record.port),
|
||||
group: readString(record.group),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnippetArtifact(snippet: unknown): VaultToolArtifact | null {
|
||||
if (!snippet || typeof snippet !== 'object') return null;
|
||||
const record = snippet as Record<string, unknown>;
|
||||
const snippetId = readString(record.id);
|
||||
const label = readString(record.label);
|
||||
if (!snippetId || !label) return null;
|
||||
return {
|
||||
kind: 'vault.snippet',
|
||||
snippetId,
|
||||
label,
|
||||
package: readString(record.package),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScriptArtifact(script: unknown): VaultToolArtifact | null {
|
||||
if (!script || typeof script !== 'object') return null;
|
||||
const record = script as Record<string, unknown>;
|
||||
const scriptId = readString(record.id);
|
||||
const label = readString(record.label);
|
||||
if (!scriptId || !label) return null;
|
||||
return {
|
||||
kind: 'vault.script',
|
||||
scriptId,
|
||||
label,
|
||||
package: readString(record.package),
|
||||
language: readString(record.language),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePreviewHosts(value: unknown): Array<{ hostId?: string; label?: string; hostname?: string }> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const hostname = readString(record.hostname);
|
||||
if (!hostname) return null;
|
||||
return {
|
||||
hostId: readString(record.id),
|
||||
label: readString(record.label),
|
||||
hostname,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { hostId?: string; label?: string; hostname: string } => entry !== null);
|
||||
}
|
||||
|
||||
export function isVaultArtifactToolName(toolName: string): boolean {
|
||||
const normalized = normalizeArtifactToolName(toolName);
|
||||
return normalized ? VAULT_ARTIFACT_TOOL_NAMES.has(normalized) : false;
|
||||
}
|
||||
|
||||
export function parseVaultToolArtifact(
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
): VaultToolArtifact | null {
|
||||
const normalizedToolName = normalizeArtifactToolName(toolName);
|
||||
if (!normalizedToolName || !VAULT_ARTIFACT_TOOL_NAMES.has(normalizedToolName)) return null;
|
||||
|
||||
const payload = parseResultPayload(result);
|
||||
if (!payload) return null;
|
||||
|
||||
if (payload.ok === false || payload.isError === true) {
|
||||
const message = readString(payload.error) ?? 'Operation failed.';
|
||||
return { kind: 'error', message };
|
||||
}
|
||||
|
||||
switch (normalizedToolName) {
|
||||
case 'vault_notes_create':
|
||||
case 'vault_notes_update':
|
||||
case 'vault_notes_get':
|
||||
return parseNoteArtifact(payload.note);
|
||||
case 'vault_notes_list': {
|
||||
const notes = Array.isArray(payload.notes) ? payload.notes : [];
|
||||
return { kind: 'vault.summary', section: 'notes', count: notes.length };
|
||||
}
|
||||
case 'vault_hosts_create':
|
||||
case 'vault_hosts_import': {
|
||||
const preview = parsePreviewHosts(payload.previewHosts);
|
||||
const addedCount = readNumber(payload.addedCount)
|
||||
?? (payload.dryRun === true ? readNumber(payload.validCount) : undefined)
|
||||
?? preview.length;
|
||||
if (addedCount <= 0 && preview.length === 0) return null;
|
||||
|
||||
const dryRun = payload.dryRun === true;
|
||||
if (!dryRun && addedCount === 1 && preview.length === 1 && preview[0].hostname) {
|
||||
const single = preview[0];
|
||||
if (single.hostId) {
|
||||
return {
|
||||
kind: 'vault.host',
|
||||
hostId: single.hostId,
|
||||
label: single.label ?? single.hostname,
|
||||
hostname: single.hostname,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: normalizedToolName === 'vault_hosts_import' ? 'vault_hosts_import' : 'vault_hosts_create',
|
||||
addedCount,
|
||||
dryRun,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
case 'vault_hosts_list': {
|
||||
const hosts = Array.isArray(payload.hosts) ? payload.hosts : [];
|
||||
return { kind: 'vault.summary', section: 'hosts', count: hosts.length };
|
||||
}
|
||||
case 'host_get':
|
||||
return parseHostArtifact(payload.host);
|
||||
case 'snippets_list': {
|
||||
const snippets = Array.isArray(payload.snippets) ? payload.snippets : [];
|
||||
return { kind: 'vault.summary', section: 'snippets', count: snippets.length };
|
||||
}
|
||||
case 'snippets_get':
|
||||
case 'snippets_create':
|
||||
case 'snippets_update':
|
||||
return parseSnippetArtifact(payload.snippet);
|
||||
case 'snippets_delete': {
|
||||
const snippetId = readString(payload.snippetId);
|
||||
if (!snippetId) return null;
|
||||
return { kind: 'vault.snippet.deleted', snippetId };
|
||||
}
|
||||
case 'snippets_run': {
|
||||
const snippetId = readString(payload.snippetId);
|
||||
if (!snippetId) return null;
|
||||
return {
|
||||
kind: 'vault.snippet.run',
|
||||
snippetId,
|
||||
command: readString(payload.command),
|
||||
};
|
||||
}
|
||||
case 'scripts_list': {
|
||||
const scripts = Array.isArray(payload.scripts) ? payload.scripts : [];
|
||||
return { kind: 'vault.summary', section: 'scripts', count: scripts.length };
|
||||
}
|
||||
case 'scripts_get':
|
||||
case 'scripts_create':
|
||||
case 'scripts_update':
|
||||
case 'scripts_targets_set':
|
||||
return parseScriptArtifact(payload.script);
|
||||
case 'scripts_delete': {
|
||||
const scriptId = readString(payload.scriptId);
|
||||
if (!scriptId) return null;
|
||||
return { kind: 'vault.script.deleted', scriptId };
|
||||
}
|
||||
case 'scripts_run': {
|
||||
const scriptId = readString(payload.snippetId) ?? readString(payload.scriptId);
|
||||
const runId = readString(payload.runId);
|
||||
if (!scriptId || !runId) return null;
|
||||
return {
|
||||
kind: 'vault.script.run',
|
||||
scriptId,
|
||||
runId,
|
||||
status: readString(payload.status),
|
||||
};
|
||||
}
|
||||
case 'scripts_reference':
|
||||
return { kind: 'vault.script.reference' };
|
||||
case 'scripts_runs_list': {
|
||||
const runs = Array.isArray(payload.runs) ? payload.runs : [];
|
||||
return { kind: 'vault.script.runs', count: runs.length };
|
||||
}
|
||||
case 'scripts_run_stop':
|
||||
return parseScriptRunAction(payload, 'stop');
|
||||
case 'scripts_run_pause':
|
||||
return parseScriptRunAction(payload, 'pause');
|
||||
case 'scripts_run_resume':
|
||||
return parseScriptRunAction(payload, 'resume');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseScriptRunAction(
|
||||
payload: Record<string, unknown>,
|
||||
action: 'stop' | 'pause' | 'resume',
|
||||
): VaultToolArtifact | null {
|
||||
const runId = readString(payload.runId);
|
||||
if (!runId) return null;
|
||||
return { kind: 'vault.script.action', action, runId };
|
||||
}
|
||||
Reference in New Issue
Block a user