895 lines
28 KiB
Dart
895 lines
28 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../i18n/app_language.dart';
|
|
import '../models/app_models.dart';
|
|
import '../runtime/runtime_models.dart';
|
|
import '../web/web_ai_gateway_client.dart';
|
|
import '../web/web_relay_gateway_client.dart';
|
|
import '../web/web_store.dart';
|
|
import 'app_capabilities.dart';
|
|
|
|
class AppController extends ChangeNotifier {
|
|
AppController({
|
|
WebStore? store,
|
|
WebAiGatewayClient? aiGatewayClient,
|
|
WebRelayGatewayClient? relayClient,
|
|
}) : _store = store ?? WebStore(),
|
|
_aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient() {
|
|
_relayClient = relayClient ?? WebRelayGatewayClient(_store);
|
|
_relayEventsSubscription = _relayClient.events.listen(_handleRelayEvent);
|
|
unawaited(_initialize());
|
|
}
|
|
|
|
final WebStore _store;
|
|
final WebAiGatewayClient _aiGatewayClient;
|
|
late final WebRelayGatewayClient _relayClient;
|
|
|
|
late final StreamSubscription<GatewayPushEvent> _relayEventsSubscription;
|
|
|
|
SettingsSnapshot _settings = SettingsSnapshot.defaults();
|
|
ThemeMode _themeMode = ThemeMode.light;
|
|
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
|
SettingsTab _settingsTab = SettingsTab.general;
|
|
bool _initializing = true;
|
|
String? _bootstrapError;
|
|
bool _relayBusy = false;
|
|
bool _aiGatewayBusy = false;
|
|
final Map<String, AssistantThreadRecord> _threadRecords =
|
|
<String, AssistantThreadRecord>{};
|
|
final Set<String> _pendingSessionKeys = <String>{};
|
|
final Map<String, String> _streamingTextBySession = <String, String>{};
|
|
String _currentSessionKey = '';
|
|
String? _lastAssistantError;
|
|
|
|
AppCapabilities get capabilities => AppCapabilities.web;
|
|
WorkspaceDestination get destination => _destination;
|
|
SettingsTab get settingsTab => _settingsTab;
|
|
ThemeMode get themeMode => _themeMode;
|
|
bool get initializing => _initializing;
|
|
String? get bootstrapError => _bootstrapError;
|
|
SettingsSnapshot get settings => _settings;
|
|
AppLanguage get appLanguage => _settings.appLanguage;
|
|
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
|
|
bool get relayBusy => _relayBusy;
|
|
bool get aiGatewayBusy => _aiGatewayBusy;
|
|
String? get lastAssistantError => _lastAssistantError;
|
|
String get currentSessionKey => _currentSessionKey;
|
|
bool get supportsDesktopIntegration => false;
|
|
bool get hasStoredGatewayToken => storedRelayTokenMask != null;
|
|
bool get hasStoredAiGatewayApiKey => storedAiGatewayApiKeyMask != null;
|
|
String? get storedGatewayTokenMask => storedRelayTokenMask;
|
|
String? get storedRelayTokenMask => WebStore.maskValue(
|
|
_relayTokenCache.trim().isEmpty ? '' : _relayTokenCache,
|
|
);
|
|
String? get storedRelayPasswordMask => WebStore.maskValue(
|
|
_relayPasswordCache.trim().isEmpty ? '' : _relayPasswordCache,
|
|
);
|
|
String? get storedAiGatewayApiKeyMask => WebStore.maskValue(
|
|
_aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache,
|
|
);
|
|
|
|
String _relayTokenCache = '';
|
|
String _relayPasswordCache = '';
|
|
String _aiGatewayApiKeyCache = '';
|
|
|
|
AssistantExecutionTarget get assistantExecutionTarget =>
|
|
_currentRecord.executionTarget ?? _settings.assistantExecutionTarget;
|
|
AssistantExecutionTarget get currentAssistantExecutionTarget =>
|
|
assistantExecutionTarget;
|
|
bool get isAiGatewayOnlyMode =>
|
|
assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly;
|
|
List<GatewayChatMessage> get chatMessages {
|
|
final base = List<GatewayChatMessage>.from(_currentRecord.messages);
|
|
final streaming = _streamingTextBySession[_currentSessionKey]?.trim() ?? '';
|
|
if (streaming.isNotEmpty) {
|
|
base.add(
|
|
GatewayChatMessage(
|
|
id: 'streaming',
|
|
role: 'assistant',
|
|
text: streaming,
|
|
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
toolCallId: null,
|
|
toolName: null,
|
|
stopReason: null,
|
|
pending: true,
|
|
error: false,
|
|
),
|
|
);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
List<WebConversationSummary> get conversations {
|
|
final entries =
|
|
_threadRecords.values
|
|
.map(
|
|
(record) => WebConversationSummary(
|
|
sessionKey: record.sessionKey,
|
|
title: _titleForRecord(record),
|
|
preview: _previewForRecord(record),
|
|
updatedAtMs:
|
|
record.updatedAtMs ??
|
|
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
executionTarget:
|
|
_sanitizeTarget(record.executionTarget) ??
|
|
AssistantExecutionTarget.aiGatewayOnly,
|
|
pending: _pendingSessionKeys.contains(record.sessionKey),
|
|
current: record.sessionKey == _currentSessionKey,
|
|
),
|
|
)
|
|
.toList(growable: true)
|
|
..sort((left, right) {
|
|
if (left.current != right.current) {
|
|
return left.current ? -1 : 1;
|
|
}
|
|
return right.updatedAtMs.compareTo(left.updatedAtMs);
|
|
});
|
|
return entries;
|
|
}
|
|
|
|
List<WebConversationSummary> conversationsForTarget(
|
|
AssistantExecutionTarget target,
|
|
) {
|
|
return conversations
|
|
.where((item) => item.executionTarget == target)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String get aiGatewayUrl => _settings.aiGateway.baseUrl.trim();
|
|
String get resolvedAiGatewayModel {
|
|
final current = _settings.defaultModel.trim();
|
|
final choices = aiGatewayConversationModelChoices;
|
|
if (choices.contains(current)) {
|
|
return current;
|
|
}
|
|
if (choices.isNotEmpty) {
|
|
return choices.first;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
List<String> get aiGatewayConversationModelChoices {
|
|
final selected = _settings.aiGateway.selectedModels
|
|
.map((item) => item.trim())
|
|
.where(
|
|
(item) =>
|
|
item.isNotEmpty &&
|
|
_settings.aiGateway.availableModels.contains(item),
|
|
)
|
|
.toList(growable: false);
|
|
if (selected.isNotEmpty) {
|
|
return selected;
|
|
}
|
|
return _settings.aiGateway.availableModels
|
|
.map((item) => item.trim())
|
|
.where((item) => item.isNotEmpty)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
bool get canUseAiGatewayConversation =>
|
|
aiGatewayUrl.isNotEmpty &&
|
|
_aiGatewayApiKeyCache.trim().isNotEmpty &&
|
|
resolvedAiGatewayModel.isNotEmpty;
|
|
|
|
String get assistantConnectionStatusLabel => isAiGatewayOnlyMode
|
|
? (canUseAiGatewayConversation
|
|
? appText('可用', 'Ready')
|
|
: appText('未配置', 'Not configured'))
|
|
: connection.status.label;
|
|
|
|
String get assistantConnectionTargetLabel {
|
|
if (!isAiGatewayOnlyMode) {
|
|
return connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline');
|
|
}
|
|
final host = _hostLabel(_settings.aiGateway.baseUrl);
|
|
final model = resolvedAiGatewayModel;
|
|
if (host.isEmpty && model.isEmpty) {
|
|
return appText('Direct AI 未配置', 'Direct AI not configured');
|
|
}
|
|
if (host.isNotEmpty && model.isNotEmpty) {
|
|
return '$model · $host';
|
|
}
|
|
return host.isNotEmpty ? host : model;
|
|
}
|
|
|
|
String get currentConversationTitle => _titleForRecord(_currentRecord);
|
|
|
|
AssistantThreadRecord get _currentRecord {
|
|
final existing = _threadRecords[_currentSessionKey];
|
|
if (existing != null) {
|
|
return existing;
|
|
}
|
|
final target =
|
|
_sanitizeTarget(_settings.assistantExecutionTarget) ??
|
|
AssistantExecutionTarget.aiGatewayOnly;
|
|
final record = _newRecord(target: target);
|
|
_threadRecords[record.sessionKey] = record;
|
|
_currentSessionKey = record.sessionKey;
|
|
return record;
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
try {
|
|
await _store.initialize();
|
|
_themeMode = await _store.loadThemeMode();
|
|
_settings = _sanitizeSettings(await _store.loadSettingsSnapshot());
|
|
_aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey();
|
|
_relayTokenCache = await _store.loadRelayToken();
|
|
_relayPasswordCache = await _store.loadRelayPassword();
|
|
final records = await _store.loadAssistantThreadRecords();
|
|
for (final record in records) {
|
|
final sanitized = _sanitizeRecord(record);
|
|
_threadRecords[sanitized.sessionKey] = sanitized;
|
|
}
|
|
if (_threadRecords.isEmpty) {
|
|
final record = _newRecord(
|
|
target: _settings.assistantExecutionTarget,
|
|
title: appText('新对话', 'New conversation'),
|
|
);
|
|
_threadRecords[record.sessionKey] = record;
|
|
}
|
|
_currentSessionKey = conversations.first.sessionKey;
|
|
} catch (error) {
|
|
_bootstrapError = '$error';
|
|
} finally {
|
|
_initializing = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void navigateTo(WorkspaceDestination destination) {
|
|
if (!capabilities.supportsDestination(destination)) {
|
|
return;
|
|
}
|
|
_destination = destination;
|
|
notifyListeners();
|
|
}
|
|
|
|
void navigateHome() {
|
|
navigateTo(WorkspaceDestination.assistant);
|
|
}
|
|
|
|
void openSettings({SettingsTab tab = SettingsTab.general}) {
|
|
_destination = WorkspaceDestination.settings;
|
|
_settingsTab = _sanitizeSettingsTab(tab);
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSettingsTab(SettingsTab tab) {
|
|
_settingsTab = _sanitizeSettingsTab(tab);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> setThemeMode(ThemeMode mode) async {
|
|
if (_themeMode == mode) {
|
|
return;
|
|
}
|
|
_themeMode = mode;
|
|
await _store.saveThemeMode(mode);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleAppLanguage() async {
|
|
final next = _settings.appLanguage == AppLanguage.zh
|
|
? AppLanguage.en
|
|
: AppLanguage.zh;
|
|
_settings = _settings.copyWith(appLanguage: next);
|
|
await _persistSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> createConversation({AssistantExecutionTarget? target}) async {
|
|
final resolvedTarget =
|
|
_sanitizeTarget(target) ?? _settings.assistantExecutionTarget;
|
|
final record = _newRecord(target: resolvedTarget);
|
|
_threadRecords[record.sessionKey] = record;
|
|
_currentSessionKey = record.sessionKey;
|
|
_lastAssistantError = null;
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> switchConversation(String sessionKey) async {
|
|
if (!_threadRecords.containsKey(sessionKey)) {
|
|
return;
|
|
}
|
|
_currentSessionKey = sessionKey;
|
|
_lastAssistantError = null;
|
|
notifyListeners();
|
|
final record = _threadRecords[sessionKey]!;
|
|
if (_sanitizeTarget(record.executionTarget) ==
|
|
AssistantExecutionTarget.remote &&
|
|
connection.status == RuntimeConnectionStatus.connected) {
|
|
await refreshRelayHistory(sessionKey: sessionKey);
|
|
}
|
|
}
|
|
|
|
Future<void> setAssistantExecutionTarget(
|
|
AssistantExecutionTarget target,
|
|
) async {
|
|
final resolvedTarget =
|
|
_sanitizeTarget(target) ?? AssistantExecutionTarget.aiGatewayOnly;
|
|
_settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget);
|
|
_replaceCurrentRecord(
|
|
_currentRecord.copyWith(executionTarget: resolvedTarget),
|
|
);
|
|
await _persistSettings();
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> saveAiGatewayConfiguration({
|
|
required String name,
|
|
required String baseUrl,
|
|
required String provider,
|
|
required String apiKey,
|
|
required String defaultModel,
|
|
}) async {
|
|
final normalizedBaseUrl = _aiGatewayClient.normalizeBaseUrl(baseUrl);
|
|
_settings = _settings.copyWith(
|
|
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
|
|
defaultModel: defaultModel.trim(),
|
|
aiGateway: _settings.aiGateway.copyWith(
|
|
name: name.trim().isEmpty ? 'Direct AI' : name.trim(),
|
|
baseUrl: normalizedBaseUrl?.toString() ?? baseUrl.trim(),
|
|
),
|
|
);
|
|
_aiGatewayApiKeyCache = apiKey.trim();
|
|
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
|
|
await _persistSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<AiGatewayConnectionCheck> testAiGatewayConnection({
|
|
required String baseUrl,
|
|
required String apiKey,
|
|
}) async {
|
|
_aiGatewayBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
return await _aiGatewayClient.testConnection(
|
|
baseUrl: baseUrl,
|
|
apiKey: apiKey,
|
|
);
|
|
} finally {
|
|
_aiGatewayBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> syncAiGatewayModels({
|
|
required String name,
|
|
required String baseUrl,
|
|
required String provider,
|
|
required String apiKey,
|
|
}) async {
|
|
_aiGatewayBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
final models = await _aiGatewayClient.loadModels(
|
|
baseUrl: baseUrl,
|
|
apiKey: apiKey,
|
|
);
|
|
final availableModels = models
|
|
.map((item) => item.id)
|
|
.toList(growable: false);
|
|
final selectedModels = availableModels.take(5).toList(growable: false);
|
|
final resolvedDefaultModel =
|
|
_settings.defaultModel.trim().isNotEmpty &&
|
|
availableModels.contains(_settings.defaultModel.trim())
|
|
? _settings.defaultModel.trim()
|
|
: selectedModels.isNotEmpty
|
|
? selectedModels.first
|
|
: '';
|
|
_settings = _settings.copyWith(
|
|
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
|
|
defaultModel: resolvedDefaultModel,
|
|
aiGateway: _settings.aiGateway.copyWith(
|
|
name: name.trim().isEmpty ? 'Direct AI' : name.trim(),
|
|
baseUrl:
|
|
_aiGatewayClient.normalizeBaseUrl(baseUrl)?.toString() ??
|
|
baseUrl.trim(),
|
|
availableModels: availableModels,
|
|
selectedModels: selectedModels,
|
|
syncState: 'ready',
|
|
syncMessage: 'Loaded ${availableModels.length} model(s)',
|
|
),
|
|
);
|
|
_aiGatewayApiKeyCache = apiKey.trim();
|
|
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
|
|
await _persistSettings();
|
|
} catch (error) {
|
|
_settings = _settings.copyWith(
|
|
aiGateway: _settings.aiGateway.copyWith(
|
|
syncState: 'error',
|
|
syncMessage: _aiGatewayClient.networkErrorLabel(error),
|
|
),
|
|
);
|
|
await _persistSettings();
|
|
rethrow;
|
|
} finally {
|
|
_aiGatewayBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> saveRelayConfiguration({
|
|
required String host,
|
|
required int port,
|
|
required bool tls,
|
|
required String token,
|
|
required String password,
|
|
}) async {
|
|
_settings = _settings.copyWith(
|
|
gateway: _settings.gateway.copyWith(
|
|
mode: RuntimeConnectionMode.remote,
|
|
useSetupCode: false,
|
|
host: host.trim(),
|
|
port: port,
|
|
tls: tls,
|
|
),
|
|
);
|
|
_relayTokenCache = token.trim();
|
|
_relayPasswordCache = password.trim();
|
|
await _store.saveRelayToken(_relayTokenCache);
|
|
await _store.saveRelayPassword(_relayPasswordCache);
|
|
await _persistSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> connectRelay() async {
|
|
_relayBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await _relayClient.connect(
|
|
profile: _settings.gateway.copyWith(
|
|
mode: RuntimeConnectionMode.remote,
|
|
useSetupCode: false,
|
|
),
|
|
authToken: _relayTokenCache,
|
|
authPassword: _relayPasswordCache,
|
|
);
|
|
await refreshRelaySessions();
|
|
await refreshRelayModels();
|
|
if (_sanitizeTarget(_currentRecord.executionTarget) ==
|
|
AssistantExecutionTarget.remote) {
|
|
await refreshRelayHistory(sessionKey: _currentSessionKey);
|
|
}
|
|
} finally {
|
|
_relayBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> disconnectRelay() async {
|
|
_relayBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await _relayClient.disconnect();
|
|
} finally {
|
|
_relayBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> refreshRelaySessions() async {
|
|
if (connection.status != RuntimeConnectionStatus.connected) {
|
|
return;
|
|
}
|
|
final sessions = await _relayClient.listSessions(limit: 50);
|
|
for (final session in sessions) {
|
|
final existing = _threadRecords[session.key];
|
|
final next = AssistantThreadRecord(
|
|
sessionKey: session.key,
|
|
messages: existing?.messages ?? const <GatewayChatMessage>[],
|
|
updatedAtMs:
|
|
session.updatedAtMs ??
|
|
existing?.updatedAtMs ??
|
|
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
title: (session.derivedTitle ?? session.displayName ?? session.key)
|
|
.trim(),
|
|
archived: false,
|
|
executionTarget: AssistantExecutionTarget.remote,
|
|
messageViewMode:
|
|
existing?.messageViewMode ?? AssistantMessageViewMode.rendered,
|
|
);
|
|
_threadRecords[session.key] = next;
|
|
}
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> refreshRelayModels() async {
|
|
if (connection.status != RuntimeConnectionStatus.connected) {
|
|
return;
|
|
}
|
|
final models = await _relayClient.listModels();
|
|
final availableModels = models
|
|
.map((item) => item.id.trim())
|
|
.where((item) => item.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (availableModels.isEmpty) {
|
|
return;
|
|
}
|
|
final defaultModel = _settings.defaultModel.trim().isNotEmpty
|
|
? _settings.defaultModel.trim()
|
|
: availableModels.first;
|
|
_settings = _settings.copyWith(
|
|
defaultModel: defaultModel,
|
|
aiGateway: _settings.aiGateway.copyWith(
|
|
availableModels: _settings.aiGateway.availableModels.isEmpty
|
|
? availableModels
|
|
: _settings.aiGateway.availableModels,
|
|
),
|
|
);
|
|
await _persistSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> refreshRelayHistory({String? sessionKey}) async {
|
|
final resolvedKey = (sessionKey ?? _currentSessionKey).trim();
|
|
if (resolvedKey.isEmpty ||
|
|
connection.status != RuntimeConnectionStatus.connected) {
|
|
return;
|
|
}
|
|
final messages = await _relayClient.loadHistory(resolvedKey, limit: 120);
|
|
final existing = _threadRecords[resolvedKey];
|
|
final next =
|
|
(existing ?? _newRecord(target: AssistantExecutionTarget.remote))
|
|
.copyWith(
|
|
sessionKey: resolvedKey,
|
|
messages: messages,
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
title: _deriveThreadTitle(
|
|
existing?.title ?? '',
|
|
messages,
|
|
fallback: resolvedKey,
|
|
),
|
|
executionTarget: AssistantExecutionTarget.remote,
|
|
);
|
|
_threadRecords[resolvedKey] = next;
|
|
_streamingTextBySession.remove(resolvedKey);
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> sendMessage(String rawMessage) async {
|
|
final trimmed = rawMessage.trim();
|
|
if (trimmed.isEmpty) {
|
|
return;
|
|
}
|
|
_lastAssistantError = null;
|
|
final target = assistantExecutionTarget;
|
|
final current = _currentRecord;
|
|
final updatedMessages = <GatewayChatMessage>[
|
|
...current.messages,
|
|
GatewayChatMessage(
|
|
id: _messageId(),
|
|
role: 'user',
|
|
text: trimmed,
|
|
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
toolCallId: null,
|
|
toolName: null,
|
|
stopReason: null,
|
|
pending: false,
|
|
error: false,
|
|
),
|
|
];
|
|
_replaceCurrentRecord(
|
|
current.copyWith(
|
|
messages: updatedMessages,
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
title: _deriveThreadTitle(current.title, updatedMessages),
|
|
executionTarget: target,
|
|
),
|
|
);
|
|
_pendingSessionKeys.add(_currentSessionKey);
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
|
|
try {
|
|
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
|
if (!canUseAiGatewayConversation) {
|
|
throw Exception(
|
|
appText(
|
|
'请先在 Settings 配置 Direct AI 的地址、API Key 和默认模型。',
|
|
'Configure Direct AI endpoint, API key, and default model first.',
|
|
),
|
|
);
|
|
}
|
|
final reply = await _aiGatewayClient.completeChat(
|
|
baseUrl: _settings.aiGateway.baseUrl,
|
|
apiKey: _aiGatewayApiKeyCache,
|
|
model: resolvedAiGatewayModel,
|
|
history: updatedMessages,
|
|
);
|
|
_appendAssistantMessage(
|
|
sessionKey: _currentSessionKey,
|
|
text: reply,
|
|
error: false,
|
|
);
|
|
} else {
|
|
if (connection.status != RuntimeConnectionStatus.connected) {
|
|
throw Exception(
|
|
appText(
|
|
'Relay OpenClaw Gateway 尚未连接。',
|
|
'Relay OpenClaw Gateway is not connected.',
|
|
),
|
|
);
|
|
}
|
|
await _relayClient.sendChat(
|
|
sessionKey: _currentSessionKey,
|
|
message: trimmed,
|
|
thinking: 'medium',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
_appendAssistantMessage(
|
|
sessionKey: _currentSessionKey,
|
|
text: error.toString(),
|
|
error: true,
|
|
);
|
|
_lastAssistantError = error.toString();
|
|
_pendingSessionKeys.remove(_currentSessionKey);
|
|
_streamingTextBySession.remove(_currentSessionKey);
|
|
await _persistThreads();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> selectDirectModel(String model) async {
|
|
final trimmed = model.trim();
|
|
if (trimmed.isEmpty) {
|
|
return;
|
|
}
|
|
_settings = _settings.copyWith(defaultModel: trimmed);
|
|
await _persistSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
unawaited(_relayEventsSubscription.cancel());
|
|
unawaited(_relayClient.dispose());
|
|
super.dispose();
|
|
}
|
|
|
|
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
|
|
return switch (tab) {
|
|
SettingsTab.workspace ||
|
|
SettingsTab.agents ||
|
|
SettingsTab.diagnostics ||
|
|
SettingsTab.experimental => SettingsTab.gateway,
|
|
_ => tab,
|
|
};
|
|
}
|
|
|
|
SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) {
|
|
final target =
|
|
_sanitizeTarget(snapshot.assistantExecutionTarget) ??
|
|
AssistantExecutionTarget.aiGatewayOnly;
|
|
return snapshot.copyWith(
|
|
assistantExecutionTarget: target,
|
|
gateway: snapshot.gateway.copyWith(
|
|
mode: target == AssistantExecutionTarget.remote
|
|
? RuntimeConnectionMode.remote
|
|
: RuntimeConnectionMode.unconfigured,
|
|
useSetupCode: false,
|
|
),
|
|
assistantNavigationDestinations: const <WorkspaceDestination>[],
|
|
);
|
|
}
|
|
|
|
AssistantThreadRecord _sanitizeRecord(AssistantThreadRecord record) {
|
|
final target =
|
|
_sanitizeTarget(record.executionTarget) ??
|
|
AssistantExecutionTarget.aiGatewayOnly;
|
|
return record.copyWith(
|
|
executionTarget: target,
|
|
title: record.title.trim().isEmpty
|
|
? appText('新对话', 'New conversation')
|
|
: record.title.trim(),
|
|
);
|
|
}
|
|
|
|
AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) {
|
|
return switch (target) {
|
|
AssistantExecutionTarget.remote => AssistantExecutionTarget.remote,
|
|
AssistantExecutionTarget.aiGatewayOnly =>
|
|
AssistantExecutionTarget.aiGatewayOnly,
|
|
_ => AssistantExecutionTarget.aiGatewayOnly,
|
|
};
|
|
}
|
|
|
|
AssistantThreadRecord _newRecord({
|
|
required AssistantExecutionTarget target,
|
|
String? title,
|
|
}) {
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
final prefix = target == AssistantExecutionTarget.remote
|
|
? 'relay'
|
|
: 'direct';
|
|
return AssistantThreadRecord(
|
|
sessionKey: '$prefix:$timestamp',
|
|
messages: const <GatewayChatMessage>[],
|
|
updatedAtMs: timestamp.toDouble(),
|
|
title: title ?? appText('新对话', 'New conversation'),
|
|
archived: false,
|
|
executionTarget: target,
|
|
messageViewMode: AssistantMessageViewMode.rendered,
|
|
);
|
|
}
|
|
|
|
void _replaceCurrentRecord(AssistantThreadRecord record) {
|
|
_threadRecords[record.sessionKey] = record;
|
|
_currentSessionKey = record.sessionKey;
|
|
}
|
|
|
|
void _appendAssistantMessage({
|
|
required String sessionKey,
|
|
required String text,
|
|
required bool error,
|
|
}) {
|
|
final existing =
|
|
_threadRecords[sessionKey] ??
|
|
_newRecord(target: assistantExecutionTarget);
|
|
final messages = <GatewayChatMessage>[
|
|
...existing.messages,
|
|
GatewayChatMessage(
|
|
id: _messageId(),
|
|
role: 'assistant',
|
|
text: text,
|
|
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
toolCallId: null,
|
|
toolName: null,
|
|
stopReason: error ? 'error' : null,
|
|
pending: false,
|
|
error: error,
|
|
),
|
|
];
|
|
_threadRecords[sessionKey] = existing.copyWith(
|
|
messages: messages,
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
title: _deriveThreadTitle(existing.title, messages, fallback: sessionKey),
|
|
);
|
|
_pendingSessionKeys.remove(sessionKey);
|
|
_streamingTextBySession.remove(sessionKey);
|
|
}
|
|
|
|
void _handleRelayEvent(GatewayPushEvent event) {
|
|
if (event.event != 'chat') {
|
|
return;
|
|
}
|
|
final payload = _castMap(event.payload);
|
|
final sessionKey = (payload['sessionKey']?.toString().trim() ?? '').trim();
|
|
if (sessionKey.isEmpty) {
|
|
return;
|
|
}
|
|
final state = payload['state']?.toString().trim() ?? '';
|
|
final message = _castMap(payload['message']);
|
|
final text = _extractMessageText(message);
|
|
if (text.isNotEmpty && (state == 'delta' || state == 'final')) {
|
|
_streamingTextBySession[sessionKey] = text;
|
|
}
|
|
if (state == 'final' || state == 'aborted' || state == 'error') {
|
|
_pendingSessionKeys.remove(sessionKey);
|
|
unawaited(refreshRelaySessions());
|
|
unawaited(refreshRelayHistory(sessionKey: sessionKey));
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _persistSettings() async {
|
|
await _store.saveSettingsSnapshot(_settings);
|
|
}
|
|
|
|
Future<void> _persistThreads() async {
|
|
await _store.saveAssistantThreadRecords(
|
|
_threadRecords.values.toList(growable: false),
|
|
);
|
|
}
|
|
|
|
String _titleForRecord(AssistantThreadRecord record) {
|
|
final title = record.title.trim();
|
|
if (title.isNotEmpty) {
|
|
return title;
|
|
}
|
|
return _deriveThreadTitle('', record.messages, fallback: record.sessionKey);
|
|
}
|
|
|
|
String _previewForRecord(AssistantThreadRecord record) {
|
|
for (final message in record.messages.reversed) {
|
|
final text = message.text.trim();
|
|
if (text.isNotEmpty) {
|
|
return text;
|
|
}
|
|
}
|
|
return appText(
|
|
'等待描述这个任务的第一条消息',
|
|
'Waiting for the first message of this task',
|
|
);
|
|
}
|
|
|
|
String _deriveThreadTitle(
|
|
String currentTitle,
|
|
List<GatewayChatMessage> messages, {
|
|
String fallback = '',
|
|
}) {
|
|
final trimmedCurrent = currentTitle.trim();
|
|
if (trimmedCurrent.isNotEmpty &&
|
|
trimmedCurrent != appText('新对话', 'New conversation')) {
|
|
return trimmedCurrent;
|
|
}
|
|
for (final message in messages) {
|
|
if (message.role.trim().toLowerCase() != 'user') {
|
|
continue;
|
|
}
|
|
final text = message.text.trim();
|
|
if (text.isEmpty) {
|
|
continue;
|
|
}
|
|
return text.length <= 32 ? text : '${text.substring(0, 32)}...';
|
|
}
|
|
return fallback.isEmpty ? appText('新对话', 'New conversation') : fallback;
|
|
}
|
|
|
|
String _hostLabel(String rawUrl) {
|
|
final normalized = _aiGatewayClient.normalizeBaseUrl(rawUrl);
|
|
return normalized?.host.trim() ?? '';
|
|
}
|
|
|
|
String _messageId() {
|
|
return DateTime.now().microsecondsSinceEpoch.toString();
|
|
}
|
|
|
|
Map<String, dynamic> _castMap(Object? value) {
|
|
if (value is Map<String, dynamic>) {
|
|
return value;
|
|
}
|
|
if (value is Map) {
|
|
return value.cast<String, dynamic>();
|
|
}
|
|
return const <String, dynamic>{};
|
|
}
|
|
|
|
String _extractMessageText(Map<String, dynamic> message) {
|
|
final directContent = message['content'];
|
|
if (directContent is String) {
|
|
return directContent;
|
|
}
|
|
final parts = <String>[];
|
|
if (directContent is List) {
|
|
for (final part in directContent) {
|
|
final map = _castMap(part);
|
|
final text = map['text']?.toString().trim();
|
|
if (text != null && text.isNotEmpty) {
|
|
parts.add(text);
|
|
}
|
|
}
|
|
}
|
|
return parts.join('\n').trim();
|
|
}
|
|
}
|
|
|
|
class WebConversationSummary {
|
|
const WebConversationSummary({
|
|
required this.sessionKey,
|
|
required this.title,
|
|
required this.preview,
|
|
required this.updatedAtMs,
|
|
required this.executionTarget,
|
|
required this.pending,
|
|
required this.current,
|
|
});
|
|
|
|
final String sessionKey;
|
|
final String title;
|
|
final String preview;
|
|
final double updatedAtMs;
|
|
final AssistantExecutionTarget executionTarget;
|
|
final bool pending;
|
|
final bool current;
|
|
}
|