Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID. - Create tests for runtime context capabilities, validating the injection of enabled skill capabilities. - Add tests for skill capability parsing, including classification and command example extraction. - Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements. - Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
@@ -11,10 +11,17 @@ import {
|
||||
Link2,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Search,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import type { ChatMessageItem } from './types';
|
||||
import type { ToolStatus } from '../../shared/chat-model';
|
||||
import type {
|
||||
AttachedFileMeta,
|
||||
ToolArtifact,
|
||||
ToolRenderHints,
|
||||
ToolResultPayload,
|
||||
ToolStatus,
|
||||
} from '../../shared/chat-model';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { apiOpenSkillPath, apiOpenSkillReadme } from '../../lib/skills-api';
|
||||
import ChatEmptyState from './ChatEmptyState';
|
||||
@@ -118,10 +125,112 @@ type ToolDetail = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ToolUiLabels = {
|
||||
analysisOverview: string;
|
||||
structuredData: string;
|
||||
searchOverview?: string;
|
||||
files: string;
|
||||
sheets: string;
|
||||
rows: string;
|
||||
cols: string;
|
||||
columns: string;
|
||||
preview: string;
|
||||
findings: string;
|
||||
artifacts: string;
|
||||
file: string;
|
||||
path: string;
|
||||
engine: string;
|
||||
provider?: string;
|
||||
query?: string;
|
||||
answer?: string;
|
||||
results?: string;
|
||||
source?: string;
|
||||
score?: string;
|
||||
responseTime?: string;
|
||||
installCommand?: string;
|
||||
notAvailable: string;
|
||||
emptyPreview: string;
|
||||
moreColumns: string;
|
||||
topLevelResult: string;
|
||||
};
|
||||
|
||||
type NormalizedToolResult = {
|
||||
payload: ToolResultPayload | null;
|
||||
renderHints?: ToolRenderHints;
|
||||
structuredData?: unknown;
|
||||
artifacts: ToolArtifact[];
|
||||
};
|
||||
|
||||
type StructuredPreview = {
|
||||
details: ToolDetail[];
|
||||
table?: {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
};
|
||||
};
|
||||
|
||||
type SpreadsheetSheetPreview = {
|
||||
name: string;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
columns: string[];
|
||||
previewRows: Array<Record<string, unknown>>;
|
||||
findings: string[];
|
||||
};
|
||||
|
||||
type SpreadsheetReportPreview = {
|
||||
key: string;
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
engine?: string;
|
||||
totalRows?: number;
|
||||
sheetCount?: number;
|
||||
sheets: SpreadsheetSheetPreview[];
|
||||
};
|
||||
|
||||
type SearchResultPreview = {
|
||||
provider?: string;
|
||||
query?: string;
|
||||
answer?: string;
|
||||
responseTimeMs?: number;
|
||||
resultCount?: number;
|
||||
results: Array<{
|
||||
title: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
age?: string;
|
||||
publishedAt?: string;
|
||||
installCommand?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isAttachedFileMeta(value: unknown): value is AttachedFileMeta {
|
||||
return isRecord(value)
|
||||
&& typeof value.fileName === 'string'
|
||||
&& typeof value.mimeType === 'string';
|
||||
}
|
||||
|
||||
function isToolArtifact(value: unknown): value is ToolArtifact {
|
||||
return isRecord(value)
|
||||
&& (
|
||||
typeof value.kind === 'string'
|
||||
|| typeof value.name === 'string'
|
||||
|| typeof value.label === 'string'
|
||||
|| typeof value.filePath === 'string'
|
||||
|| typeof value.uri === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function getRecordString(value: unknown, ...keys: string[]): string | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
@@ -135,6 +244,396 @@ function getRecordString(value: unknown, ...keys: string[]): string | undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRecordNumber(value: unknown, ...keys: string[]): number | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
for (const key of keys) {
|
||||
const field = value[key];
|
||||
if (typeof field === 'number' && Number.isFinite(field)) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getToolUiLabels(locale: string): ToolUiLabels {
|
||||
if (locale.startsWith('zh')) {
|
||||
return {
|
||||
analysisOverview: '分析概览',
|
||||
structuredData: '结构化结果',
|
||||
files: '文件',
|
||||
sheets: '工作表',
|
||||
rows: '行',
|
||||
cols: '列',
|
||||
columns: '字段',
|
||||
preview: '预览',
|
||||
findings: '发现',
|
||||
artifacts: '相关产物',
|
||||
file: '文件',
|
||||
path: '路径',
|
||||
engine: '解析引擎',
|
||||
notAvailable: '暂无',
|
||||
emptyPreview: '没有可展示的预览内容',
|
||||
moreColumns: '更多字段',
|
||||
topLevelResult: '结果',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
analysisOverview: 'Analysis overview',
|
||||
structuredData: 'Structured result',
|
||||
files: 'Files',
|
||||
sheets: 'Sheets',
|
||||
rows: 'Rows',
|
||||
cols: 'Cols',
|
||||
columns: 'Columns',
|
||||
preview: 'Preview',
|
||||
findings: 'Findings',
|
||||
artifacts: 'Artifacts',
|
||||
file: 'File',
|
||||
path: 'Path',
|
||||
engine: 'Engine',
|
||||
notAvailable: 'N/A',
|
||||
emptyPreview: 'No preview available',
|
||||
moreColumns: 'More columns',
|
||||
topLevelResult: 'Result',
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumberValue(value: number, locale: string, maximumFractionDigits = 2): string {
|
||||
return new Intl.NumberFormat(locale.startsWith('zh') ? 'zh-CN' : undefined, {
|
||||
maximumFractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatStructuredValue(value: unknown, locale: string, labels: ToolUiLabels): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return labels.notAvailable;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return formatNumberValue(value, locale);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const compact = value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (typeof item === 'number') return formatNumberValue(item, locale);
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (compact.length > 0) {
|
||||
const summary = compact.slice(0, 4).join(', ');
|
||||
return compact.length > 4 ? `${summary} +${compact.length - 4}` : summary;
|
||||
}
|
||||
|
||||
return `${value.length}`;
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
return getRecordString(value, 'label', 'title', 'name', 'message', 'summary')
|
||||
|| `${Object.keys(value).length}`;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function prettifyKey(key: string): string {
|
||||
if (!key) return key;
|
||||
if (/[\u4e00-\u9fff]/.test(key)) return key;
|
||||
|
||||
return key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function isToolResultPayload(value: unknown): value is ToolResultPayload {
|
||||
return isRecord(value)
|
||||
&& (
|
||||
'structuredData' in value
|
||||
|| 'renderHints' in value
|
||||
|| 'artifacts' in value
|
||||
|| 'files' in value
|
||||
|| 'logs' in value
|
||||
|| 'retryable' in value
|
||||
|| 'ok' in value
|
||||
);
|
||||
}
|
||||
|
||||
function mergeToolArtifacts(
|
||||
payloadArtifacts: ToolArtifact[],
|
||||
payloadFiles: AttachedFileMeta[],
|
||||
toolArtifacts: ToolArtifact[],
|
||||
): ToolArtifact[] {
|
||||
const merged = [
|
||||
...payloadArtifacts,
|
||||
...payloadFiles.map((file) => ({
|
||||
kind: 'file' as const,
|
||||
name: file.fileName,
|
||||
filePath: file.filePath,
|
||||
mimeType: file.mimeType,
|
||||
preview: file.preview,
|
||||
metadata: {
|
||||
fileSize: file.fileSize,
|
||||
source: file.source,
|
||||
},
|
||||
})),
|
||||
...toolArtifacts,
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return merged.filter((artifact) => {
|
||||
const key = [
|
||||
artifact.kind,
|
||||
artifact.name,
|
||||
artifact.label,
|
||||
artifact.filePath,
|
||||
artifact.uri,
|
||||
].filter(Boolean).join('|');
|
||||
if (!key || seen.has(key)) {
|
||||
return Boolean(!key);
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getNormalizedToolResult(tool: ToolStatus): NormalizedToolResult {
|
||||
const payload = isToolResultPayload(tool.result) ? tool.result : null;
|
||||
const payloadArtifacts = payload?.artifacts?.filter(isToolArtifact) ?? [];
|
||||
const payloadFiles = payload?.files?.filter(isAttachedFileMeta) ?? [];
|
||||
const toolArtifacts = tool.artifacts?.filter(isToolArtifact) ?? [];
|
||||
|
||||
return {
|
||||
payload,
|
||||
renderHints: payload?.renderHints || tool.renderHints,
|
||||
structuredData: payload?.structuredData,
|
||||
artifacts: mergeToolArtifacts(payloadArtifacts, payloadFiles, toolArtifacts),
|
||||
};
|
||||
}
|
||||
|
||||
function getToolResultSource(tool: ToolStatus): unknown {
|
||||
const normalized = getNormalizedToolResult(tool);
|
||||
if (isRecord(normalized.payload?.structuredData)) {
|
||||
return normalized.payload.structuredData;
|
||||
}
|
||||
|
||||
return normalized.payload || tool.result;
|
||||
}
|
||||
|
||||
function buildMetricLabel(kind: 'files' | 'sheets' | 'rows' | 'cols', value: number, labels: ToolUiLabels, locale: string): string {
|
||||
const count = formatNumberValue(value, locale);
|
||||
if (locale.startsWith('zh')) {
|
||||
switch (kind) {
|
||||
case 'files':
|
||||
return `${count}${labels.files}`;
|
||||
case 'sheets':
|
||||
return `${count}${labels.sheets}`;
|
||||
case 'rows':
|
||||
return `${count}${labels.rows}`;
|
||||
case 'cols':
|
||||
return `${count}${labels.cols}`;
|
||||
default:
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case 'files':
|
||||
return `${count} ${value === 1 ? 'file' : labels.files.toLowerCase()}`;
|
||||
case 'sheets':
|
||||
return `${count} ${value === 1 ? 'sheet' : labels.sheets.toLowerCase()}`;
|
||||
case 'rows':
|
||||
return `${count} ${value === 1 ? 'row' : labels.rows.toLowerCase()}`;
|
||||
case 'cols':
|
||||
return `${count} ${value === 1 ? 'col' : labels.cols.toLowerCase()}`;
|
||||
default:
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
function buildStructuredPreview(
|
||||
structuredData: unknown,
|
||||
locale: string,
|
||||
labels: ToolUiLabels,
|
||||
): StructuredPreview | null {
|
||||
if (Array.isArray(structuredData)) {
|
||||
const rows = structuredData.filter(isRecord).slice(0, 5);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6);
|
||||
return {
|
||||
details: [],
|
||||
table: {
|
||||
headers,
|
||||
rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(structuredData)) {
|
||||
if (typeof structuredData === 'string' && structuredData.trim()) {
|
||||
return {
|
||||
details: [{ label: labels.topLevelResult, value: structuredData.trim() }],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailEntries = Object.entries(structuredData)
|
||||
.filter(([, value]) =>
|
||||
typeof value === 'string'
|
||||
|| typeof value === 'number'
|
||||
|| typeof value === 'boolean'
|
||||
|| isStringArray(value),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map(([key, value]) => ({
|
||||
label: prettifyKey(key),
|
||||
value: formatStructuredValue(value, locale, labels),
|
||||
}));
|
||||
|
||||
const tableSource = Object.values(structuredData).find((value) =>
|
||||
Array.isArray(value) && value.length > 0 && value.every(isRecord),
|
||||
);
|
||||
|
||||
if (Array.isArray(tableSource)) {
|
||||
const rows = tableSource.filter(isRecord).slice(0, 5);
|
||||
const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6);
|
||||
if (headers.length > 0) {
|
||||
return {
|
||||
details: detailEntries,
|
||||
table: {
|
||||
headers,
|
||||
rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return detailEntries.length > 0 ? { details: detailEntries } : null;
|
||||
}
|
||||
|
||||
function formatFinding(value: unknown, locale: string, labels: ToolUiLabels): string {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return formatStructuredValue(value, locale, labels);
|
||||
}
|
||||
|
||||
return getRecordString(value, 'message', 'summary', 'description', 'label')
|
||||
|| [
|
||||
getRecordString(value, 'type', 'code'),
|
||||
getRecordString(value, 'column', 'field'),
|
||||
typeof value.count === 'number' ? formatNumberValue(value.count, locale) : undefined,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function buildSpreadsheetPreview(
|
||||
structuredData: unknown,
|
||||
locale: string,
|
||||
labels: ToolUiLabels,
|
||||
): SpreadsheetReportPreview[] {
|
||||
if (!isRecord(structuredData) || !Array.isArray(structuredData.reports)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return structuredData.reports
|
||||
.filter(isRecord)
|
||||
.map((reportItem, reportIndex) => {
|
||||
const report = isRecord(reportItem.report) ? reportItem.report : null;
|
||||
const structure = isRecord(report?.structure) ? report.structure : null;
|
||||
const quality = isRecord(report?.quality) ? report.quality : null;
|
||||
const workbook = isRecord(report?.workbook) ? report.workbook : null;
|
||||
const sheetNames = isStringArray(workbook?.sheetNames) ? workbook.sheetNames : [];
|
||||
const sheets = Object.entries(structure ?? {})
|
||||
.filter(([, value]) => isRecord(value))
|
||||
.slice(0, 3)
|
||||
.map(([sheetName, sheetValue]) => {
|
||||
const shape = isRecord(sheetValue.shape) ? sheetValue.shape : null;
|
||||
const columns = isStringArray(sheetValue.columns) ? sheetValue.columns : [];
|
||||
const previewRows = Array.isArray(sheetValue.preview)
|
||||
? sheetValue.preview.filter(isRecord).slice(0, 5)
|
||||
: [];
|
||||
const findings = Array.isArray(quality?.[sheetName])
|
||||
? quality[sheetName]
|
||||
.map((finding) => formatFinding(finding, locale, labels))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: sheetName,
|
||||
rows: getRecordNumber(shape, 'rows'),
|
||||
cols: getRecordNumber(shape, 'cols'),
|
||||
columns,
|
||||
previewRows,
|
||||
findings,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: `${getRecordString(reportItem, 'filePath', 'fileName') || `report-${reportIndex}`}`,
|
||||
fileName: getRecordString(reportItem, 'fileName') || getRecordString(reportItem, 'filePath') || `Report ${reportIndex + 1}`,
|
||||
filePath: getRecordString(reportItem, 'filePath'),
|
||||
engine: getRecordString(report, 'engine'),
|
||||
totalRows: getRecordNumber(workbook, 'totalRows'),
|
||||
sheetCount: sheetNames.length || sheets.length,
|
||||
sheets,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSearchResultsPreview(structuredData: unknown): SearchResultPreview | null {
|
||||
if (!isRecord(structuredData) || !Array.isArray(structuredData.results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = structuredData.results
|
||||
.filter(isRecord)
|
||||
.slice(0, 6)
|
||||
.map((item) => ({
|
||||
title: getRecordString(item, 'title', 'name') || 'Untitled result',
|
||||
url: getRecordString(item, 'url'),
|
||||
snippet: getRecordString(item, 'snippet', 'description', 'content'),
|
||||
source: getRecordString(item, 'source'),
|
||||
score: getRecordNumber(item, 'score'),
|
||||
age: getRecordString(item, 'age'),
|
||||
publishedAt: getRecordString(item, 'publishedAt', 'published_at', 'published_date'),
|
||||
installCommand: getRecordString(item, 'installCommand', 'install_command'),
|
||||
}));
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: getRecordString(structuredData, 'provider'),
|
||||
query: getRecordString(structuredData, 'query'),
|
||||
answer: getRecordString(structuredData, 'answer'),
|
||||
responseTimeMs: getRecordNumber(structuredData, 'responseTimeMs', 'response_time_ms'),
|
||||
resultCount: getRecordNumber(structuredData, 'resultCount', 'result_count'),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function getToolDisplayName(name: string, t: TranslateFn): string {
|
||||
switch (name) {
|
||||
case 'skills.install':
|
||||
@@ -160,12 +659,15 @@ function getToolStatusLabel(status: ToolStatus['status'], t: TranslateFn): strin
|
||||
}
|
||||
|
||||
function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
|
||||
const normalized = getNormalizedToolResult(tool);
|
||||
const toolResult = getToolResultSource(tool);
|
||||
|
||||
if (tool.name === 'skills.install') {
|
||||
const slug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
|
||||
const source = getRecordString(tool.result, 'source') || getRecordString(tool.input, 'kind');
|
||||
const baseDir = getRecordString(tool.result, 'baseDir');
|
||||
const slug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug');
|
||||
const source = getRecordString(toolResult, 'source') || getRecordString(tool.input, 'kind');
|
||||
const baseDir = getRecordString(toolResult, 'baseDir');
|
||||
const requestUrl = getRecordString(tool.input, 'url');
|
||||
const error = getRecordString(tool.result, 'error');
|
||||
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
|
||||
const details: ToolDetail[] = [];
|
||||
|
||||
if (slug) {
|
||||
@@ -188,9 +690,9 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
|
||||
}
|
||||
|
||||
if (tool.name === 'browser.open_url') {
|
||||
const link = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
|
||||
const title = getRecordString(tool.result, 'title');
|
||||
const error = getRecordString(tool.result, 'error');
|
||||
const link = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
|
||||
const title = getRecordString(toolResult, 'title');
|
||||
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
|
||||
const details: ToolDetail[] = [];
|
||||
|
||||
if (link) {
|
||||
@@ -206,7 +708,7 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
|
||||
return details;
|
||||
}
|
||||
|
||||
const error = getRecordString(tool.result, 'error');
|
||||
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
|
||||
return error ? [{ label: t('conversation.messageList.toolFields.error'), value: error }] : [];
|
||||
}
|
||||
|
||||
@@ -251,18 +753,44 @@ function ToolResultCard({
|
||||
}: {
|
||||
tool: ToolStatus;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [copiedAction, setCopiedAction] = useState<string | null>(null);
|
||||
const duration = formatToolDuration(tool.durationMs);
|
||||
const isRunning = tool.status === 'running';
|
||||
const isError = tool.status === 'error';
|
||||
const labels = getToolUiLabels(locale);
|
||||
const normalized = getNormalizedToolResult(tool);
|
||||
const toolResult = getToolResultSource(tool);
|
||||
const details = buildToolDetails(tool, t);
|
||||
const skillKey = getRecordString(tool.result, 'skillKey', 'slug') || getRecordString(tool.input, 'slug');
|
||||
const skillSlug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
|
||||
const skillBaseDir = getRecordString(tool.result, 'baseDir');
|
||||
const browserUrl = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
|
||||
const skillKey = getRecordString(toolResult, 'skillKey', 'slug') || getRecordString(tool.input, 'slug');
|
||||
const skillSlug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug');
|
||||
const skillBaseDir = getRecordString(toolResult, 'baseDir');
|
||||
const browserUrl = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
|
||||
const spreadsheetReports = !isRunning && !isError
|
||||
? buildSpreadsheetPreview(normalized.structuredData, locale, labels)
|
||||
: [];
|
||||
const searchPreview = !isRunning && !isError
|
||||
? buildSearchResultsPreview(normalized.structuredData)
|
||||
: null;
|
||||
const genericStructuredPreview = !isRunning && !isError && spreadsheetReports.length === 0 && !searchPreview
|
||||
? buildStructuredPreview(normalized.structuredData, locale, labels)
|
||||
: null;
|
||||
const totalSheets = spreadsheetReports.reduce((sum, report) => sum + (report.sheetCount || report.sheets.length), 0);
|
||||
const totalRows = spreadsheetReports.reduce((sum, report) => {
|
||||
if (typeof report.totalRows === 'number') {
|
||||
return sum + report.totalRows;
|
||||
}
|
||||
|
||||
return sum + report.sheets.reduce((sheetSum, sheet) => sheetSum + (sheet.rows || 0), 0);
|
||||
}, 0);
|
||||
const overviewMetrics = [
|
||||
spreadsheetReports.length > 0 ? buildMetricLabel('files', spreadsheetReports.length, labels, locale) : null,
|
||||
totalSheets > 0 ? buildMetricLabel('sheets', totalSheets, labels, locale) : null,
|
||||
totalRows > 0 ? buildMetricLabel('rows', totalRows, labels, locale) : null,
|
||||
].filter(Boolean) as string[];
|
||||
const artifacts = normalized.artifacts.slice(0, 8);
|
||||
|
||||
async function handleAction(
|
||||
actionKey: string,
|
||||
@@ -359,6 +887,347 @@ function ToolResultCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{spreadsheetReports.length > 0 ? (
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
|
||||
data-testid="tool-spreadsheet-preview"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.analysisOverview}
|
||||
</div>
|
||||
{overviewMetrics.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{overviewMetrics.map((metric) => (
|
||||
<span
|
||||
key={`${tool.toolCallId || tool.id || tool.name}-${metric}`}
|
||||
className="inline-flex items-center rounded-full border border-current/10 bg-white/60 px-2.5 py-1 text-[11px] font-medium text-current/85 dark:bg-black/10"
|
||||
>
|
||||
{metric}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{spreadsheetReports.map((report) => (
|
||||
<div
|
||||
key={report.key}
|
||||
className="flex flex-col gap-3 rounded-[18px] border border-current/10 bg-white/60 p-3 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold leading-6 text-current">
|
||||
{report.fileName}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] leading-5 text-current/75">
|
||||
{report.filePath ? (
|
||||
<span className="break-all">{labels.path}: {report.filePath}</span>
|
||||
) : null}
|
||||
{report.engine ? <span>{labels.engine}: {report.engine}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{typeof report.sheetCount === 'number' && report.sheetCount > 0 ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{buildMetricLabel('sheets', report.sheetCount, labels, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof report.totalRows === 'number' && report.totalRows > 0 ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{buildMetricLabel('rows', report.totalRows, labels, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.sheets.map((sheet) => {
|
||||
const previewHeaders = Array.from(new Set([
|
||||
...sheet.columns,
|
||||
...sheet.previewRows.flatMap((row) => Object.keys(row)),
|
||||
])).slice(0, 6);
|
||||
const extraColumnCount = Math.max(sheet.columns.length - previewHeaders.length, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${report.key}-${sheet.name}`}
|
||||
className="flex flex-col gap-2 rounded-2xl border border-current/10 bg-white/50 p-3 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-[12px] font-semibold leading-5 text-current">
|
||||
{sheet.name}
|
||||
</div>
|
||||
{typeof sheet.rows === 'number' ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{buildMetricLabel('rows', sheet.rows, labels, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof sheet.cols === 'number' ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{buildMetricLabel('cols', sheet.cols, labels, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sheet.columns.length > 0 ? (
|
||||
<div className="text-[11px] leading-5 text-current/75">
|
||||
{labels.columns}: {sheet.columns.slice(0, 6).join(', ')}
|
||||
{extraColumnCount > 0 ? ` · ${labels.moreColumns} +${extraColumnCount}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.preview}
|
||||
</div>
|
||||
{previewHeaders.length > 0 && sheet.previewRows.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-2xl border border-current/10 bg-white/65 dark:bg-black/10">
|
||||
<table className="min-w-full border-collapse text-left text-[11px] leading-5">
|
||||
<thead>
|
||||
<tr className="border-b border-current/10 bg-white/70 dark:bg-black/20">
|
||||
{previewHeaders.map((header) => (
|
||||
<th key={`${sheet.name}-${header}`} className="px-3 py-2 font-semibold text-current/80">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.previewRows.map((row, rowIndex) => (
|
||||
<tr key={`${sheet.name}-row-${rowIndex}`} className="border-b border-current/10 last:border-b-0">
|
||||
{previewHeaders.map((header) => (
|
||||
<td key={`${sheet.name}-${rowIndex}-${header}`} className="px-3 py-2 align-top text-current/85">
|
||||
{formatStructuredValue(row[header], locale, labels)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-current/15 px-3 py-2 text-[11px] text-current/70">
|
||||
{labels.emptyPreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sheet.findings.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.findings}
|
||||
</div>
|
||||
<ul className="grid gap-1 text-[11px] leading-5 text-current/85">
|
||||
{sheet.findings.map((finding, index) => (
|
||||
<li key={`${report.key}-${sheet.name}-finding-${index}`} className="break-words">
|
||||
{finding}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchPreview ? (
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
|
||||
data-testid="tool-search-results"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-current/10 bg-white/60 px-2.5 py-1 text-[11px] font-medium text-current/85 dark:bg-black/10">
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{labels.searchOverview || 'Search overview'}</span>
|
||||
</div>
|
||||
{searchPreview.provider ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{(labels.provider || 'Provider')}: {searchPreview.provider}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof searchPreview.resultCount === 'number' ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{formatNumberValue(searchPreview.resultCount, locale)} {(labels.results || 'Results').toLowerCase()}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof searchPreview.responseTimeMs === 'number' ? (
|
||||
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
|
||||
{(labels.responseTime || 'Response time')}: {formatToolDuration(searchPreview.responseTimeMs) || `${searchPreview.responseTimeMs}ms`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{searchPreview.query ? (
|
||||
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.query || 'Query'}
|
||||
</div>
|
||||
<div className="break-words text-[12px] leading-5 text-current/90">
|
||||
{searchPreview.query}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchPreview.answer ? (
|
||||
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.answer || 'Answer'}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-[12px] leading-5 text-current/90">
|
||||
{searchPreview.answer}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.results || 'Results'}
|
||||
</div>
|
||||
{searchPreview.results.map((result, index) => (
|
||||
<div
|
||||
key={`${tool.toolCallId || tool.id || tool.name}-search-result-${index}`}
|
||||
className="flex flex-col gap-2 rounded-[18px] border border-current/10 bg-white/60 p-3 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-[13px] font-semibold leading-6 text-current">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.url ? (
|
||||
<div className="break-all text-[11px] leading-5 text-current/75">
|
||||
{result.url}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result.snippet ? (
|
||||
<div className="text-[12px] leading-5 text-current/85">
|
||||
{result.snippet}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-[10px] leading-5 text-current/75">
|
||||
{result.source ? (
|
||||
<span>{labels.source || 'Source'}: {result.source}</span>
|
||||
) : null}
|
||||
{typeof result.score === 'number' ? (
|
||||
<span>{labels.score || 'Score'}: {result.score.toFixed(4)}</span>
|
||||
) : null}
|
||||
{result.age ? <span>{result.age}</span> : null}
|
||||
{result.publishedAt ? <span>{result.publishedAt}</span> : null}
|
||||
</div>
|
||||
|
||||
{result.installCommand ? (
|
||||
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.installCommand || 'Install command'}
|
||||
</div>
|
||||
<div className="break-all font-mono text-[11px] leading-5 text-current/90">
|
||||
{result.installCommand}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{genericStructuredPreview && (genericStructuredPreview.details.length > 0 || genericStructuredPreview.table) ? (
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
|
||||
data-testid="tool-structured-result"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.structuredData}
|
||||
</div>
|
||||
|
||||
{genericStructuredPreview.details.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{genericStructuredPreview.details.map((detail) => (
|
||||
<div
|
||||
key={`${tool.toolCallId || tool.id || tool.name}-structured-${detail.label}`}
|
||||
className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{detail.label}
|
||||
</div>
|
||||
<div className="break-words text-[12px] leading-5 text-current/90">
|
||||
{detail.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{genericStructuredPreview.table ? (
|
||||
<div className="overflow-x-auto rounded-2xl border border-current/10 bg-white/65 dark:bg-black/10">
|
||||
<table className="min-w-full border-collapse text-left text-[11px] leading-5">
|
||||
<thead>
|
||||
<tr className="border-b border-current/10 bg-white/70 dark:bg-black/20">
|
||||
{genericStructuredPreview.table.headers.map((header) => (
|
||||
<th key={`${tool.toolCallId || tool.id || tool.name}-${header}`} className="px-3 py-2 font-semibold text-current/80">
|
||||
{prettifyKey(header)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{genericStructuredPreview.table.rows.map((row, rowIndex) => (
|
||||
<tr key={`${tool.toolCallId || tool.id || tool.name}-structured-row-${rowIndex}`} className="border-b border-current/10 last:border-b-0">
|
||||
{row.map((value, valueIndex) => (
|
||||
<td
|
||||
key={`${tool.toolCallId || tool.id || tool.name}-structured-cell-${rowIndex}-${valueIndex}`}
|
||||
className="px-3 py-2 align-top text-current/85"
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{artifacts.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 rounded-2xl bg-white/45 p-3 dark:bg-black/10">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
{labels.artifacts}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{artifacts.map((artifact, index) => {
|
||||
const title = artifact.label || artifact.name || artifact.filePath || artifact.uri || `${labels.file} ${index + 1}`;
|
||||
const subtitle = artifact.description
|
||||
|| artifact.filePath
|
||||
|| artifact.uri
|
||||
|| artifact.mimeType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${tool.toolCallId || tool.id || tool.name}-artifact-${title}-${index}`}
|
||||
className="flex min-w-[180px] max-w-full flex-col gap-1 rounded-2xl border border-current/10 bg-white/60 px-3 py-2 dark:bg-black/10"
|
||||
>
|
||||
<div className="truncate text-[12px] font-semibold leading-5 text-current">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="break-all text-[11px] leading-5 text-current/70">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tool.name === 'skills.install' && !isRunning && (skillKey || skillBaseDir) ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
|
||||
|
||||
@@ -2,17 +2,56 @@ import type { RawMessage } from '../../runtime-shared/shared/chat-model';
|
||||
|
||||
export * from '../../runtime-shared/shared/chat-model';
|
||||
|
||||
function flattenContentBlocks(content: RawMessage['content']): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
return block.text;
|
||||
}
|
||||
|
||||
if ((block.type === 'tool_result' || block.type === 'toolResult')) {
|
||||
if (typeof block.content === 'string' && block.content.trim()) {
|
||||
return block.content;
|
||||
}
|
||||
|
||||
if (Array.isArray(block.content)) {
|
||||
return flattenContentBlocks(block.content);
|
||||
}
|
||||
|
||||
if (typeof block.summary === 'string' && block.summary.trim()) {
|
||||
return block.summary;
|
||||
}
|
||||
|
||||
const result = block.result;
|
||||
if (
|
||||
result
|
||||
&& typeof result === 'object'
|
||||
&& 'summary' in result
|
||||
&& typeof result.summary === 'string'
|
||||
&& result.summary.trim()
|
||||
) {
|
||||
return result.summary;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function extractText(message?: RawMessage | null): string {
|
||||
if (!message) return '';
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
return message.content
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text ?? '')
|
||||
.join('\n');
|
||||
return flattenContentBlocks(message.content);
|
||||
}
|
||||
|
||||
export function extractThinking(message?: RawMessage | null): string | null {
|
||||
@@ -99,6 +138,16 @@ export function isInternalMessage(message: { role?: string; content?: unknown })
|
||||
if (message.role === 'system') return true;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
if (Array.isArray(message.content)) {
|
||||
const hasVisibleText = message.content.some((block) => block.type === 'text' && block.text?.trim());
|
||||
const hasOnlyToolUseBlocks = message.content.length > 0
|
||||
&& message.content.every((block) => block.type === 'tool_use' || block.type === 'toolCall');
|
||||
|
||||
if (hasOnlyToolUseBlocks && !hasVisibleText) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const text = typeof message.content === 'string'
|
||||
? message.content
|
||||
: extractText(message as RawMessage);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { t } from '../i18n';
|
||||
import { extractText, isToolOnlyMessage } from '../shared/chat-model';
|
||||
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
|
||||
import { hostApiFetch } from '../lib/host-api';
|
||||
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../lib/runtime-events';
|
||||
import type { GatewayEvent } from '../types/runtime';
|
||||
import { agentsStore } from './agents';
|
||||
|
||||
@@ -417,6 +418,11 @@ async function subscribeToGateway(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRuntimeChangedGatewayEvent(event) && runtimeEventHasTopic(event, 'skills')) {
|
||||
void handleGatewayEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.sessionKey === 'string' && event.sessionKey !== state.currentSessionKey) {
|
||||
return;
|
||||
}
|
||||
@@ -1031,6 +1037,14 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'runtime:changed': {
|
||||
if (runtimeEventHasTopic(event, 'skills') && state.initialized) {
|
||||
patchState({ error: null });
|
||||
lastHistoryLoadAtBySession.delete(state.currentSessionKey);
|
||||
await loadHistory(state.currentSessionKey, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user