Implement local-first single-agent artifact sync
This commit is contained in:
parent
ee452fc7ea
commit
0391039c18
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -1,3 +0,0 @@
|
||||
[submodule "vendor/codex"]
|
||||
path = vendor/codex
|
||||
url = https://github.com/openai/codex.git
|
||||
@ -1,6 +1,8 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
@ -164,6 +166,11 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
routing: routing,
|
||||
routingHint: 'single-agent',
|
||||
provider: effectiveProvider,
|
||||
remoteWorkingDirectoryHint:
|
||||
controller
|
||||
.requireTaskThreadForSessionInternal(sessionKey)
|
||||
.lastRemoteWorkingDirectory ??
|
||||
'',
|
||||
),
|
||||
onUpdate: (update) {
|
||||
if (update.isDelta) {
|
||||
@ -175,7 +182,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
}
|
||||
},
|
||||
);
|
||||
_applySingleAgentGoTaskResultDesktopInternal(
|
||||
await _applySingleAgentGoTaskResultDesktopInternal(
|
||||
controller,
|
||||
sessionKey: sessionKey,
|
||||
sessionTarget: sessionTarget,
|
||||
@ -212,7 +219,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
});
|
||||
}
|
||||
|
||||
void _applySingleAgentGoTaskResultDesktopInternal(
|
||||
Future<void> _applySingleAgentGoTaskResultDesktopInternal(
|
||||
AppController controller, {
|
||||
required String sessionKey,
|
||||
required AssistantExecutionTarget sessionTarget,
|
||||
@ -220,7 +227,7 @@ void _applySingleAgentGoTaskResultDesktopInternal(
|
||||
required String thinking,
|
||||
required List<GatewayChatAttachmentPayload> attachments,
|
||||
required GoTaskServiceResult result,
|
||||
}) {
|
||||
}) async {
|
||||
final resolvedRuntimeModel = result.resolvedModel.trim();
|
||||
final resolvedGatewayEntryState = goTaskServiceGatewayEntryState(
|
||||
requestedTarget: sessionTarget,
|
||||
@ -233,13 +240,13 @@ void _applySingleAgentGoTaskResultDesktopInternal(
|
||||
lifecycleStatus: 'ready',
|
||||
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastResultCode: result.success ? 'success' : 'error',
|
||||
lastRemoteWorkingDirectory: result.remoteWorkingDirectory.trim().isEmpty
|
||||
? null
|
||||
: result.remoteWorkingDirectory.trim(),
|
||||
lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
_updateSingleAgentWorkspaceBindingFromResultDesktopInternal(
|
||||
controller,
|
||||
sessionKey,
|
||||
result,
|
||||
);
|
||||
await _persistSingleAgentArtifactsDesktopInternal(controller, sessionKey, result);
|
||||
controller.clearAiGatewayStreamingTextInternal(sessionKey);
|
||||
if (!result.success) {
|
||||
controller.appendAssistantThreadMessageInternal(
|
||||
@ -285,30 +292,107 @@ void _applySingleAgentGoTaskResultDesktopInternal(
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSingleAgentWorkspaceBindingFromResultDesktopInternal(
|
||||
Future<void> _persistSingleAgentArtifactsDesktopInternal(
|
||||
AppController controller,
|
||||
String sessionKey,
|
||||
GoTaskServiceResult result,
|
||||
) {
|
||||
final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind;
|
||||
final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim();
|
||||
if (resolvedWorkspaceKind == null || resolvedWorkingDirectory.isEmpty) {
|
||||
) async {
|
||||
final artifacts = result.artifacts;
|
||||
if (artifacts.isEmpty) {
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: 'no-artifacts',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final existingThread = controller.requireTaskThreadForSessionInternal(
|
||||
sessionKey,
|
||||
);
|
||||
if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) {
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
workspaceBinding: WorkspaceBinding(
|
||||
workspaceId: existingThread.workspaceBinding.workspaceId,
|
||||
workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath
|
||||
? WorkspaceKind.remoteFs
|
||||
: WorkspaceKind.localFs,
|
||||
workspacePath: resolvedWorkingDirectory,
|
||||
displayPath: resolvedWorkingDirectory,
|
||||
writable: existingThread.workspaceBinding.writable,
|
||||
),
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: 'skipped-non-local-workspace',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final root = Directory(existingThread.workspaceBinding.workspacePath);
|
||||
await root.create(recursive: true);
|
||||
|
||||
var wroteArtifact = false;
|
||||
for (final artifact in artifacts) {
|
||||
if (!artifact.hasInlineContent) {
|
||||
continue;
|
||||
}
|
||||
final relativePath = _sanitizeArtifactRelativePathInternal(
|
||||
artifact.relativePath,
|
||||
);
|
||||
if (relativePath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final target = await _nextArtifactTargetFileInternal(root, relativePath);
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(
|
||||
_decodeArtifactContentInternal(artifact),
|
||||
flush: true,
|
||||
);
|
||||
wroteArtifact = true;
|
||||
}
|
||||
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
String _sanitizeArtifactRelativePathInternal(String raw) {
|
||||
final trimmed = raw.trim().replaceAll('\\', '/');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final cleaned = trimmed
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty && segment != '.' && segment != '..')
|
||||
.join('/');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
|
||||
final encoding = artifact.encoding.trim().toLowerCase();
|
||||
if (encoding == 'base64') {
|
||||
return base64Decode(artifact.content);
|
||||
}
|
||||
return utf8.encode(artifact.content);
|
||||
}
|
||||
|
||||
Future<File> _nextArtifactTargetFileInternal(
|
||||
Directory root,
|
||||
String relativePath,
|
||||
) async {
|
||||
final segments = relativePath.split('/');
|
||||
final fileName = segments.removeLast();
|
||||
final parent = segments.isEmpty
|
||||
? root
|
||||
: Directory('${root.path}/${segments.join('/')}');
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
|
||||
var candidate = File('${parent.path}/$fileName');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
for (var version = 2; version < 1000; version += 1) {
|
||||
candidate = File('${parent.path}/$baseName.v$version$extension');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return File(
|
||||
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
|
||||
);
|
||||
}
|
||||
|
||||
@ -276,6 +276,10 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
String? lifecycleStatus,
|
||||
double? lastRunAtMs,
|
||||
String? lastResultCode,
|
||||
String? lastRemoteWorkingDirectory,
|
||||
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
|
||||
double? lastArtifactSyncAtMs,
|
||||
String? lastArtifactSyncStatus,
|
||||
}) {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
@ -379,6 +383,10 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
gatewayEntryState: gatewayEntryStateForTargetInternal(
|
||||
nextExecutionTarget,
|
||||
),
|
||||
lastRemoteWorkingDirectory: null,
|
||||
lastRemoteWorkspaceRefKind: null,
|
||||
lastArtifactSyncAtMs: null,
|
||||
lastArtifactSyncStatus: null,
|
||||
))
|
||||
.copyWith(
|
||||
messages: nextMessages,
|
||||
@ -397,6 +405,10 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
existing?.contextState.selectedSkillsSource,
|
||||
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
|
||||
gatewayEntryState: gatewayEntryState,
|
||||
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
|
||||
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
|
||||
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
|
||||
lastArtifactSyncStatus: lastArtifactSyncStatus,
|
||||
);
|
||||
final nextStatus =
|
||||
lifecycleStatus ??
|
||||
|
||||
@ -174,6 +174,7 @@ class GoTaskServiceRequest {
|
||||
this.routing,
|
||||
this.routingHint = '',
|
||||
this.provider = SingleAgentProvider.auto,
|
||||
this.remoteWorkingDirectoryHint = '',
|
||||
this.resumeSession = false,
|
||||
this.collaborationMode = GoTaskServiceCollaborationMode.standard,
|
||||
this.multiAgent = false,
|
||||
@ -196,6 +197,7 @@ class GoTaskServiceRequest {
|
||||
final ExternalCodeAgentAcpRoutingConfig? routing;
|
||||
final String routingHint;
|
||||
final SingleAgentProvider provider;
|
||||
final String remoteWorkingDirectoryHint;
|
||||
final bool resumeSession;
|
||||
final GoTaskServiceCollaborationMode collaborationMode;
|
||||
final bool multiAgent;
|
||||
@ -275,6 +277,8 @@ class GoTaskServiceRequest {
|
||||
)
|
||||
.toList(growable: false),
|
||||
if (provider != SingleAgentProvider.auto) 'provider': provider.providerId,
|
||||
if (remoteWorkingDirectoryHint.trim().isNotEmpty)
|
||||
'remoteWorkingDirectoryHint': remoteWorkingDirectoryHint.trim(),
|
||||
if (model.trim().isNotEmpty) 'model': model.trim(),
|
||||
if (thinking.trim().isNotEmpty) 'thinking': thinking.trim(),
|
||||
if (aiGatewayBaseUrl.trim().isNotEmpty)
|
||||
@ -370,6 +374,53 @@ class GoTaskServiceUpdate {
|
||||
bool get isDone => type == 'done' || payload['event'] == 'completed';
|
||||
}
|
||||
|
||||
class GoTaskServiceArtifact {
|
||||
const GoTaskServiceArtifact({
|
||||
required this.relativePath,
|
||||
required this.label,
|
||||
required this.contentType,
|
||||
required this.encoding,
|
||||
required this.content,
|
||||
required this.downloadUrl,
|
||||
required this.sizeBytes,
|
||||
required this.sha256,
|
||||
});
|
||||
|
||||
final String relativePath;
|
||||
final String label;
|
||||
final String contentType;
|
||||
final String encoding;
|
||||
final String content;
|
||||
final String downloadUrl;
|
||||
final int? sizeBytes;
|
||||
final String sha256;
|
||||
|
||||
bool get hasInlineContent => content.trim().isNotEmpty;
|
||||
|
||||
factory GoTaskServiceArtifact.fromJson(Map<String, dynamic> json) {
|
||||
int? parseSize(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
return GoTaskServiceArtifact(
|
||||
relativePath: json['relativePath']?.toString().trim() ?? '',
|
||||
label: json['label']?.toString().trim() ?? '',
|
||||
contentType: json['contentType']?.toString().trim() ?? '',
|
||||
encoding: json['encoding']?.toString().trim() ?? '',
|
||||
content: json['content']?.toString() ?? '',
|
||||
downloadUrl: json['downloadUrl']?.toString().trim() ?? '',
|
||||
sizeBytes: parseSize(json['sizeBytes'] ?? json['size']),
|
||||
sha256: json['sha256']?.toString().trim() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoTaskServiceResult {
|
||||
const GoTaskServiceResult({
|
||||
required this.success,
|
||||
@ -395,6 +446,11 @@ class GoTaskServiceResult {
|
||||
raw['workingDirectory']?.toString().trim() ??
|
||||
'';
|
||||
|
||||
String get resultSummary =>
|
||||
raw['resultSummary']?.toString().trim().isNotEmpty == true
|
||||
? raw['resultSummary'].toString().trim()
|
||||
: raw['summary']?.toString().trim() ?? '';
|
||||
|
||||
String get resolvedExecutionTarget =>
|
||||
raw['resolvedExecutionTarget']?.toString().trim() ?? '';
|
||||
|
||||
@ -429,6 +485,22 @@ class GoTaskServiceResult {
|
||||
List<Map<String, dynamic>> get memorySources =>
|
||||
_castMapList(raw['memorySources']);
|
||||
|
||||
List<GoTaskServiceArtifact> get artifacts {
|
||||
final rawArtifacts = raw['artifacts'];
|
||||
if (rawArtifacts is! List) {
|
||||
return const <GoTaskServiceArtifact>[];
|
||||
}
|
||||
return rawArtifacts
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => GoTaskServiceArtifact.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.where((item) => item.relativePath.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
WorkspaceRefKind? get resolvedWorkspaceRefKind {
|
||||
final rawValue = raw['resolvedWorkspaceRefKind']?.toString().trim() ?? '';
|
||||
if (rawValue.isEmpty) {
|
||||
@ -436,6 +508,25 @@ class GoTaskServiceResult {
|
||||
}
|
||||
return WorkspaceRefKindCopy.fromJsonValue(rawValue);
|
||||
}
|
||||
|
||||
String get remoteWorkingDirectory {
|
||||
final remoteExecution = _castMap(raw['remoteExecution']);
|
||||
return remoteExecution['remoteWorkingDirectory']?.toString().trim() ??
|
||||
raw['remoteWorkingDirectory']?.toString().trim() ??
|
||||
resolvedWorkingDirectory;
|
||||
}
|
||||
|
||||
WorkspaceRefKind? get remoteWorkspaceRefKind {
|
||||
final remoteExecution = _castMap(raw['remoteExecution']);
|
||||
final rawValue =
|
||||
remoteExecution['remoteWorkspaceRefKind']?.toString().trim() ??
|
||||
raw['remoteWorkspaceRefKind']?.toString().trim() ??
|
||||
'';
|
||||
if (rawValue.isEmpty) {
|
||||
return resolvedWorkspaceRefKind;
|
||||
}
|
||||
return WorkspaceRefKindCopy.fromJsonValue(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
String? goTaskServiceGatewayEntryState({
|
||||
|
||||
@ -765,6 +765,10 @@ class ThreadContextState {
|
||||
this.selectedModelSource = ThreadSelectionSource.inherited,
|
||||
this.selectedSkillsSource = ThreadSelectionSource.inherited,
|
||||
this.gatewayEntryState,
|
||||
this.lastRemoteWorkingDirectory,
|
||||
this.lastRemoteWorkspaceRefKind,
|
||||
this.lastArtifactSyncAtMs,
|
||||
this.lastArtifactSyncStatus,
|
||||
});
|
||||
|
||||
final List<GatewayChatMessage> messages;
|
||||
@ -777,6 +781,10 @@ class ThreadContextState {
|
||||
final ThreadSelectionSource selectedModelSource;
|
||||
final ThreadSelectionSource selectedSkillsSource;
|
||||
final String? gatewayEntryState;
|
||||
final String? lastRemoteWorkingDirectory;
|
||||
final WorkspaceRefKind? lastRemoteWorkspaceRefKind;
|
||||
final double? lastArtifactSyncAtMs;
|
||||
final String? lastArtifactSyncStatus;
|
||||
|
||||
ThreadContextState copyWith({
|
||||
List<GatewayChatMessage>? messages,
|
||||
@ -790,6 +798,10 @@ class ThreadContextState {
|
||||
ThreadSelectionSource? selectedSkillsSource,
|
||||
String? gatewayEntryState,
|
||||
bool clearGatewayEntryState = false,
|
||||
String? lastRemoteWorkingDirectory,
|
||||
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
|
||||
double? lastArtifactSyncAtMs,
|
||||
String? lastArtifactSyncStatus,
|
||||
}) {
|
||||
return ThreadContextState(
|
||||
messages: messages ?? this.messages,
|
||||
@ -805,6 +817,13 @@ class ThreadContextState {
|
||||
gatewayEntryState: clearGatewayEntryState
|
||||
? null
|
||||
: (gatewayEntryState ?? this.gatewayEntryState),
|
||||
lastRemoteWorkingDirectory:
|
||||
lastRemoteWorkingDirectory ?? this.lastRemoteWorkingDirectory,
|
||||
lastRemoteWorkspaceRefKind:
|
||||
lastRemoteWorkspaceRefKind ?? this.lastRemoteWorkspaceRefKind,
|
||||
lastArtifactSyncAtMs: lastArtifactSyncAtMs ?? this.lastArtifactSyncAtMs,
|
||||
lastArtifactSyncStatus:
|
||||
lastArtifactSyncStatus ?? this.lastArtifactSyncStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -822,10 +841,21 @@ class ThreadContextState {
|
||||
'selectedModelSource': selectedModelSource.name,
|
||||
'selectedSkillsSource': selectedSkillsSource.name,
|
||||
'gatewayEntryState': gatewayEntryState,
|
||||
'lastRemoteWorkingDirectory': lastRemoteWorkingDirectory,
|
||||
'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name,
|
||||
'lastArtifactSyncAtMs': lastArtifactSyncAtMs,
|
||||
'lastArtifactSyncStatus': lastArtifactSyncStatus,
|
||||
};
|
||||
}
|
||||
|
||||
factory ThreadContextState.fromJson(Map<String, dynamic> json) {
|
||||
double? asDouble(Object? value) {
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
return double.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
final rawMessages = json['messages'];
|
||||
final messages = rawMessages is List
|
||||
? rawMessages
|
||||
@ -876,6 +906,18 @@ class ThreadContextState {
|
||||
json['selectedSkillsSource']?.toString(),
|
||||
),
|
||||
gatewayEntryState: json['gatewayEntryState']?.toString(),
|
||||
lastRemoteWorkingDirectory:
|
||||
json['lastRemoteWorkingDirectory']?.toString(),
|
||||
lastRemoteWorkspaceRefKind: (() {
|
||||
final rawValue =
|
||||
json['lastRemoteWorkspaceRefKind']?.toString().trim() ?? '';
|
||||
if (rawValue.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return WorkspaceRefKindCopy.fromJsonValue(rawValue);
|
||||
})(),
|
||||
lastArtifactSyncAtMs: asDouble(json['lastArtifactSyncAtMs']),
|
||||
lastArtifactSyncStatus: json['lastArtifactSyncStatus']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -958,6 +1000,10 @@ class TaskThread {
|
||||
String? latestResolvedRuntimeModel,
|
||||
double? lastRunAtMs,
|
||||
String? lastResultCode,
|
||||
String? lastRemoteWorkingDirectory,
|
||||
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
|
||||
double? lastArtifactSyncAtMs,
|
||||
String? lastArtifactSyncStatus,
|
||||
}) : threadId = _resolveThreadId(threadId),
|
||||
title = title ?? '',
|
||||
ownerScope =
|
||||
@ -992,6 +1038,16 @@ class TaskThread {
|
||||
latestResolvedRuntimeModel:
|
||||
latestResolvedRuntimeModel?.trim() ?? '',
|
||||
gatewayEntryState: gatewayEntryState?.trim(),
|
||||
lastRemoteWorkingDirectory:
|
||||
lastRemoteWorkingDirectory?.trim().isNotEmpty == true
|
||||
? lastRemoteWorkingDirectory!.trim()
|
||||
: null,
|
||||
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
|
||||
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
|
||||
lastArtifactSyncStatus:
|
||||
lastArtifactSyncStatus?.trim().isNotEmpty == true
|
||||
? lastArtifactSyncStatus!.trim()
|
||||
: null,
|
||||
),
|
||||
lifecycleState =
|
||||
lifecycleState ??
|
||||
@ -1024,6 +1080,12 @@ class TaskThread {
|
||||
String get assistantModelId => contextState.selectedModelId;
|
||||
AssistantMessageViewMode get messageViewMode => contextState.messageViewMode;
|
||||
String? get gatewayEntryState => contextState.gatewayEntryState;
|
||||
String? get lastRemoteWorkingDirectory =>
|
||||
contextState.lastRemoteWorkingDirectory;
|
||||
WorkspaceRefKind? get lastRemoteWorkspaceRefKind =>
|
||||
contextState.lastRemoteWorkspaceRefKind;
|
||||
double? get lastArtifactSyncAtMs => contextState.lastArtifactSyncAtMs;
|
||||
String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus;
|
||||
String get latestResolvedRuntimeModel =>
|
||||
contextState.latestResolvedRuntimeModel;
|
||||
bool get hasExplicitExecutionTargetSelection =>
|
||||
@ -1060,6 +1122,10 @@ class TaskThread {
|
||||
String? gatewayEntryState,
|
||||
bool clearGatewayEntryState = false,
|
||||
String? latestResolvedRuntimeModel,
|
||||
String? lastRemoteWorkingDirectory,
|
||||
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
|
||||
double? lastArtifactSyncAtMs,
|
||||
String? lastArtifactSyncStatus,
|
||||
}) {
|
||||
return TaskThread(
|
||||
threadId: threadId ?? this.threadId,
|
||||
@ -1078,6 +1144,10 @@ class TaskThread {
|
||||
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
|
||||
gatewayEntryState: gatewayEntryState,
|
||||
clearGatewayEntryState: clearGatewayEntryState,
|
||||
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
|
||||
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
|
||||
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
|
||||
lastArtifactSyncStatus: lastArtifactSyncStatus,
|
||||
),
|
||||
lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith(
|
||||
archived: archived,
|
||||
@ -1213,6 +1283,10 @@ class TaskThread {
|
||||
'selectedModelSource': json['assistantModelSource'],
|
||||
'selectedSkillsSource': json['selectedSkillsSource'],
|
||||
'gatewayEntryState': json['gatewayEntryState'],
|
||||
'lastRemoteWorkingDirectory': json['lastRemoteWorkingDirectory'],
|
||||
'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'],
|
||||
'lastArtifactSyncAtMs': json['lastArtifactSyncAtMs'],
|
||||
'lastArtifactSyncStatus': json['lastArtifactSyncStatus'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ List<ManagedMountTargetState> withAvailableMountTargetsInternal(
|
||||
|
||||
Future<void> waitForInternal(
|
||||
bool Function() predicate, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!predicate()) {
|
||||
|
||||
@ -858,7 +858,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController rebinds local Single Agent threads to the structured resolved directory',
|
||||
'AppController keeps local Single Agent threads bound to the local workspace while recording remote metadata',
|
||||
() async {
|
||||
final tempDirectory = await createTempDirectoryInternal(
|
||||
'xworkmate-single-agent-remote-thread-cwd-',
|
||||
@ -918,29 +918,39 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
await controller.switchSession('draft:remote-thread');
|
||||
|
||||
await controller.sendChatMessage('第一次运行', thinking: 'low');
|
||||
final localThreadDir =
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread';
|
||||
const remoteThreadDir =
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread';
|
||||
expect(
|
||||
client.requests.first.workingDirectory,
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread',
|
||||
localThreadDir,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspacePathForSession('draft:remote-thread'),
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
localThreadDir,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceKindForSession('draft:remote-thread'),
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'draft:remote-thread',
|
||||
);
|
||||
expect(thread.lastRemoteWorkingDirectory, remoteThreadDir);
|
||||
expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.localPath);
|
||||
|
||||
await controller.sendChatMessage('第二次运行', thinking: 'low');
|
||||
expect(
|
||||
client.requests.last.workingDirectory,
|
||||
'/opt/data/.xworkmate/threads/draft-remote-thread',
|
||||
localThreadDir,
|
||||
);
|
||||
expect(client.requests.last.remoteWorkingDirectoryHint, remoteThreadDir);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController rebinds remote Single Agent threads to the resolved thread directory',
|
||||
'AppController keeps remote Single Agent threads on the local workspace and forwards the remote directory as a hint',
|
||||
() async {
|
||||
final tempDirectory = await createTempDirectoryInternal(
|
||||
'xworkmate-single-agent-remote-rebind-cwd-',
|
||||
@ -1013,27 +1023,145 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
|
||||
await controller.switchSession('draft:remote-thread');
|
||||
|
||||
await controller.sendChatMessage('第一次运行', thinking: 'low');
|
||||
final localThreadDir =
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread';
|
||||
expect(
|
||||
client.requests.first.workingDirectory,
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread',
|
||||
localThreadDir,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspacePathForSession('draft:remote-thread'),
|
||||
'/remote/threads/task-42',
|
||||
localThreadDir,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceKindForSession('draft:remote-thread'),
|
||||
WorkspaceRefKind.remotePath,
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'draft:remote-thread',
|
||||
);
|
||||
expect(thread.lastRemoteWorkingDirectory, '/remote/threads/task-42');
|
||||
expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
|
||||
|
||||
await controller.sendChatMessage('第二次运行', thinking: 'low');
|
||||
expect(
|
||||
client.requests.last.workingDirectory,
|
||||
localThreadDir,
|
||||
);
|
||||
expect(
|
||||
client.requests.last.remoteWorkingDirectoryHint,
|
||||
'/remote/threads/task-42',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController writes returned Single Agent artifacts into the local workspace and versions name conflicts',
|
||||
() async {
|
||||
final tempDirectory = await createTempDirectoryInternal(
|
||||
'xworkmate-single-agent-artifact-sync-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
|
||||
final store = createStoreFromTempDirectoryInternal(tempDirectory);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final artifactPayload = base64Encode(utf8.encode('new report body'));
|
||||
final client = FakeGoTaskServiceClientInternal(
|
||||
capabilities: ExternalCodeAgentAcpCapabilities(
|
||||
singleAgent: true,
|
||||
multiAgent: false,
|
||||
providers: <SingleAgentProvider>{SingleAgentProvider.opencode},
|
||||
raw: <String, dynamic>{},
|
||||
),
|
||||
result: GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'ARTIFACT_OK',
|
||||
turnId: 'turn-artifact-1',
|
||||
raw: <String, dynamic>{
|
||||
'resolvedWorkingDirectory': '/remote/threads/artifact-thread',
|
||||
'resolvedWorkspaceRefKind': 'remotePath',
|
||||
'artifacts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'relativePath': 'outputs/report.docx',
|
||||
'label': 'Report',
|
||||
'contentType':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'encoding': 'base64',
|
||||
'content': artifactPayload,
|
||||
},
|
||||
],
|
||||
},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
final controller = await createAppControllerInternal(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: FakeGatewayRuntimeInternal(store: store),
|
||||
codex: FakeCodexRuntimeInternal(),
|
||||
),
|
||||
goTaskServiceClient: client,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:artifact-thread',
|
||||
title: 'Artifact Thread',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession('draft:artifact-thread');
|
||||
|
||||
final localThreadDir = Directory(
|
||||
'${defaultWorkspace.path}/.xworkmate/threads/draft-artifact-thread',
|
||||
);
|
||||
await localThreadDir.create(recursive: true);
|
||||
final existingArtifact = File('${localThreadDir.path}/outputs/report.docx');
|
||||
await existingArtifact.parent.create(recursive: true);
|
||||
await existingArtifact.writeAsString('old report body');
|
||||
|
||||
await controller.sendChatMessage('生成文档', thinking: 'low');
|
||||
|
||||
final versionedArtifact = File(
|
||||
'${localThreadDir.path}/outputs/report.v2.docx',
|
||||
);
|
||||
expect(existingArtifact.existsSync(), isTrue);
|
||||
expect(versionedArtifact.existsSync(), isTrue);
|
||||
expect(await versionedArtifact.readAsString(), 'new report body');
|
||||
expect(
|
||||
controller.assistantWorkspacePathForSession('draft:artifact-thread'),
|
||||
localThreadDir.path,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceKindForSession('draft:artifact-thread'),
|
||||
WorkspaceRefKind.localPath,
|
||||
);
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'draft:artifact-thread',
|
||||
);
|
||||
expect(
|
||||
thread.lastRemoteWorkingDirectory,
|
||||
'/remote/threads/artifact-thread',
|
||||
);
|
||||
expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
|
||||
expect(thread.lastArtifactSyncStatus, 'synced');
|
||||
expect(thread.lastArtifactSyncAtMs, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps local Codex-style working directories for remote thread refs',
|
||||
() async {
|
||||
|
||||
1
vendor/codex
vendored
1
vendor/codex
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 78280f872a58dfbb51d2883791d036db00cbfe0f
|
||||
BIN
web/favicon.png
BIN
web/favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
@ -1,49 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta
|
||||
name="description"
|
||||
content="XWorkmate Web keeps the Assistant-first workflow with Single Agent and Relay OpenClaw Gateway."
|
||||
>
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="XWorkmate">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>XWorkmate</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "XWorkmate",
|
||||
"short_name": "XWorkmate",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Assistant-first Flutter Web shell for Single Agent and Relay OpenClaw Gateway.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user