xworkmate-app/lib/web/web_relay_gateway_client.dart
2026-03-24 20:48:59 +08:00

952 lines
29 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:cryptography/cryptography.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../app/app_metadata.dart';
import '../runtime/runtime_models.dart';
import 'web_store.dart';
class GatewayPushEvent {
const GatewayPushEvent({
required this.event,
required this.payload,
this.sequence,
});
final String event;
final dynamic payload;
final int? sequence;
}
class WebRelayGatewayClient {
WebRelayGatewayClient(this._store);
final WebStore _store;
final StreamController<GatewayPushEvent> _events =
StreamController<GatewayPushEvent>.broadcast();
final Map<String, Completer<_RelayRpcResponse>> _pending =
<String, Completer<_RelayRpcResponse>>{};
final _WebRelayIdentityManager _identityManager = _WebRelayIdentityManager();
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
int _requestCounter = 0;
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.unconfigured,
);
Stream<GatewayPushEvent> get events => _events.stream;
GatewayConnectionSnapshot get snapshot => _snapshot;
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
String get mainSessionKey => _snapshot.mainSessionKey ?? 'main';
Future<void> connect({
required GatewayConnectionProfile profile,
required String authToken,
required String authPassword,
}) async {
await disconnect();
final targetMode = profile.mode == RuntimeConnectionMode.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final endpoint = _resolveEndpoint(profile);
if (endpoint == null) {
_snapshot =
GatewayConnectionSnapshot.initial(
mode: targetMode,
).copyWith(
status: RuntimeConnectionStatus.error,
statusText: 'Missing relay endpoint',
lastError: 'Configure relay host / port first.',
lastErrorCode: 'MISSING_ENDPOINT',
);
throw const WebRelayGatewayException('Missing relay endpoint');
}
final identity = await _identityManager.loadOrCreate(_store);
_snapshot =
GatewayConnectionSnapshot.initial(
mode: targetMode,
).copyWith(
status: RuntimeConnectionStatus.connecting,
statusText: 'Connecting…',
remoteAddress: '${endpoint.host}:${endpoint.port}',
deviceId: identity.deviceId,
authRole: 'operator',
authScopes: const <String>[
'operator.admin',
'operator.read',
'operator.write',
'operator.approvals',
'operator.pairing',
],
connectAuthMode: authToken.trim().isNotEmpty
? 'shared-token'
: authPassword.trim().isNotEmpty
? 'password'
: 'none',
connectAuthFields: <String>[
if (authToken.trim().isNotEmpty) 'token',
if (authPassword.trim().isNotEmpty) 'password',
],
connectAuthSources: <String>[
if (authToken.trim().isNotEmpty) 'browser-store',
if (authPassword.trim().isNotEmpty) 'browser-store',
],
hasSharedAuth:
authToken.trim().isNotEmpty || authPassword.trim().isNotEmpty,
hasDeviceToken: false,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
final uri = Uri(
scheme: endpoint.tls ? 'wss' : 'ws',
host: endpoint.host,
port: endpoint.port,
);
final channel = WebSocketChannel.connect(uri);
final challenge = Completer<String>();
_channel = channel;
_subscription = channel.stream.listen(
(dynamic raw) => _handleIncoming(raw, challenge),
onError: (Object error, StackTrace stackTrace) {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.error,
statusText: 'Relay error',
lastError: error.toString(),
lastErrorCode: 'SOCKET_FAILURE',
);
},
onDone: () {
if (_snapshot.status == RuntimeConnectionStatus.connected) {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.error,
statusText: 'Disconnected',
lastError: 'Relay connection closed',
lastErrorCode: 'SOCKET_CLOSED',
);
}
},
cancelOnError: true,
);
try {
await channel.ready;
final nonce = await challenge.future.timeout(
const Duration(seconds: 5),
onTimeout: () =>
throw const WebRelayGatewayException('Relay challenge timeout'),
);
final result = await _requestRaw(
'connect',
params: await _buildConnectParams(
identity: identity,
nonce: nonce,
authToken: authToken.trim(),
authPassword: authPassword.trim(),
),
timeout: const Duration(seconds: 12),
);
final payload = _asMap(result.payload);
final auth = _asMap(payload['auth']);
final snapshot = _asMap(payload['snapshot']);
final sessionDefaults = _asMap(snapshot['sessionDefaults']);
final server = _asMap(payload['server']);
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
mode: targetMode,
serverName: _stringValue(server['host']),
remoteAddress: '${endpoint.host}:${endpoint.port}',
mainSessionKey:
_stringValue(sessionDefaults['mainSessionKey']) ?? 'main',
lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch,
authRole: _stringValue(auth['role']) ?? 'operator',
authScopes: _stringList(auth['scopes']),
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
} catch (error) {
await disconnect();
_snapshot = _snapshot.copyWith(
mode: targetMode,
status: RuntimeConnectionStatus.error,
statusText: 'Connection failed',
lastError: error.toString(),
lastErrorCode: 'CONNECT_FAILED',
);
rethrow;
}
}
Future<void> disconnect() async {
for (final pending in _pending.values) {
if (!pending.isCompleted) {
pending.completeError(
const WebRelayGatewayException('Relay request cancelled'),
);
}
}
_pending.clear();
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
if (_snapshot.status != RuntimeConnectionStatus.offline) {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
clearRemoteAddress: true,
);
}
}
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
final payload = _asMap(
await request(
'sessions.list',
params: <String, dynamic>{
'includeGlobal': true,
'includeUnknown': false,
'includeDerivedTitles': true,
'includeLastMessage': true,
'limit': limit,
},
),
);
return _asList(payload['sessions'])
.map((item) {
final map = _asMap(item);
return GatewaySessionSummary(
key: _stringValue(map['key']) ?? 'main',
kind: _stringValue(map['kind']),
displayName:
_stringValue(map['displayName']) ?? _stringValue(map['label']),
surface: _stringValue(map['surface']),
subject: _stringValue(map['subject']),
room: _stringValue(map['room']),
space: _stringValue(map['space']),
updatedAtMs: _doubleValue(map['updatedAt']),
sessionId: _stringValue(map['sessionId']),
systemSent: _boolValue(map['systemSent']),
abortedLastRun: _boolValue(map['abortedLastRun']),
thinkingLevel: _stringValue(map['thinkingLevel']),
verboseLevel: _stringValue(map['verboseLevel']),
inputTokens: _intValue(map['inputTokens']),
outputTokens: _intValue(map['outputTokens']),
totalTokens: _intValue(map['totalTokens']),
model: _stringValue(map['model']),
contextTokens: _intValue(map['contextTokens']),
derivedTitle: _stringValue(map['derivedTitle']),
lastMessagePreview: _stringValue(map['lastMessagePreview']),
);
})
.toList(growable: false);
}
Future<List<GatewayChatMessage>> loadHistory(
String sessionKey, {
int limit = 120,
}) async {
final payload = _asMap(
await request(
'chat.history',
params: <String, dynamic>{'sessionKey': sessionKey, 'limit': limit},
),
);
return _asList(payload['messages'])
.map((item) {
final map = _asMap(item);
return GatewayChatMessage(
id: _randomId(),
role: _stringValue(map['role']) ?? 'assistant',
text: _extractMessageText(map),
timestampMs: _doubleValue(map['timestamp']),
toolCallId:
_stringValue(map['toolCallId']) ??
_stringValue(map['tool_call_id']),
toolName:
_stringValue(map['toolName']) ?? _stringValue(map['tool_name']),
stopReason: _stringValue(map['stopReason']),
pending: false,
error: false,
);
})
.toList(growable: false);
}
Future<String> sendChat({
required String sessionKey,
required String message,
required String thinking,
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
Map<String, dynamic> metadata = const <String, dynamic>{},
}) async {
final runId = _randomId();
final normalizedMetadata = <String, dynamic>{
for (final entry in metadata.entries)
if (entry.key.trim().isNotEmpty) entry.key: entry.value,
};
final payload = _asMap(
await request(
'chat.send',
params: <String, dynamic>{
'sessionKey': sessionKey,
'message': message,
'thinking': thinking,
if (attachments.isNotEmpty)
'attachments': attachments
.map((item) => item.toJson())
.toList(growable: false),
if (normalizedMetadata.isNotEmpty) 'metadata': normalizedMetadata,
'timeoutMs': 30000,
'idempotencyKey': runId,
},
timeout: const Duration(seconds: 35),
),
);
return _stringValue(payload['runId']) ?? runId;
}
Future<List<GatewayModelSummary>> listModels() async {
final payload = _asMap(await request('models.list'));
return _asList(payload['models'])
.map((item) {
final map = _asMap(item);
return GatewayModelSummary(
id: _stringValue(map['id']) ?? 'unknown',
name:
_stringValue(map['name']) ??
_stringValue(map['id']) ??
'unknown',
provider: _stringValue(map['provider']) ?? 'relay',
contextWindow: _intValue(map['contextWindow']),
maxOutputTokens: _intValue(map['maxOutputTokens']),
);
})
.toList(growable: false);
}
Future<List<GatewayAgentSummary>> listAgents() async {
final payload = _asMap(
await request('agents.list', params: const <String, dynamic>{}),
);
return _asList(payload['agents'])
.map((item) {
final map = _asMap(item);
final identity = _asMap(map['identity']);
return GatewayAgentSummary(
id: _stringValue(map['id']) ?? 'unknown',
name:
_stringValue(map['name']) ??
_stringValue(identity['name']) ??
'Agent',
emoji: _stringValue(identity['emoji']) ?? '·',
theme: _stringValue(identity['theme']) ?? 'default',
);
})
.toList(growable: false);
}
Future<List<GatewayInstanceSummary>> listInstances() async {
final payload = await request(
'system-presence',
params: const <String, dynamic>{},
);
return _asList(payload)
.map((item) {
final map = _asMap(item);
return GatewayInstanceSummary(
id: _stringValue(map['id']) ?? _randomId(),
host: _stringValue(map['host']),
ip: _stringValue(map['ip']),
version: _stringValue(map['version']),
platform: _stringValue(map['platform']),
deviceFamily: _stringValue(map['deviceFamily']),
modelIdentifier: _stringValue(map['modelIdentifier']),
lastInputSeconds: _intValue(map['lastInputSeconds']),
mode: _stringValue(map['mode']),
reason: _stringValue(map['reason']),
text: _stringValue(map['text']) ?? '',
timestampMs:
_doubleValue(map['ts']) ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
);
})
.toList(growable: false);
}
Future<List<GatewayConnectorSummary>> listConnectors() async {
final payload = _asMap(
await request(
'channels.status',
params: const <String, dynamic>{'probe': true, 'timeoutMs': 8000},
timeout: const Duration(seconds: 16),
),
);
final channelMeta = <String, Map<String, dynamic>>{
for (final entry in _asList(payload['channelMeta']))
if (_stringValue(_asMap(entry)['id']) != null)
_stringValue(_asMap(entry)['id'])!: _asMap(entry),
};
final labels = _asMap(payload['channelLabels']);
final detailLabels = _asMap(payload['channelDetailLabels']);
final accounts = _asMap(payload['channelAccounts']);
final order = _stringList(payload['channelOrder']);
final summaries = <GatewayConnectorSummary>[];
for (final channelId in order) {
final channelAccounts = _asList(accounts[channelId]);
if (channelAccounts.isEmpty) {
final meta = channelMeta[channelId] ?? const <String, dynamic>{};
summaries.add(
GatewayConnectorSummary(
id: channelId,
label:
_stringValue(meta['label']) ??
_stringValue(labels[channelId]) ??
channelId,
detailLabel:
_stringValue(meta['detailLabel']) ??
_stringValue(detailLabels[channelId]) ??
channelId,
accountName: null,
configured: false,
enabled: false,
running: false,
connected: false,
status: 'idle',
lastError: null,
meta: const <String>[],
),
);
continue;
}
for (final account in channelAccounts) {
final map = _asMap(account);
final configured = _boolValue(map['configured']) ?? false;
final enabled = _boolValue(map['enabled']) ?? configured;
final running = _boolValue(map['running']) ?? false;
final connected =
_boolValue(map['connected']) ?? _boolValue(map['linked']) ?? false;
final lastError = _stringValue(map['lastError']);
final status = lastError != null && lastError.trim().isNotEmpty
? 'error'
: connected
? 'connected'
: running
? 'running'
: configured
? 'configured'
: 'idle';
final mode = _stringValue(map['mode']);
final tokenSource = _stringValue(map['tokenSource']);
final baseUrl = _stringValue(map['baseUrl']);
summaries.add(
GatewayConnectorSummary(
id: channelId,
label:
_stringValue(channelMeta[channelId]?['label']) ??
_stringValue(labels[channelId]) ??
channelId,
detailLabel:
_stringValue(channelMeta[channelId]?['detailLabel']) ??
_stringValue(detailLabels[channelId]) ??
channelId,
accountName:
_stringValue(map['name']) ?? _stringValue(map['accountId']),
configured: configured,
enabled: enabled,
running: running,
connected: connected,
status: status,
lastError: lastError,
meta: [
...?(mode == null ? null : <String>[mode]),
...?(tokenSource == null ? null : <String>[tokenSource]),
...?(baseUrl == null ? null : <String>[baseUrl]),
],
),
);
}
}
return summaries;
}
Future<List<GatewayCronJobSummary>> listCronJobs() async {
final payload = _asMap(
await request(
'cron.list',
params: const <String, dynamic>{'includeDisabled': true},
timeout: const Duration(seconds: 16),
),
);
return _asList(payload['jobs'])
.map((item) {
final map = _asMap(item);
final state = _asMap(map['state']);
return GatewayCronJobSummary(
id: _stringValue(map['id']) ?? _randomId(),
name: _stringValue(map['name']) ?? 'Untitled job',
description: _stringValue(map['description']),
enabled: _boolValue(map['enabled']) ?? true,
agentId: _stringValue(map['agentId']),
scheduleLabel: _cronScheduleLabel(_asMap(map['schedule'])),
nextRunAtMs: _intValue(state['nextRunAtMs']),
lastRunAtMs: _intValue(state['lastRunAtMs']),
lastStatus: _stringValue(state['lastStatus']),
lastError: _stringValue(state['lastError']),
);
})
.toList(growable: false);
}
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 15),
}) async {
if (_channel == null || !isConnected) {
throw const WebRelayGatewayException('Relay not connected');
}
final result = await _requestRaw(method, params: params, timeout: timeout);
return result.payload;
}
Future<_RelayRpcResponse> _requestRaw(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 15),
}) async {
final channel = _channel;
if (channel == null) {
throw const WebRelayGatewayException('Relay not connected');
}
final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}';
final completer = Completer<_RelayRpcResponse>();
_pending[id] = completer;
channel.sink.add(
jsonEncode(<String, dynamic>{
'type': 'req',
'id': id,
'method': method,
if (params != null && params.isNotEmpty) 'params': params,
}),
);
try {
return await completer.future.timeout(
timeout,
onTimeout: () =>
throw WebRelayGatewayException('$method request timeout'),
);
} finally {
_pending.remove(id);
}
}
Future<Map<String, dynamic>> _buildConnectParams({
required LocalDeviceIdentity identity,
required String nonce,
required String authToken,
required String authPassword,
}) async {
const scopes = <String>[
'operator.admin',
'operator.read',
'operator.write',
'operator.approvals',
'operator.pairing',
];
const clientId = 'xworkmate-web';
const clientMode = 'ui';
final signedAtMs = DateTime.now().millisecondsSinceEpoch;
final signaturePayload = _identityManager.buildDeviceAuthPayloadV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
role: 'operator',
scopes: scopes,
signedAtMs: signedAtMs,
token: authToken,
nonce: nonce,
platform: 'web',
deviceFamily: 'Browser',
);
final signature = await _identityManager.signPayload(
identity: identity,
payload: signaturePayload,
);
return <String, dynamic>{
'minProtocol': 3,
'maxProtocol': 3,
'client': <String, dynamic>{
'id': clientId,
'displayName': '$kSystemAppName Browser',
'version': kAppVersion,
'platform': 'web',
'deviceFamily': 'Browser',
'modelIdentifier': 'browser',
'mode': clientMode,
'instanceId':
'$clientId-${identity.deviceId.substring(0, min(8, identity.deviceId.length))}',
},
'caps': const <String>['tool-events'],
'commands': const <String>[],
'permissions': const <String, bool>{},
'role': 'operator',
'scopes': scopes,
if (authToken.isNotEmpty || authPassword.isNotEmpty)
'auth': <String, dynamic>{
if (authToken.isNotEmpty) 'token': authToken,
if (authPassword.isNotEmpty) 'password': authPassword,
},
'locale': 'web',
'userAgent': '$kSystemAppName/$kAppVersion web',
'device': <String, dynamic>{
'id': identity.deviceId,
'publicKey': identity.publicKeyBase64Url,
'signature': signature,
'signedAt': signedAtMs,
'nonce': nonce,
},
};
}
void _handleIncoming(dynamic raw, Completer<String> challenge) {
final text = raw is String ? raw : utf8.decode(raw as List<int>);
final decoded = jsonDecode(text) as Map<String, dynamic>;
final type = _stringValue(decoded['type']);
if (type == 'event') {
final event = _stringValue(decoded['event']) ?? '';
final payload = decoded['payload'];
if (event == 'connect.challenge') {
final nonce = _stringValue(_asMap(payload)['nonce']);
if (nonce != null && !challenge.isCompleted) {
challenge.complete(nonce);
}
return;
}
_events.add(
GatewayPushEvent(
event: event,
payload: payload,
sequence: _intValue(decoded['seq']),
),
);
return;
}
if (type != 'res') {
return;
}
final id = _stringValue(decoded['id']);
if (id == null) {
return;
}
final completer = _pending.remove(id);
if (completer == null || completer.isCompleted) {
return;
}
final ok = _boolValue(decoded['ok']) ?? false;
if (!ok) {
final error = _asMap(decoded['error']);
completer.completeError(
WebRelayGatewayException(
_stringValue(error['message']) ?? 'Relay request failed',
),
);
return;
}
completer.complete(
_RelayRpcResponse(
ok: true,
payload: decoded['payload'],
error: _asMap(decoded['error']),
),
);
}
_ResolvedRelayEndpoint? _resolveEndpoint(GatewayConnectionProfile profile) {
final rawHost = profile.host.trim();
if (rawHost.isEmpty) {
return null;
}
final candidate = rawHost.contains('://')
? rawHost
: '${profile.tls ? 'https' : 'http'}://$rawHost:${profile.port}';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final tls = switch (uri.scheme.trim().toLowerCase()) {
'http' || 'ws' => false,
_ => true,
};
return _ResolvedRelayEndpoint(
host: uri.host.trim(),
port: uri.hasPort ? uri.port : (tls ? 443 : 80),
tls: tls,
);
}
Future<void> dispose() async {
await disconnect();
await _events.close();
}
}
class WebRelayGatewayException implements Exception {
const WebRelayGatewayException(this.message);
final String message;
@override
String toString() => message;
}
class _ResolvedRelayEndpoint {
const _ResolvedRelayEndpoint({
required this.host,
required this.port,
required this.tls,
});
final String host;
final int port;
final bool tls;
}
class _RelayRpcResponse {
const _RelayRpcResponse({
required this.ok,
required this.payload,
required this.error,
});
final bool ok;
final dynamic payload;
final Map<String, dynamic> error;
}
class _WebRelayIdentityManager {
final Ed25519 _algorithm = Ed25519();
Future<LocalDeviceIdentity> loadOrCreate(WebStore store) async {
final existing = await store.loadRelayDeviceIdentity();
if (existing != null &&
existing.deviceId.isNotEmpty &&
existing.publicKeyBase64Url.isNotEmpty &&
existing.privateKeyBase64Url.isNotEmpty) {
return existing;
}
final keyPair = await _algorithm.newKeyPair();
final publicKey = await keyPair.extractPublicKey();
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
final publicKeyBytes = publicKey.bytes;
final identity = LocalDeviceIdentity(
deviceId: _deriveDeviceId(publicKeyBytes),
publicKeyBase64Url: _base64UrlEncode(publicKeyBytes),
privateKeyBase64Url: _base64UrlEncode(privateKeyBytes),
createdAtMs: DateTime.now().millisecondsSinceEpoch,
);
await store.saveRelayDeviceIdentity(identity);
return identity;
}
Future<String> signPayload({
required LocalDeviceIdentity identity,
required String payload,
}) async {
final publicKeyBytes = _base64UrlDecode(identity.publicKeyBase64Url);
final privateKeyBytes = _base64UrlDecode(identity.privateKeyBase64Url);
final keyPair = SimpleKeyPairData(
privateKeyBytes,
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.ed25519),
type: KeyPairType.ed25519,
);
final signature = await _algorithm.sign(
utf8.encode(payload),
keyPair: keyPair,
);
return _base64UrlEncode(signature.bytes);
}
String buildDeviceAuthPayloadV3({
required String deviceId,
required String clientId,
required String clientMode,
required String role,
required List<String> scopes,
required int signedAtMs,
required String token,
required String nonce,
required String platform,
required String deviceFamily,
}) {
return <String>[
'v3',
deviceId,
clientId,
clientMode,
role,
scopes.join(','),
'$signedAtMs',
token,
nonce,
_normalizeMetadata(platform),
_normalizeMetadata(deviceFamily),
].join('|');
}
String _normalizeMetadata(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return '';
}
final buffer = StringBuffer();
for (final rune in trimmed.runes) {
if (rune >= 65 && rune <= 90) {
buffer.writeCharCode(rune + 32);
} else {
buffer.writeCharCode(rune);
}
}
return buffer.toString();
}
String _deriveDeviceId(List<int> publicKeyBytes) {
return crypto.sha256.convert(publicKeyBytes).toString();
}
String _base64UrlEncode(List<int> value) {
return base64Url.encode(value).replaceAll('=', '');
}
Uint8List _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
return Uint8List.fromList(base64.decode(padded));
}
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
List<Object?> _asList(Object? value) {
if (value is List<Object?>) {
return value;
}
if (value is List) {
return value.cast<Object?>();
}
return const <Object?>[];
}
String? _stringValue(Object? value) {
final text = value?.toString().trim() ?? '';
return text.isEmpty ? null : text;
}
int? _intValue(Object? value) {
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? '');
}
double? _doubleValue(Object? value) {
if (value is num) {
return value.toDouble();
}
return double.tryParse(value?.toString() ?? '');
}
bool? _boolValue(Object? value) {
if (value is bool) {
return value;
}
if (value is num) {
return value != 0;
}
final normalized = value?.toString().trim().toLowerCase();
if (normalized == 'true') {
return true;
}
if (normalized == 'false') {
return false;
}
return null;
}
List<String> _stringList(Object? value) {
return _asList(
value,
).map(_stringValue).whereType<String>().toList(growable: false);
}
String _extractMessageText(Map<String, dynamic> message) {
final directContent = message['content'];
if (directContent is String) {
return directContent;
}
final parts = <String>[];
for (final part in _asList(directContent)) {
final map = _asMap(part);
final text = _stringValue(map['text']) ?? _stringValue(map['thinking']);
if (text != null && text.isNotEmpty) {
parts.add(text);
continue;
}
final nestedContent = map['content'];
if (nestedContent is String && nestedContent.trim().isNotEmpty) {
parts.add(nestedContent.trim());
}
}
return parts.join('\n').trim();
}
String _cronScheduleLabel(Map<String, dynamic> schedule) {
final type = _stringValue(schedule['type']) ?? 'cron';
final every = _intValue(schedule['every']);
final at = _stringValue(schedule['at']);
final weekdays = _stringList(schedule['weekdays']);
final parts = <String>[type];
if (every != null && every > 0) {
parts.add('every $every');
}
if (weekdays.isNotEmpty) {
parts.add(weekdays.join(','));
}
if (at != null && at.isNotEmpty) {
parts.add(at);
}
return parts.join(' · ');
}
String _randomId() {
final random = Random.secure();
final timestamp = DateTime.now().microsecondsSinceEpoch.toRadixString(16);
final suffix = List<int>.generate(
6,
(_) => random.nextInt(256),
).map((value) => value.toRadixString(16).padLeft(2, '0')).join();
return '$timestamp-$suffix';
}