xworkmate-app/lib/runtime/desktop_thread_artifact_service.dart
Haitao Pan fd28b4f485 fix(artifacts): prioritize PDF deliverables in sidebar
Co-authored-by: Haitao Pan <haitao.pan@xworkmate.ai>
2026-06-28 11:27:41 +08:00

612 lines
19 KiB
Dart

import 'dart:io';
import 'assistant_artifacts.dart';
import 'runtime_models.dart';
class DesktopThreadArtifactService {
static const int defaultResultLimitInternal = 24;
static const Set<String> ignoredDirectoryNamesInternal = <String>{
'.git',
'.dart_tool',
'build',
'Pods',
'DerivedData',
'.symlinks',
'.gradle',
'out',
};
Future<AssistantArtifactSnapshot> loadSnapshot({
required String workspacePath,
required WorkspaceRefKind workspaceKind,
List<String> artifactRelativePaths = const <String>[],
}) async {
final normalizedRef = workspacePath.trim();
if (normalizedRef.isEmpty) {
return AssistantArtifactSnapshot(
workspacePath: normalizedRef,
workspaceKind: workspaceKind,
resultMessage: 'No recorded working directory for this thread.',
filesMessage: 'No recorded working directory for this thread.',
changesMessage: 'No recorded working directory for this thread.',
);
}
if (workspaceKind != WorkspaceRefKind.localPath) {
return AssistantArtifactSnapshot(
workspacePath: normalizedRef,
workspaceKind: workspaceKind,
resultMessage:
'This thread workspace is recorded on a remote agent and is not browsable from desktop.',
filesMessage:
'This thread workspace is recorded on a remote agent and is not browsable from desktop.',
changesMessage:
'This thread workspace is recorded on a remote agent and is not browsable from desktop.',
);
}
final root = Directory(normalizedRef);
if (!await root.exists()) {
return AssistantArtifactSnapshot(
workspacePath: normalizedRef,
workspaceKind: workspaceKind,
resultMessage:
'This thread workspace is recorded but is not available on the current machine.',
filesMessage:
'This thread workspace is recorded but is not available on the current machine.',
changesMessage:
'This thread workspace is recorded but is not available on the current machine.',
);
}
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
final taskFiles = taskArtifactPaths.isEmpty
? const <File>[]
: await collectTaskArtifactFilesInternal(
root,
normalizedRef,
taskArtifactPaths,
);
final fileEntries = await buildEntriesInternal(taskFiles, normalizedRef);
final changes = taskArtifactPaths.isEmpty
? const <AssistantArtifactChangeEntry>[]
: await readGitChangesInternal(
root,
normalizedRef,
artifactRelativePaths: taskArtifactPaths,
);
final results = await buildResultEntriesInternal(
changes: changes,
fileEntries: fileEntries,
workspacePath: normalizedRef,
);
final resultMessage = results.isEmpty
? taskArtifactPaths.isEmpty
? 'No task artifacts recorded for this run.'
: 'No current task artifacts found for this run.'
: '';
final filesMessage = taskArtifactPaths.isEmpty
? ''
: fileEntries.isEmpty
? 'No current task artifact files found in the recorded working directory.'
: '';
final changesMessage = changes.isEmpty
? 'No Git changes found for the current thread workspace.'
: '';
return AssistantArtifactSnapshot(
workspacePath: normalizedRef,
workspaceKind: workspaceKind,
resultEntries: results,
fileEntries: fileEntries,
changes: changes,
resultMessage: resultMessage,
filesMessage: filesMessage,
changesMessage: changesMessage,
);
}
Future<AssistantArtifactPreview> loadPreview({
required AssistantArtifactEntry entry,
required String workspacePath,
required WorkspaceRefKind workspaceKind,
List<String> artifactRelativePaths = const <String>[],
}) async {
if (workspaceKind != WorkspaceRefKind.localPath) {
return const AssistantArtifactPreview.empty(
message: 'Remote agent artifacts are not directly readable on desktop.',
);
}
final root = Directory(workspacePath.trim());
if (!await root.exists()) {
return const AssistantArtifactPreview.empty(
message:
'The recorded working directory is not available on this machine.',
);
}
final entryRelativePath = normalizeArtifactPathInternal(entry.relativePath);
if (entryRelativePath.isEmpty) {
return const AssistantArtifactPreview.empty(
message:
'The selected file is not part of the current thread workspace.',
);
}
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
if (taskArtifactPaths.isEmpty ||
!taskArtifactPaths.contains(entryRelativePath)) {
return const AssistantArtifactPreview.empty(
message: 'The selected file is not part of the current task artifacts.',
);
}
final targetPath = resolveAbsolutePathInternal(
workspacePath,
entryRelativePath,
);
final file = File(targetPath);
if (!await file.exists()) {
return AssistantArtifactPreview.empty(
message:
'The selected file is no longer available: ${entry.relativePath}',
);
}
final resolvedRelativePath = relativePathInternal(workspacePath, file.path);
if (resolvedRelativePath == null ||
resolvedRelativePath != entryRelativePath) {
return const AssistantArtifactPreview.empty(
message:
'The selected file is not part of the current thread workspace.',
);
}
final extension = fileExtensionInternal(entryRelativePath);
final content = await file.readAsString();
final title = entry.label;
if (extension == 'md' || extension == 'markdown') {
return AssistantArtifactPreview(
kind: AssistantArtifactPreviewKind.markdown,
title: title,
content: content,
);
}
if (extension == 'html' || extension == 'htm') {
return AssistantArtifactPreview(
kind: AssistantArtifactPreviewKind.html,
title: title,
content: sanitizeHtmlInternal(content),
);
}
if (isPlainTextExtensionInternal(extension)) {
return AssistantArtifactPreview(
kind: AssistantArtifactPreviewKind.text,
title: title,
content: content,
);
}
return AssistantArtifactPreview.unsupported(
title: title,
message: 'Preview is not available for this file type.',
);
}
Future<List<File>> collectFilesInternal(Directory root) async {
final files = <File>[];
try {
await for (final entity in root.list(followLinks: false)) {
if (entity is Directory) {
if (ignoredDirectoryNamesInternal.contains(
baseNameInternal(entity.path),
)) {
continue;
}
files.addAll(await collectFilesInternal(entity));
continue;
}
if (entity is File) {
files.add(entity);
}
}
} on FileSystemException {
// Best effort only. A single unreadable directory should not block the panel.
}
return files;
}
Future<List<File>> collectTaskArtifactFilesInternal(
Directory root,
String workspacePath,
List<String> artifactRelativePaths,
) async {
final files = <File>[];
for (final relativePath in artifactRelativePaths) {
final target = File(resolveAbsolutePathInternal(root.path, relativePath));
try {
if (!await target.exists()) {
continue;
}
final resolvedRelativePath = relativePathInternal(
workspacePath,
target.path,
);
if (resolvedRelativePath == null || resolvedRelativePath.isEmpty) {
continue;
}
files.add(target);
} on FileSystemException {
continue;
}
}
return files;
}
Future<List<AssistantArtifactEntry>> buildEntriesInternal(
List<File> files,
String workspacePath,
) async {
final entries = <AssistantArtifactEntry>[];
for (final file in files) {
try {
final stat = await file.stat();
final relativePath =
relativePathInternal(workspacePath, file.path) ?? file.path;
final extension = fileExtensionInternal(relativePath);
entries.add(
AssistantArtifactEntry(
id: '$workspacePath::$relativePath',
label: baseNameInternal(relativePath),
relativePath: relativePath,
kind: AssistantArtifactEntryKind.file,
mimeType: guessMimeTypeInternal(relativePath),
sizeBytes: stat.size,
updatedAtMs: stat.modified.millisecondsSinceEpoch.toDouble(),
previewable: isPreviewableExtensionInternal(extension),
workspacePath: workspacePath,
),
);
} on FileSystemException {
// Ignore files that cannot be stat'ed.
}
}
entries.sort((a, b) {
final deliveryCompare = artifactDisplayPriorityInternal(
a.relativePath,
).compareTo(artifactDisplayPriorityInternal(b.relativePath));
if (deliveryCompare != 0) {
return deliveryCompare;
}
if (fileExtensionInternal(a.relativePath) == 'pdf') {
final depthCompare = artifactPathDepthInternal(
a.relativePath,
).compareTo(artifactPathDepthInternal(b.relativePath));
if (depthCompare != 0) {
return depthCompare;
}
}
final updatedCompare = (b.updatedAtMs ?? 0).compareTo(a.updatedAtMs ?? 0);
if (updatedCompare != 0) {
return updatedCompare;
}
return a.relativePath.compareTo(b.relativePath);
});
return entries;
}
static int artifactDisplayPriorityInternal(String relativePath) {
return fileExtensionInternal(relativePath) == 'pdf' ? 0 : 1;
}
static int artifactPathDepthInternal(String relativePath) {
return normalizeArtifactPathInternal(
relativePath,
).split('/').where((segment) => segment.isNotEmpty).length;
}
Future<List<AssistantArtifactEntry>> buildResultEntriesInternal({
required List<AssistantArtifactChangeEntry> changes,
required List<AssistantArtifactEntry> fileEntries,
required String workspacePath,
}) async {
final filesByPath = <String, AssistantArtifactEntry>{
for (final entry in fileEntries) entry.relativePath: entry,
};
final results = <AssistantArtifactEntry>[];
for (final change in changes) {
final entry = filesByPath[change.path];
if (entry != null) {
results.add(entry);
}
}
if (results.isNotEmpty) {
return results;
}
return fileEntries.take(defaultResultLimitInternal).toList(growable: false);
}
Future<List<AssistantArtifactChangeEntry>> readGitChangesInternal(
Directory workspaceRoot,
String workspacePath, {
List<String> artifactRelativePaths = const <String>[],
}) async {
final allowedPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
).toSet();
String? repositoryRoot;
try {
final revParse = await Process.run('git', <String>[
'-C',
workspaceRoot.path,
'rev-parse',
'--show-toplevel',
]);
if (revParse.exitCode != 0) {
return const <AssistantArtifactChangeEntry>[];
}
repositoryRoot = revParse.stdout.toString().trim();
if (repositoryRoot.isEmpty) {
return const <AssistantArtifactChangeEntry>[];
}
final status = await Process.run('git', <String>[
'-C',
repositoryRoot,
'status',
'--short',
'--untracked-files=all',
]);
if (status.exitCode != 0) {
return const <AssistantArtifactChangeEntry>[];
}
final items = <AssistantArtifactChangeEntry>[];
final lines = status.stdout
.toString()
.split('\n')
.map((item) => item.trimRight())
.where((item) => item.isNotEmpty);
for (final line in lines) {
if (line.length < 3) {
continue;
}
final statusCode = line.substring(0, 2).trim();
final rawPath = line.substring(3).trim();
final path = rawPath.contains(' -> ')
? rawPath.split(' -> ').last.trim()
: rawPath;
final absolutePath = joinPathInternal(repositoryRoot, path);
final relativePath = relativePathInternal(workspacePath, absolutePath);
if (relativePath == null || relativePath.isEmpty) {
continue;
}
if (allowedPaths.isNotEmpty && !allowedPaths.contains(relativePath)) {
continue;
}
items.add(
AssistantArtifactChangeEntry(
path: relativePath,
changeType: statusCode,
displayLabel: statusLabelForInternal(statusCode),
),
);
}
return items;
} on ProcessException {
return const <AssistantArtifactChangeEntry>[];
}
}
static String resolveAbsolutePathInternal(String root, String relativePath) {
if (relativePath.startsWith('/') ||
relativePath.startsWith('\\') ||
relativePath.contains(':\\')) {
return relativePath;
}
return joinPathInternal(root, relativePath);
}
static String sanitizeHtmlInternal(String value) {
final withoutBlockedTags = value
.replaceAll(
RegExp(
r'<(script|iframe|object|embed|link|meta|base)[^>]*>[\s\S]*?<\/\1>',
caseSensitive: false,
),
'',
)
.replaceAll(
RegExp(
r'<(script|iframe|object|embed|link|meta|base)[^>]*\/?>',
caseSensitive: false,
),
'',
);
final withoutEventHandlers = withoutBlockedTags.replaceAll(
RegExp(r'''\son\w+\s*=\s*(".*?"|'.*?'|[^\s>]+)''', caseSensitive: false),
'',
);
return withoutEventHandlers.replaceAllMapped(
RegExp(
r'''\s(href|src)\s*=\s*(".*?"|'.*?'|[^\s>]+)''',
caseSensitive: false,
),
(match) {
final quoteWrapped = match.group(2) ?? '';
final raw = quoteWrapped
.replaceAll('"', '')
.replaceAll('\'', '')
.trim();
final lower = raw.toLowerCase();
if (lower.startsWith('javascript:') ||
lower.startsWith('http://') ||
lower.startsWith('https://') ||
lower.startsWith('//')) {
return ' ${match.group(1)}="#"';
}
return match.group(0) ?? '';
},
);
}
static String joinPathInternal(String root, String child) {
final separator = Platform.pathSeparator;
final normalizedRoot = root.endsWith(separator) ? root : '$root$separator';
final normalizedChild = child.startsWith(separator)
? child.substring(1)
: child;
return '$normalizedRoot$normalizedChild';
}
static String? relativePathInternal(String root, String absolutePath) {
final normalizedRoot = normalizePathInternal(root);
final normalizedPath = normalizePathInternal(absolutePath);
if (normalizedRoot == normalizedPath) {
return '';
}
final prefix = normalizedRoot.endsWith('/')
? normalizedRoot
: '$normalizedRoot/';
if (!normalizedPath.startsWith(prefix)) {
return null;
}
return normalizedPath.substring(prefix.length);
}
static List<String> normalizeTaskArtifactPathsInternal(
List<String> relativePaths,
) {
final seen = <String>{};
final normalized = <String>[];
for (final relativePath in relativePaths) {
final item = normalizeArtifactPathInternal(relativePath);
if (item.isEmpty || !seen.add(item)) {
continue;
}
normalized.add(item);
}
return normalized;
}
static String normalizeArtifactPathInternal(String relativePath) {
final trimmed = relativePath.trim().replaceAll('\\', '/');
if (trimmed.isEmpty ||
trimmed.startsWith('/') ||
trimmed.startsWith('~') ||
trimmed.contains(':')) {
return '';
}
final segments = trimmed
.split('/')
.where((segment) => segment.isNotEmpty && segment != '.')
.toList(growable: false);
if (segments.isEmpty || segments.any((segment) => segment == '..')) {
return '';
}
return segments.join('/');
}
static String normalizePathInternal(String path) {
try {
final type = FileSystemEntity.typeSync(path, followLinks: true);
final resolved = switch (type) {
FileSystemEntityType.directory => Directory(
path,
).resolveSymbolicLinksSync(),
FileSystemEntityType.file ||
FileSystemEntityType.link => File(path).resolveSymbolicLinksSync(),
FileSystemEntityType.notFound => File(path).absolute.path,
_ => File(path).absolute.path,
};
return resolved.replaceAll('\\', '/');
} on FileSystemException {
return File(path).absolute.path.replaceAll('\\', '/');
}
}
static String baseNameInternal(String path) {
final normalized = path.replaceAll('\\', '/');
final parts = normalized.split('/');
return parts.isEmpty ? normalized : parts.last;
}
static String fileExtensionInternal(String path) {
final name = baseNameInternal(path);
final index = name.lastIndexOf('.');
if (index <= 0 || index >= name.length - 1) {
return '';
}
return name.substring(index + 1).toLowerCase();
}
static String guessMimeTypeInternal(String path) {
final extension = fileExtensionInternal(path);
return switch (extension) {
'md' || 'markdown' => 'text/markdown',
'html' || 'htm' => 'text/html',
'txt' || 'log' => 'text/plain',
'json' => 'application/json',
'yaml' || 'yml' => 'application/yaml',
'csv' => 'text/csv',
'png' => 'image/png',
'jpg' || 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'dart' => 'text/x-dart',
'js' => 'text/javascript',
'ts' => 'text/typescript',
'css' => 'text/css',
'xml' => 'application/xml',
_ => 'application/octet-stream',
};
}
static bool isPreviewableExtensionInternal(String extension) {
return extension == 'md' ||
extension == 'markdown' ||
extension == 'html' ||
extension == 'htm' ||
isPlainTextExtensionInternal(extension);
}
static bool isPlainTextExtensionInternal(String extension) {
return <String>{
'txt',
'log',
'json',
'yaml',
'yml',
'csv',
'dart',
'js',
'ts',
'css',
'xml',
'sh',
}.contains(extension);
}
static String statusLabelForInternal(String code) {
if (code == '??') {
return 'Untracked';
}
if (code.contains('A')) {
return 'Added';
}
if (code.contains('M')) {
return 'Modified';
}
if (code.contains('D')) {
return 'Deleted';
}
if (code.contains('R')) {
return 'Renamed';
}
if (code.contains('C')) {
return 'Copied';
}
if (code.contains('U')) {
return 'Updated';
}
return code.isEmpty ? 'Changed' : code;
}
}