From 27d87eea85ff55aa2c2dedd6e104abe2075acd3e Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 18:08:47 +0800 Subject: [PATCH 01/10] feat: add unified project agent workspace --- .gitignore | 1 + lib/agents/agent_chat_adapter.dart | 61 +++ lib/agents/claude_code_chat_adapter.dart | 44 ++ lib/agents/codex_chat_adapter.dart | 56 +++ lib/agents/opencode_chat_adapter.dart | 44 ++ lib/api/codex_client.dart | 37 +- lib/api/gateway_client.dart | 313 ++++++++++++++ lib/api/git_client.dart | 2 + lib/api/opencode_client.dart | 2 +- lib/models/agent.dart | 86 ++++ lib/models/gateway_event.dart | 91 ++++ lib/models/gateway_session.dart | 76 ++++ lib/models/message.dart | 7 +- lib/models/project.dart | 42 ++ lib/models/session.dart | 4 +- lib/state/agent_catalog_store.dart | 115 +++++ lib/state/chat_store.dart | 11 +- lib/state/codex_chat_store.dart | 22 +- lib/state/codex_thread_store.dart | 11 +- lib/state/gateway_chat_store.dart | 338 +++++++++++++++ lib/state/gateway_client_provider.dart | 16 + lib/state/gateway_providers.dart | 10 + lib/state/gateway_session_store.dart | 115 +++++ lib/state/project_store.dart | 104 +++++ lib/state/session_store.dart | 4 +- lib/state/settings_store.dart | 3 +- lib/ui/pages/agent_group_page.dart | 289 +++++++++++++ lib/ui/pages/chat_page.dart | 6 +- lib/ui/pages/chat_tab.dart | 8 +- lib/ui/pages/codex_chat_page.dart | 13 +- lib/ui/pages/codex_thread_list_page.dart | 3 +- lib/ui/pages/files_page.dart | 294 ++++++++++--- lib/ui/pages/gateway_chat_page.dart | 422 ++++++++++++++++++ lib/ui/pages/gateway_ui_adapters.dart | 497 ++++++++++++++++++++++ lib/ui/pages/git_page.dart | 51 ++- lib/ui/pages/home_page.dart | 6 +- lib/ui/pages/project_detail_page.dart | 214 ++++++++++ lib/ui/pages/project_list_page.dart | 188 ++++++++ lib/ui/pages/session_list_page.dart | 31 +- lib/ui/widgets/agent_badge.dart | 92 ++++ lib/ui/widgets/attachment_picker.dart | 33 +- lib/ui/widgets/connection_chip.dart | 15 +- lib/ui/widgets/directory_picker.dart | 45 +- lib/ui/widgets/message_bubble.dart | 3 +- lib/ui/widgets/model_picker.dart | 3 +- lib/ui/widgets/parts/image_part_view.dart | 5 +- lib/ui/widgets/parts/text_part_view.dart | 14 +- lib/ui/widgets/parts/tool_part_view.dart | 3 +- lib/ui/widgets/session_status_chip.dart | 92 ++++ pubspec.lock | 320 ++++++-------- pubspec.yaml | 2 +- test/api/gateway_client_test.dart | 64 +++ test/models/gateway_models_test.dart | 88 ++++ test/state/gateway_chat_store_test.dart | 72 ++++ 54 files changed, 4098 insertions(+), 390 deletions(-) create mode 100644 lib/agents/agent_chat_adapter.dart create mode 100644 lib/agents/claude_code_chat_adapter.dart create mode 100644 lib/agents/codex_chat_adapter.dart create mode 100644 lib/agents/opencode_chat_adapter.dart create mode 100644 lib/api/gateway_client.dart create mode 100644 lib/models/agent.dart create mode 100644 lib/models/gateway_event.dart create mode 100644 lib/models/gateway_session.dart create mode 100644 lib/models/project.dart create mode 100644 lib/state/agent_catalog_store.dart create mode 100644 lib/state/gateway_chat_store.dart create mode 100644 lib/state/gateway_client_provider.dart create mode 100644 lib/state/gateway_providers.dart create mode 100644 lib/state/gateway_session_store.dart create mode 100644 lib/state/project_store.dart create mode 100644 lib/ui/pages/agent_group_page.dart create mode 100644 lib/ui/pages/gateway_chat_page.dart create mode 100644 lib/ui/pages/gateway_ui_adapters.dart create mode 100644 lib/ui/pages/project_detail_page.dart create mode 100644 lib/ui/pages/project_list_page.dart create mode 100644 lib/ui/widgets/agent_badge.dart create mode 100644 lib/ui/widgets/session_status_chip.dart create mode 100644 test/api/gateway_client_test.dart create mode 100644 test/models/gateway_models_test.dart create mode 100644 test/state/gateway_chat_store_test.dart diff --git a/.gitignore b/.gitignore index 6ddabae..d385db2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ diff --git a/lib/agents/agent_chat_adapter.dart b/lib/agents/agent_chat_adapter.dart new file mode 100644 index 0000000..c98eb76 --- /dev/null +++ b/lib/agents/agent_chat_adapter.dart @@ -0,0 +1,61 @@ +library; + +import '../api/gateway_client.dart'; +import '../models/agent.dart'; +import '../state/gateway_chat_store.dart'; + +typedef AgentMetadata = Agent; + +class AgentCommandSuggestion { + const AgentCommandSuggestion({ + required this.command, + this.description, + }); + + final String command; + final String? description; +} + +abstract class AgentChatAdapter { + AgentChatAdapter({ + required this.client, + required this.metadata, + required this.chatStore, + }); + + final GatewayClient client; + final AgentMetadata metadata; + final GatewayChatStore chatStore; + + List get commandSuggestions; + bool get supportsAttachments; + + Future sendMessage( + String text, { + List> attachments = const [], + }); + Future sendSlashCommand(String command); + Future abort(); + + bool get supportsSlashCommands => metadata.supportsSlashCommands; + + List buildSuggestions( + Iterable fallbackCommands, + ) { + final fromMetadata = metadata.commands + .map( + (command) => AgentCommandSuggestion( + command: command.name, + description: + command.description.isEmpty ? null : command.description, + ), + ) + .toList(growable: false); + if (fromMetadata.isNotEmpty) { + return fromMetadata; + } + return fallbackCommands + .map((command) => AgentCommandSuggestion(command: command)) + .toList(growable: false); + } +} diff --git a/lib/agents/claude_code_chat_adapter.dart b/lib/agents/claude_code_chat_adapter.dart new file mode 100644 index 0000000..795cc7a --- /dev/null +++ b/lib/agents/claude_code_chat_adapter.dart @@ -0,0 +1,44 @@ +library; + +import 'agent_chat_adapter.dart'; + +class ClaudeCodeChatAdapter extends AgentChatAdapter { + ClaudeCodeChatAdapter({ + required super.client, + required super.metadata, + required super.chatStore, + }); + + static const _fallbackCommands = [ + '/help', + '/clear', + '/compact', + '/model', + '/permissions', + '/mcp', + '/cost', + ]; + + @override + List get commandSuggestions => + buildSuggestions(_fallbackCommands); + + @override + bool get supportsAttachments => metadata.supportsAttachments; + + @override + Future sendMessage( + String text, { + List> attachments = const [], + }) { + return chatStore.sendMessage(text, attachments: attachments); + } + + @override + Future sendSlashCommand(String command) { + return chatStore.sendSlashCommand(command); + } + + @override + Future abort() => chatStore.abort(); +} diff --git a/lib/agents/codex_chat_adapter.dart b/lib/agents/codex_chat_adapter.dart new file mode 100644 index 0000000..8eabdea --- /dev/null +++ b/lib/agents/codex_chat_adapter.dart @@ -0,0 +1,56 @@ +library; + +import 'agent_chat_adapter.dart'; + +class CodexChatAdapter extends AgentChatAdapter { + CodexChatAdapter({ + required super.client, + required super.metadata, + required super.chatStore, + }); + + static const _fallbackCommands = [ + '/help', + '/clear', + '/compact', + '/model', + '/approvals', + '/status', + ]; + + @override + List get commandSuggestions { + final suggestions = buildSuggestions(_fallbackCommands); + final fastSupported = + metadata.commands.any((command) => command.name == '/fast') || + metadata.raw['supportsFast'] == true || + metadata.raw['fast'] == true; + if (fastSupported && + !suggestions.any((suggestion) => suggestion.command == '/fast')) { + return [ + ...suggestions, + const AgentCommandSuggestion(command: '/fast'), + ]; + } + return suggestions; + } + + @override + bool get supportsAttachments => metadata.supportsAttachments; + + @override + Future sendMessage( + String text, { + List> attachments = const [], + }) { + return chatStore.sendMessage(text, attachments: attachments); + } + + @override + Future sendSlashCommand(String command) { + return chatStore.sendSlashCommand(command); + } + + @override + Future abort() => chatStore.abort(); +} diff --git a/lib/agents/opencode_chat_adapter.dart b/lib/agents/opencode_chat_adapter.dart new file mode 100644 index 0000000..f2aad68 --- /dev/null +++ b/lib/agents/opencode_chat_adapter.dart @@ -0,0 +1,44 @@ +library; + +import 'agent_chat_adapter.dart'; + +class OpenCodeChatAdapter extends AgentChatAdapter { + OpenCodeChatAdapter({ + required super.client, + required super.metadata, + required super.chatStore, + }); + + static const _fallbackCommands = [ + '/help', + '/clear', + '/compact', + '/undo', + '/redo', + '/share', + '/unshare', + ]; + + @override + List get commandSuggestions => + buildSuggestions(_fallbackCommands); + + @override + bool get supportsAttachments => metadata.supportsAttachments; + + @override + Future sendMessage( + String text, { + List> attachments = const [], + }) { + return chatStore.sendMessage(text, attachments: attachments); + } + + @override + Future sendSlashCommand(String command) { + return chatStore.sendSlashCommand(command); + } + + @override + Future abort() => chatStore.abort(); +} diff --git a/lib/api/codex_client.dart b/lib/api/codex_client.dart index 0b34c7b..a309baa 100644 --- a/lib/api/codex_client.dart +++ b/lib/api/codex_client.dart @@ -182,17 +182,19 @@ class CodexClient { req = await http.postUrl(url); req.headers.set('Accept', 'text/event-stream'); req.headers.set('Content-Type', 'application/json; charset=utf-8'); - if (_bearer != null && _bearer!.isNotEmpty) { + if (_bearer != null && _bearer.isNotEmpty) { req.headers.set('Authorization', 'Bearer $_bearer'); } req.add(utf8.encode(jsonEncode(body))); res = await req.close(); if (res.statusCode != 200) { await res.drain(); - controller.add(CodexEvent( - type: 'error', - data: {'message': 'HTTP ${res.statusCode}'}, - )); + controller.add( + CodexEvent( + type: 'error', + data: {'message': 'HTTP ${res.statusCode}'}, + ), + ); await controller.close(); return; } @@ -208,9 +210,8 @@ class CodexClient { remainder = lines.removeLast(); for (final raw in lines) { - final line = raw.endsWith('\r') - ? raw.substring(0, raw.length - 1) - : raw; + final line = + raw.endsWith('\r') ? raw.substring(0, raw.length - 1) : raw; if (line.isEmpty) { if (dataBuf.isNotEmpty) { @@ -242,10 +243,12 @@ class CodexClient { }, onError: (Object e) { if (!controller.isClosed) { - controller.add(CodexEvent( - type: 'error', - data: {'message': e.toString()}, - )); + controller.add( + CodexEvent( + type: 'error', + data: {'message': e.toString()}, + ), + ); } controller.close(); }, @@ -268,10 +271,12 @@ class CodexClient { }; } catch (e) { if (!controller.isClosed) { - controller.add(CodexEvent( - type: 'error', - data: {'message': e.toString()}, - )); + controller.add( + CodexEvent( + type: 'error', + data: {'message': e.toString()}, + ), + ); await controller.close(); } try { diff --git a/lib/api/gateway_client.dart b/lib/api/gateway_client.dart new file mode 100644 index 0000000..58dc7ed --- /dev/null +++ b/lib/api/gateway_client.dart @@ -0,0 +1,313 @@ +/// Unified gateway protocol client. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; + +import '../models/agent.dart'; +import '../models/gateway_event.dart'; +import '../models/gateway_session.dart'; +import '../models/project.dart'; + +class GatewayClient { + GatewayClient({ + required Uri baseUrl, + String? bearerToken, + Dio? dio, + http.Client? httpClient, + }) : _base = baseUrl.toString().replaceAll(RegExp(r'/$'), ''), + _bearerToken = bearerToken, + _ownsDio = dio == null, + _ownsHttpClient = httpClient == null, + _dio = dio ?? + Dio( + BaseOptions( + baseUrl: baseUrl.toString().replaceAll(RegExp(r'/$'), ''), + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 300), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Accept': 'application/json', + if (bearerToken != null && bearerToken.isNotEmpty) + 'Authorization': 'Bearer $bearerToken', + }, + ), + ), + _httpClient = httpClient ?? http.Client(); + + final String _base; + final String? _bearerToken; + final Dio _dio; + final http.Client _httpClient; + final bool _ownsDio; + final bool _ownsHttpClient; + + Future health() async { + try { + final res = await _dio.get('/health'); + final status = res.statusCode ?? 0; + return status >= 200 && status < 300; + } on DioException { + return false; + } + } + + Future> listProjects() async { + final res = await _dio.get>('/projects'); + return _readList(res.data).map(Project.fromJson).toList(growable: false); + } + + Future createProject({ + required String directory, + String? name, + }) async { + final res = await _dio.post>( + '/projects', + data: { + 'directory': directory, + if (name != null && name.isNotEmpty) 'name': name, + }, + ); + return Project.fromJson(res.data ?? const {}); + } + + Future getProject(String projectId) async { + final res = await _dio.get>( + '/projects/${_path(projectId)}', + ); + return Project.fromJson(res.data ?? const {}); + } + + Future deleteProject(String projectId) async { + await _dio.delete('/projects/${_path(projectId)}'); + } + + Future> listDirectories() async { + final res = await _dio.get('/directories'); + final data = res.data; + if (data is List) { + return data.whereType().toList(growable: false); + } + if (data is Map) { + final dirs = data['directories'] as List? ?? const []; + return dirs.whereType().toList(growable: false); + } + return const []; + } + + Future> listAgents() async { + final res = await _dio.get>('/agents'); + return _readList(res.data).map(Agent.fromJson).toList(growable: false); + } + + Future getAgent(String agentId) async { + final res = await _dio.get>( + '/agents/${_path(agentId)}', + ); + return Agent.fromJson(res.data ?? const {}); + } + + Future> listAgentModels(String agentId) async { + final res = await _dio.get('/agents/${_path(agentId)}/models'); + return _readEnvelopeList(res.data, 'models') + .map(AgentModel.fromJson) + .toList(growable: false); + } + + Future> listAgentCommands(String agentId) async { + final res = await _dio.get('/agents/${_path(agentId)}/commands'); + return _readEnvelopeList(res.data, 'commands') + .map(AgentCommand.fromJson) + .toList(growable: false); + } + + Future> listProjectSessions(String projectId) async { + final res = await _dio.get>( + '/projects/${_path(projectId)}/sessions', + ); + return _readList(res.data) + .map(GatewaySession.fromJson) + .toList(growable: false); + } + + Future createSession({ + required String projectId, + required String agentId, + String? modelId, + String? title, + }) async { + final res = await _dio.post>( + '/projects/${_path(projectId)}/sessions', + data: { + 'agentId': agentId, + if (modelId != null && modelId.isNotEmpty) 'modelId': modelId, + if (title != null && title.isNotEmpty) 'title': title, + }, + ); + return GatewaySession.fromJson(res.data ?? const {}); + } + + Future getSession(String sessionId) async { + final res = await _dio.get>( + '/sessions/${_path(sessionId)}', + ); + return GatewaySession.fromJson(res.data ?? const {}); + } + + Future updateSession( + String sessionId, { + String? title, + String? modelId, + }) async { + final res = await _dio.patch>( + '/sessions/${_path(sessionId)}', + data: { + if (title != null) 'title': title, + if (modelId != null) 'modelId': modelId, + }, + ); + return GatewaySession.fromJson(res.data ?? const {}); + } + + Future deleteSession(String sessionId) async { + await _dio.delete('/sessions/${_path(sessionId)}'); + } + + Future>> listMessages(String sessionId) async { + final res = await _dio.get>( + '/sessions/${_path(sessionId)}/messages', + ); + return _readList(res.data); + } + + Future> sendMessage({ + required String sessionId, + String? text, + List> parts = const >[], + Map extra = const {}, + }) async { + final res = await _dio.post>( + '/sessions/${_path(sessionId)}/messages', + data: { + if (text != null) 'text': text, + if (parts.isNotEmpty) 'parts': parts, + ...extra, + }, + ); + return res.data ?? const {}; + } + + Future> sendSlashCommand({ + required String sessionId, + required String command, + Map extra = const {}, + }) { + return sendMessage( + sessionId: sessionId, + text: command, + extra: { + 'slashCommand': command, + ...extra, + }, + ); + } + + Future abortSession(String sessionId) async { + await _dio.post('/sessions/${_path(sessionId)}/abort'); + } + + Stream events(String sessionId) { + final request = http.Request( + 'GET', + Uri.parse('$_base/sessions/${_path(sessionId)}/events'), + ); + request.headers['Accept'] = 'text/event-stream'; + request.headers['Cache-Control'] = 'no-cache'; + if (_bearerToken != null && _bearerToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer $_bearerToken'; + } + return _sendSse(request); + } + + Stream _sendSse(http.BaseRequest request) async* { + final response = await _httpClient.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.drain(); + throw StateError('SSE handshake failed: HTTP ${response.statusCode}'); + } + + String? eventType; + final dataBuffer = StringBuffer(); + await for (final line in response.stream + .transform(utf8.decoder) + .transform(const LineSplitter())) { + final normalized = + line.endsWith('\r') ? line.substring(0, line.length - 1) : line; + if (normalized.isEmpty) { + if (dataBuffer.isNotEmpty) { + yield GatewayEvent.fromSseData( + sseEvent: eventType ?? 'message', + data: dataBuffer.toString(), + ); + } + eventType = null; + dataBuffer.clear(); + continue; + } + if (normalized.startsWith(':')) continue; + + final colon = normalized.indexOf(':'); + final field = colon >= 0 ? normalized.substring(0, colon) : normalized; + final value = colon >= 0 + ? (normalized.length > colon + 1 && normalized[colon + 1] == ' ' + ? normalized.substring(colon + 2) + : normalized.substring(colon + 1)) + : ''; + + switch (field) { + case 'event': + eventType = value; + break; + case 'data': + if (dataBuffer.isNotEmpty) dataBuffer.writeln(); + dataBuffer.write(value); + break; + default: + break; + } + } + + if (dataBuffer.isNotEmpty) { + yield GatewayEvent.fromSseData( + sseEvent: eventType ?? 'message', + data: dataBuffer.toString(), + ); + } + } + + void close() { + if (_ownsDio) _dio.close(force: true); + if (_ownsHttpClient) _httpClient.close(); + } + + static String _path(String value) => Uri.encodeComponent(value); + + static List> _readList(Object? data) { + return (data as List? ?? const []) + .whereType>() + .toList(growable: false); + } + + static List> _readEnvelopeList( + Object? data, + String field, + ) { + if (data is Map) { + return _readList(data[field]); + } + return _readList(data); + } +} diff --git a/lib/api/git_client.dart b/lib/api/git_client.dart index cc4f381..05f872c 100644 --- a/lib/api/git_client.dart +++ b/lib/api/git_client.dart @@ -1,3 +1,5 @@ +// ignore_for_file: unintended_html_in_doc_comment + /// HTTP client for git operations via the QQBot server. /// /// Endpoints: diff --git a/lib/api/opencode_client.dart b/lib/api/opencode_client.dart index 0c6d34f..894938b 100644 --- a/lib/api/opencode_client.dart +++ b/lib/api/opencode_client.dart @@ -207,4 +207,4 @@ class OpencodeClient { } void close() => _dio.close(force: true); -} \ No newline at end of file +} diff --git a/lib/models/agent.dart b/lib/models/agent.dart new file mode 100644 index 0000000..5430ce6 --- /dev/null +++ b/lib/models/agent.dart @@ -0,0 +1,86 @@ +/// Gateway agent metadata models. +library; + +class Agent { + const Agent({ + required this.id, + required this.displayName, + required this.supportsModels, + required this.supportsSlashCommands, + required this.supportsAttachments, + required this.supportsPermissions, + required this.sessionKind, + required this.commands, + required this.raw, + }); + + final String id; + final String displayName; + final bool supportsModels; + final bool supportsSlashCommands; + final bool supportsAttachments; + final bool supportsPermissions; + final String sessionKind; + final List commands; + final Map raw; + + factory Agent.fromJson(Map json) { + final commands = json['commands'] as List? ?? const []; + return Agent( + id: json['id'] as String? ?? '', + displayName: + json['displayName'] as String? ?? json['name'] as String? ?? '', + supportsModels: json['supportsModels'] as bool? ?? false, + supportsSlashCommands: json['supportsSlashCommands'] as bool? ?? false, + supportsAttachments: json['supportsAttachments'] as bool? ?? false, + supportsPermissions: json['supportsPermissions'] as bool? ?? false, + sessionKind: json['sessionKind'] as String? ?? 'thread', + commands: commands + .whereType>() + .map(AgentCommand.fromJson) + .toList(growable: false), + raw: Map.from(json), + ); + } +} + +class AgentModel { + const AgentModel({ + required this.id, + required this.displayName, + required this.raw, + }); + + final String id; + final String displayName; + final Map raw; + + factory AgentModel.fromJson(Map json) { + return AgentModel( + id: json['id'] as String? ?? json['modelId'] as String? ?? '', + displayName: + json['displayName'] as String? ?? json['name'] as String? ?? '', + raw: Map.from(json), + ); + } +} + +class AgentCommand { + const AgentCommand({ + required this.name, + required this.description, + required this.raw, + }); + + final String name; + final String description; + final Map raw; + + factory AgentCommand.fromJson(Map json) { + return AgentCommand( + name: json['name'] as String? ?? json['id'] as String? ?? '', + description: json['description'] as String? ?? '', + raw: Map.from(json), + ); + } +} diff --git a/lib/models/gateway_event.dart b/lib/models/gateway_event.dart new file mode 100644 index 0000000..5f1e649 --- /dev/null +++ b/lib/models/gateway_event.dart @@ -0,0 +1,91 @@ +/// Gateway event envelope decoded from SSE. +library; + +import 'dart:convert'; + +class GatewayEvent { + const GatewayEvent({ + required this.type, + required this.sessionId, + required this.agentId, + required this.timestampMs, + required this.data, + required this.raw, + required this.sseEvent, + }); + + final String type; + final String sessionId; + final String agentId; + final int timestampMs; + final Map data; + final Map raw; + final String sseEvent; + + factory GatewayEvent.fromJson( + Map json, { + String sseEvent = 'message', + }) { + final data = _readMap(json['data']); + final raw = _readMap(json['raw'], fallback: json); + return GatewayEvent( + type: json['type'] as String? ?? sseEvent, + sessionId: json['sessionId'] as String? ?? '', + agentId: json['agentId'] as String? ?? '', + timestampMs: _readInt(json['timestamp']) ?? 0, + data: data, + raw: raw, + sseEvent: sseEvent, + ); + } + + factory GatewayEvent.fromSseData({ + required String sseEvent, + required String data, + }) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) { + return GatewayEvent.fromJson(decoded, sseEvent: sseEvent); + } + return GatewayEvent( + type: sseEvent, + sessionId: '', + agentId: '', + timestampMs: 0, + data: {'_value': decoded}, + raw: {'_raw': decoded}, + sseEvent: sseEvent, + ); + } catch (_) { + return GatewayEvent( + type: sseEvent, + sessionId: '', + agentId: '', + timestampMs: 0, + data: {'_raw': data}, + raw: {'_raw': data}, + sseEvent: sseEvent, + ); + } + } + + static int? _readInt(Object? value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + static Map _readMap( + Object? value, { + Map fallback = const {}, + }) { + if (value is Map) { + return Map.from(value); + } + if (value is Map) { + return value.cast(); + } + return Map.from(fallback); + } +} diff --git a/lib/models/gateway_session.dart b/lib/models/gateway_session.dart new file mode 100644 index 0000000..b869039 --- /dev/null +++ b/lib/models/gateway_session.dart @@ -0,0 +1,76 @@ +/// Gateway session model. +library; + +enum GatewaySessionStatus { + idle, + running, + waitingForApproval, + error, + completed, + unknown; + + static GatewaySessionStatus from(String? raw) => switch (raw) { + 'idle' => GatewaySessionStatus.idle, + 'running' => GatewaySessionStatus.running, + 'waiting-for-approval' => GatewaySessionStatus.waitingForApproval, + 'error' => GatewaySessionStatus.error, + 'completed' => GatewaySessionStatus.completed, + _ => GatewaySessionStatus.unknown, + }; + + String get wireName => switch (this) { + GatewaySessionStatus.idle => 'idle', + GatewaySessionStatus.running => 'running', + GatewaySessionStatus.waitingForApproval => 'waiting-for-approval', + GatewaySessionStatus.error => 'error', + GatewaySessionStatus.completed => 'completed', + GatewaySessionStatus.unknown => 'unknown', + }; +} + +class GatewaySession { + const GatewaySession({ + required this.id, + required this.projectId, + required this.directory, + required this.agentId, + required this.title, + required this.status, + required this.createdAtMs, + required this.updatedAtMs, + required this.raw, + this.modelId, + }); + + final String id; + final String projectId; + final String directory; + final String agentId; + final String? modelId; + final String title; + final GatewaySessionStatus status; + final int createdAtMs; + final int updatedAtMs; + final Map raw; + + factory GatewaySession.fromJson(Map json) { + return GatewaySession( + id: json['id'] as String? ?? '', + projectId: json['projectId'] as String? ?? '', + directory: json['directory'] as String? ?? '', + agentId: json['agentId'] as String? ?? '', + modelId: json['modelId'] as String?, + title: json['title'] as String? ?? '', + status: GatewaySessionStatus.from(json['status'] as String?), + createdAtMs: _readInt(json['createdAt']) ?? 0, + updatedAtMs: _readInt(json['updatedAt']) ?? 0, + raw: Map.from(json), + ); + } + + static int? _readInt(Object? value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/models/message.dart b/lib/models/message.dart index c0fd427..07de99a 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -31,6 +31,7 @@ class Message { final MessageRole role; final String sessionId; final MessageStatus status; + /// Parts in arrival order. Map for O(1) updates by id; we expose insertion /// order via [orderedParts]. final Map parts; @@ -81,8 +82,10 @@ class Message { parts: parsedParts, createdAtMs: (info['time'] as Map?)?['created'] as int?, completedAtMs: (info['time'] as Map?)?['completed'] as int?, - modelId: info['modelID'] as String? ?? (info['model'] as Map?)?['id'] as String?, - providerId: info['providerID'] as String? ?? (info['model'] as Map?)?['providerID'] as String?, + modelId: info['modelID'] as String? ?? + (info['model'] as Map?)?['id'] as String?, + providerId: info['providerID'] as String? ?? + (info['model'] as Map?)?['providerID'] as String?, ); } diff --git a/lib/models/project.dart b/lib/models/project.dart new file mode 100644 index 0000000..9fc33e2 --- /dev/null +++ b/lib/models/project.dart @@ -0,0 +1,42 @@ +/// Gateway project model. +/// +/// A project is a working directory exposed by the gateway host. +library; + +class Project { + const Project({ + required this.id, + required this.name, + required this.directory, + required this.updatedAtMs, + }); + + final String id; + final String name; + final String directory; + final int updatedAtMs; + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + directory: json['directory'] as String? ?? '', + updatedAtMs: _readInt(json['updatedAt']) ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'directory': directory, + 'updatedAt': updatedAtMs, + }; + } + + static int? _readInt(Object? value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/models/session.dart b/lib/models/session.dart index 21c3530..8337d3c 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -48,7 +48,9 @@ class Session { providerId: model['providerID'] as String?, cost: (json['cost'] as num?)?.toDouble() ?? 0.0, tokens: json['tokens'] is Map - ? SessionTokens.fromJson((json['tokens'] as Map).cast()) + ? SessionTokens.fromJson( + (json['tokens'] as Map).cast(), + ) : null, ); } diff --git a/lib/state/agent_catalog_store.dart b/lib/state/agent_catalog_store.dart new file mode 100644 index 0000000..61f548a --- /dev/null +++ b/lib/state/agent_catalog_store.dart @@ -0,0 +1,115 @@ +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import '../models/agent.dart'; +import 'gateway_client_provider.dart'; + +@immutable +class AgentCatalogState { + const AgentCatalogState({ + required this.agents, + required this.selectedAgentId, + required this.loading, + this.error, + }); + + final List agents; + final String? selectedAgentId; + final bool loading; + final String? error; + + AgentCatalogState copyWith({ + List? agents, + String? selectedAgentId, + bool? loading, + String? error, + bool clearError = false, + }) => + AgentCatalogState( + agents: agents ?? this.agents, + selectedAgentId: selectedAgentId ?? this.selectedAgentId, + loading: loading ?? this.loading, + error: clearError ? null : (error ?? this.error), + ); +} + +class AgentCatalogStore extends StateNotifier { + AgentCatalogStore({required GatewayClient client}) + : _client = client, + super( + const AgentCatalogState( + agents: [], + selectedAgentId: null, + loading: true, + ), + ) { + refresh(); + } + + final GatewayClient _client; + + Future refresh() async { + state = state.copyWith(loading: true, clearError: true); + try { + final listedAgents = await _client.listAgents(); + final agents = await Future.wait( + listedAgents.map((agent) async { + if (!agent.supportsModels) return agent; + try { + final models = await _client.listAgentModels(agent.id); + return Agent( + id: agent.id, + displayName: agent.displayName, + supportsModels: agent.supportsModels, + supportsSlashCommands: agent.supportsSlashCommands, + supportsAttachments: agent.supportsAttachments, + supportsPermissions: agent.supportsPermissions, + sessionKind: agent.sessionKind, + commands: agent.commands, + raw: { + ...agent.raw, + 'models': + models.map((model) => model.raw).toList(growable: false), + }, + ); + } catch (_) { + return agent; + } + }), + ); + state = state.copyWith( + agents: agents, + selectedAgentId: + state.selectedAgentId ?? (agents.isEmpty ? null : agents.first.id), + loading: false, + ); + } catch (error) { + state = state.copyWith(loading: false, error: error.toString()); + } + } + + void selectAgent(String? agentId) { + state = AgentCatalogState( + agents: state.agents, + selectedAgentId: agentId, + loading: state.loading, + ); + } + + Future> modelsFor(String agentId) => + _client.listAgentModels(agentId); + + Future> commandsFor(String agentId) => + _client.listAgentCommands(agentId); +} + +final agentCatalogStoreProvider = + StateNotifierProvider((ref) { + final client = ref.watch(gatewayClientProvider); + return AgentCatalogStore(client: client); +}); + +final agentCatalogProvider = agentCatalogStoreProvider; diff --git a/lib/state/chat_store.dart b/lib/state/chat_store.dart index 82ea923..940fa05 100644 --- a/lib/state/chat_store.dart +++ b/lib/state/chat_store.dart @@ -134,7 +134,8 @@ class ChatController extends StateNotifier { void _onEvent(SseEvent ev) { final type = ev.data['type'] as String? ?? ev.type; - final props = (ev.data['properties'] as Map?)?.cast() ?? const {}; + final props = + (ev.data['properties'] as Map?)?.cast() ?? const {}; switch (type) { case 'message.updated': @@ -145,7 +146,9 @@ class ChatController extends StateNotifier { _onPartDelta(props); case 'session.error': final err = props['error']; - state = state.copyWith(error: err is Map ? err['message'] as String? : err?.toString()); + state = state.copyWith( + error: err is Map ? err['message'] as String? : err?.toString(), + ); case 'server.connected': case 'session.updated': // We don't change message state here; session updates affect the @@ -157,7 +160,9 @@ class ChatController extends StateNotifier { void _onMessageUpdated(Map props) { final info = (props['info'] as Map?)?.cast(); if (info == null) return; - if (info['sessionID'] != null && info['sessionID'] != state.sessionId) return; + if (info['sessionID'] != null && info['sessionID'] != state.sessionId) { + return; + } final incoming = Message.fromJson(info); final existing = state.messages[incoming.id]; diff --git a/lib/state/codex_chat_store.dart b/lib/state/codex_chat_store.dart index dd7929b..da717e2 100644 --- a/lib/state/codex_chat_store.dart +++ b/lib/state/codex_chat_store.dart @@ -106,10 +106,12 @@ class CodexChatController extends StateNotifier { String directory = '', }) : _client = client, _localKey = localKey, - super(CodexChatState.initial( - threadId: threadId ?? '', - directory: directory, - )); + super( + CodexChatState.initial( + threadId: threadId ?? '', + directory: directory, + ), + ); final CodexClient _client; // ignore: unused_field @@ -131,7 +133,8 @@ class CodexChatController extends StateNotifier { if (state.isStreaming) return; // serialize turns // 1) Append the user message immediately so the UI shows it. - final userId = 'u_${++_userMsgCounter}_${DateTime.now().microsecondsSinceEpoch}'; + final userId = + 'u_${++_userMsgCounter}_${DateTime.now().microsecondsSinceEpoch}'; final userMessage = Message( id: userId, role: MessageRole.user, @@ -147,7 +150,8 @@ class CodexChatController extends StateNotifier { }, createdAtMs: DateTime.now().millisecondsSinceEpoch, ); - final next = Map.from(state.messages)..[userId] = userMessage; + final next = Map.from(state.messages) + ..[userId] = userMessage; state = state.copyWith( messages: next, directory: directory, @@ -156,7 +160,8 @@ class CodexChatController extends StateNotifier { ); // 2) Pre-create the assistant message so streaming parts can attach. - final asstId = 'a_${++_userMsgCounter}_${DateTime.now().microsecondsSinceEpoch}'; + final asstId = + 'a_${++_userMsgCounter}_${DateTime.now().microsecondsSinceEpoch}'; _activeAssistantMessageId = asstId; final asst = Message( id: asstId, @@ -262,8 +267,7 @@ class CodexChatController extends StateNotifier { if (raw is! Map) return; final item = raw.cast(); final itemType = item['type'] as String? ?? ''; - final itemId = (item['id'] as String?)?.toString() ?? - 'item_${++_itemSeq}'; + final itemId = (item['id'] as String?)?.toString() ?? 'item_${++_itemSeq}'; switch (itemType) { case 'command_execution': diff --git a/lib/state/codex_thread_store.dart b/lib/state/codex_thread_store.dart index 0cf9b10..4975722 100644 --- a/lib/state/codex_thread_store.dart +++ b/lib/state/codex_thread_store.dart @@ -78,10 +78,8 @@ class CodexThreadListState { static const empty = CodexThreadListState(items: []); } -class CodexThreadListController - extends StateNotifier { - CodexThreadListController(this._prefs) - : super(_load(_prefs)); +class CodexThreadListController extends StateNotifier { + CodexThreadListController(this._prefs) : super(_load(_prefs)); final SharedPreferences _prefs; static const _kKey = 'codex.threads'; @@ -168,8 +166,9 @@ class CodexThreadListController } } -final codexThreadListProvider = StateNotifierProvider< - CodexThreadListController, CodexThreadListState>((ref) { +final codexThreadListProvider = + StateNotifierProvider( + (ref) { final prefs = ref.watch(sharedPreferencesProvider).maybeWhen( data: (v) => v, orElse: () => null, diff --git a/lib/state/gateway_chat_store.dart b/lib/state/gateway_chat_store.dart new file mode 100644 index 0000000..b8a0193 --- /dev/null +++ b/lib/state/gateway_chat_store.dart @@ -0,0 +1,338 @@ +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import '../models/gateway_event.dart'; +import '../models/gateway_session.dart'; +import '../models/message.dart'; +import '../models/part.dart'; +import 'gateway_client_provider.dart'; + +enum GatewayChatConnectionState { connecting, connected, disconnected } + +@immutable +class GatewayChatState { + const GatewayChatState({ + required this.sessionId, + required this.session, + required this.messages, + required this.isStreaming, + required this.connection, + this.error, + }); + + final String sessionId; + final GatewaySession? session; + final Map messages; + final bool isStreaming; + final GatewayChatConnectionState connection; + final String? error; + + Iterable get orderedMessages => messages.values; + List get items => messages.values.toList(growable: false); + + static GatewayChatState initial(String sessionId) => GatewayChatState( + sessionId: sessionId, + session: null, + messages: const {}, + isStreaming: false, + connection: GatewayChatConnectionState.connecting, + ); + + GatewayChatState copyWith({ + String? sessionId, + GatewaySession? session, + Map? messages, + bool? isStreaming, + GatewayChatConnectionState? connection, + String? error, + bool clearError = false, + }) => + GatewayChatState( + sessionId: sessionId ?? this.sessionId, + session: session ?? this.session, + messages: messages ?? this.messages, + isStreaming: isStreaming ?? this.isStreaming, + connection: connection ?? this.connection, + error: clearError ? null : (error ?? this.error), + ); +} + +class GatewayChatStore extends StateNotifier { + GatewayChatStore({ + required GatewayClient client, + required String sessionId, + }) : _client = client, + super(GatewayChatState.initial(sessionId)) { + _load(); + _bindEvents(); + } + + final GatewayClient _client; + StreamSubscription? _eventSub; + + Future _load() async { + try { + final session = await _client.getSession(state.sessionId); + final rawMessages = await _client.listMessages(state.sessionId); + final next = Map.from(state.messages); + for (final json in rawMessages) { + final message = Message.fromJson(json); + if (message.id.isNotEmpty) { + final existing = next[message.id]; + if (existing == null || + message.parts.length >= existing.parts.length) { + next[message.id] = message; + } + } + } + state = state.copyWith(session: session, messages: next); + } catch (error) { + state = state.copyWith(error: error.toString()); + } + } + + void _bindEvents() { + state = state.copyWith(connection: GatewayChatConnectionState.connecting); + _eventSub = _client.events(state.sessionId).listen( + _onEvent, + onError: (Object error) { + state = state.copyWith( + connection: GatewayChatConnectionState.disconnected, + error: error.toString(), + ); + }, + onDone: () { + state = + state.copyWith(connection: GatewayChatConnectionState.disconnected); + }, + ); + } + + Future sendMessage( + String text, { + List> attachments = const [], + }) async { + if (text.trim().isEmpty && attachments.isEmpty) return; + state = state.copyWith(isStreaming: true, clearError: true); + await _client.sendMessage( + sessionId: state.sessionId, + text: text, + parts: attachments, + ); + } + + Future sendSlashCommand(String command) async { + if (command.trim().isEmpty) return; + state = state.copyWith(isStreaming: true, clearError: true); + await _client.sendSlashCommand( + sessionId: state.sessionId, + command: command, + ); + } + + Future abort() async { + await _client.abortSession(state.sessionId); + state = state.copyWith(isStreaming: false); + } + + void _onEvent(GatewayEvent event) { + if (state.connection != GatewayChatConnectionState.connected) { + state = state.copyWith(connection: GatewayChatConnectionState.connected); + } + switch (event.type) { + case 'message.created': + case 'message.updated': + _onMessage(event.data); + case 'message.part.updated': + _onPart(event.data); + case 'message.delta': + _onMessageDelta(event); + case 'message.part.delta': + _onPartDelta(event.data); + case 'session.updated': + _onSession(event.data); + case 'session.started': + case 'session.completed': + state = state.copyWith(isStreaming: event.type != 'session.completed'); + case 'session.error': + state = state.copyWith( + isStreaming: false, + error: _stringMessage(event.data['error']) ?? + _stringMessage(event.data['message']) ?? + 'session error', + ); + case 'status.updated': + state = state.copyWith( + isStreaming: event.data['status']?.toString() == 'running', + ); + default: + break; + } + } + + void _onSession(Map data) { + final session = data['session'] is Map + ? GatewaySession.fromJson( + (data['session'] as Map).cast(), + ) + : GatewaySession.fromJson(data); + state = state.copyWith(session: session); + } + + void _onMessage(Map data) { + final messageJson = data['message'] is Map + ? (data['message'] as Map).cast() + : data['info'] is Map + ? (data['info'] as Map).cast() + : data; + final message = Message.fromJson(messageJson); + if (message.id.isEmpty) return; + final existing = state.messages[message.id]; + final merged = existing == null + ? message + : existing.copyWith( + role: message.role, + status: message.status, + createdAtMs: message.createdAtMs ?? existing.createdAtMs, + completedAtMs: message.completedAtMs ?? existing.completedAtMs, + modelId: message.modelId ?? existing.modelId, + providerId: message.providerId ?? existing.providerId, + ); + final next = Map.from(state.messages) + ..[merged.id] = merged; + state = state.copyWith(messages: next, clearError: true); + } + + void _onPart(Map data) { + final partJson = data['part'] is Map + ? (data['part'] as Map).cast() + : data; + final part = Part.fromJson(partJson); + if (part.id.isEmpty) return; + final messageId = part.messageId; + if (messageId.isEmpty) return; + final existing = state.messages[messageId]; + if (existing == null) { + final placeholder = Message( + id: messageId, + role: MessageRole.unknown, + sessionId: state.sessionId, + status: MessageStatus.running, + parts: {part.id: part}, + ); + final next = Map.from(state.messages) + ..[messageId] = placeholder; + state = state.copyWith(messages: next); + return; + } + final next = Map.from(state.messages) + ..[messageId] = existing.withPartUpsert(part); + state = state.copyWith(messages: next); + } + + void _onMessageDelta(GatewayEvent event) { + final data = event.data; + final messageId = data['messageID'] as String? ?? + data['messageId'] as String? ?? + data['id'] as String? ?? + 'gateway_${event.sessionId}_${state.messages.length}'; + final partId = data['partID'] as String? ?? + data['partId'] as String? ?? + '${messageId}_text'; + final delta = data['delta'] ?? data['text'] ?? data['content']; + if (delta is! String || delta.isEmpty) return; + _onPartDelta({ + 'messageId': messageId, + 'partId': partId, + 'field': data['field'] as String? ?? 'text', + 'delta': delta, + }); + } + + void _onPartDelta(Map data) { + final messageId = + data['messageID'] as String? ?? data['messageId'] as String? ?? ''; + final partId = data['partID'] as String? ?? data['partId'] as String? ?? ''; + final field = data['field'] as String? ?? 'text'; + final delta = data['delta']; + if (messageId.isEmpty || partId.isEmpty || delta is! String) return; + final existing = state.messages[messageId]; + final existingPart = existing?.parts[partId]; + Part nextPart; + if (existingPart is TextPart && field == 'text') { + nextPart = TextPart( + id: existingPart.id, + messageId: existingPart.messageId, + sessionId: existingPart.sessionId, + text: existingPart.text + delta, + ); + } else if (existingPart is ReasoningPart && field == 'reasoning') { + nextPart = ReasoningPart( + id: existingPart.id, + messageId: existingPart.messageId, + sessionId: existingPart.sessionId, + text: existingPart.text + delta, + ); + } else if (existingPart == null) { + nextPart = field == 'reasoning' + ? ReasoningPart( + id: partId, + messageId: messageId, + sessionId: state.sessionId, + text: delta, + ) + : TextPart( + id: partId, + messageId: messageId, + sessionId: state.sessionId, + text: delta, + ); + } else { + return; + } + + if (existing == null) { + final placeholder = Message( + id: messageId, + role: MessageRole.unknown, + sessionId: state.sessionId, + status: MessageStatus.running, + parts: {partId: nextPart}, + ); + final next = Map.from(state.messages) + ..[messageId] = placeholder; + state = state.copyWith(messages: next); + return; + } + final next = Map.from(state.messages) + ..[messageId] = existing.withPartUpsert(nextPart); + state = state.copyWith(messages: next); + } + + static String? _stringMessage(Object? value) => value is String + ? value + : value is Map + ? (value['message'] as String?) + : value?.toString(); + + @override + void dispose() { + _eventSub?.cancel(); + super.dispose(); + } +} + +final gatewayChatStoreProvider = StateNotifierProvider.autoDispose + .family( + (ref, sessionId) { + final client = ref.watch(gatewayClientProvider); + return GatewayChatStore(client: client, sessionId: sessionId); + }, +); + +final gatewayChatProvider = gatewayChatStoreProvider; diff --git a/lib/state/gateway_client_provider.dart b/lib/state/gateway_client_provider.dart new file mode 100644 index 0000000..4e49630 --- /dev/null +++ b/lib/state/gateway_client_provider.dart @@ -0,0 +1,16 @@ +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import 'settings_store.dart'; + +final gatewayClientProvider = Provider((ref) { + final settings = ref.watch(settingsControllerProvider); + final client = GatewayClient( + baseUrl: Uri.parse(settings.baseUrl), + bearerToken: settings.bearerToken, + ); + ref.onDispose(client.close); + return client; +}); diff --git a/lib/state/gateway_providers.dart b/lib/state/gateway_providers.dart new file mode 100644 index 0000000..574a63d --- /dev/null +++ b/lib/state/gateway_providers.dart @@ -0,0 +1,10 @@ +library; + +export 'agent_catalog_store.dart' + show agentCatalogProvider, agentCatalogStoreProvider; +export 'gateway_chat_store.dart' + show gatewayChatProvider, gatewayChatStoreProvider; +export 'gateway_client_provider.dart' show gatewayClientProvider; +export 'gateway_session_store.dart' + show gatewaySessionListProvider, gatewaySessionStoreProvider; +export 'project_store.dart' show projectStoreProvider; diff --git a/lib/state/gateway_session_store.dart b/lib/state/gateway_session_store.dart new file mode 100644 index 0000000..c921ade --- /dev/null +++ b/lib/state/gateway_session_store.dart @@ -0,0 +1,115 @@ +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import '../models/gateway_session.dart'; +import 'gateway_client_provider.dart'; + +@immutable +class GatewaySessionState { + const GatewaySessionState({ + required this.projectId, + required this.sessions, + required this.loading, + this.selectedSessionId, + this.error, + }); + + final String projectId; + final List sessions; + final String? selectedSessionId; + final bool loading; + final String? error; + + GatewaySessionState copyWith({ + String? projectId, + List? sessions, + String? selectedSessionId, + bool? loading, + String? error, + bool clearError = false, + }) => + GatewaySessionState( + projectId: projectId ?? this.projectId, + sessions: sessions ?? this.sessions, + selectedSessionId: selectedSessionId ?? this.selectedSessionId, + loading: loading ?? this.loading, + error: clearError ? null : (error ?? this.error), + ); +} + +class GatewaySessionStore extends StateNotifier { + GatewaySessionStore({ + required GatewayClient client, + required String projectId, + }) : _client = client, + super( + GatewaySessionState( + projectId: projectId, + sessions: const [], + loading: true, + ), + ) { + refresh(); + } + + final GatewayClient _client; + + Future refresh() async { + if (state.projectId.isEmpty) return; + state = state.copyWith(loading: true, clearError: true); + try { + final sessions = await _client.listProjectSessions(state.projectId); + state = state.copyWith( + sessions: sessions, + selectedSessionId: state.selectedSessionId ?? + (sessions.isEmpty ? null : sessions.first.id), + loading: false, + ); + } catch (error) { + state = state.copyWith(loading: false, error: error.toString()); + } + } + + Future createSession({ + required String agentId, + String? modelId, + }) async { + final session = await _client.createSession( + projectId: state.projectId, + agentId: agentId, + modelId: modelId, + ); + final sessions = [ + session, + ...state.sessions.where((s) => s.id != session.id), + ]; + state = state.copyWith( + sessions: sessions, + selectedSessionId: session.id, + clearError: true, + ); + return session; + } + + void selectSession(String? sessionId) { + state = GatewaySessionState( + projectId: state.projectId, + sessions: state.sessions, + selectedSessionId: sessionId, + loading: state.loading, + ); + } +} + +final gatewaySessionStoreProvider = StateNotifierProvider.family< + GatewaySessionStore, GatewaySessionState, String>( + (ref, projectId) { + final client = ref.watch(gatewayClientProvider); + return GatewaySessionStore(client: client, projectId: projectId); + }, +); + +final gatewaySessionListProvider = gatewaySessionStoreProvider; diff --git a/lib/state/project_store.dart b/lib/state/project_store.dart new file mode 100644 index 0000000..cca24c4 --- /dev/null +++ b/lib/state/project_store.dart @@ -0,0 +1,104 @@ +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import '../models/project.dart'; +import 'gateway_client_provider.dart'; + +@immutable +class ProjectState { + const ProjectState({ + required this.projects, + required this.selectedProjectId, + required this.loading, + this.error, + }); + + final List projects; + final String? selectedProjectId; + final bool loading; + final String? error; + + ProjectState copyWith({ + List? projects, + String? selectedProjectId, + bool? loading, + String? error, + bool clearError = false, + }) => + ProjectState( + projects: projects ?? this.projects, + selectedProjectId: selectedProjectId ?? this.selectedProjectId, + loading: loading ?? this.loading, + error: clearError ? null : (error ?? this.error), + ); +} + +class ProjectStore extends StateNotifier { + ProjectStore({required GatewayClient client}) + : _client = client, + super( + const ProjectState( + projects: [], + selectedProjectId: null, + loading: true, + ), + ) { + refresh(); + } + + final GatewayClient _client; + + Future refresh() async { + state = state.copyWith(loading: true, clearError: true); + try { + final projects = await _client.listProjects(); + state = state.copyWith( + projects: projects, + selectedProjectId: state.selectedProjectId ?? + (projects.isEmpty ? null : projects.first.id), + loading: false, + ); + } catch (error) { + state = state.copyWith(loading: false, error: error.toString()); + } + } + + Future addProject(String directory) async { + final project = await _client.createProject( + directory: directory, + name: _shortDirName(directory), + ); + final projects = [ + project, + ...state.projects.where((p) => p.id != project.id), + ]; + state = state.copyWith( + projects: projects, + selectedProjectId: project.id, + clearError: true, + ); + return project; + } + + void selectProject(String? projectId) { + state = ProjectState( + projects: state.projects, + selectedProjectId: projectId, + loading: state.loading, + ); + } +} + +String _shortDirName(String path) { + final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty); + return parts.isEmpty ? path : parts.last; +} + +final projectStoreProvider = + StateNotifierProvider((ref) { + final client = ref.watch(gatewayClientProvider); + return ProjectStore(client: client); +}); diff --git a/lib/state/session_store.dart b/lib/state/session_store.dart index 7c7aabf..6cf59c9 100644 --- a/lib/state/session_store.dart +++ b/lib/state/session_store.dart @@ -99,8 +99,8 @@ class SessionListController extends StateNotifier { void _onEvent(SseEvent ev) { final type = ev.data['type'] as String? ?? ev.type; if (type != 'session.updated') return; - final info = - ((ev.data['properties'] as Map?)?['info'] as Map?)?.cast(); + final info = ((ev.data['properties'] as Map?)?['info'] as Map?) + ?.cast(); if (info == null) return; final updated = Session.fromJson(info); final next = [...state.items]; diff --git a/lib/state/settings_store.dart b/lib/state/settings_store.dart index 10490b1..969b315 100644 --- a/lib/state/settings_store.dart +++ b/lib/state/settings_store.dart @@ -23,7 +23,8 @@ class AppSettings { final String providerId; final String modelId; - bool get isConfigured => baseUrl.isNotEmpty && providerId.isNotEmpty && modelId.isNotEmpty; + bool get isConfigured => + baseUrl.isNotEmpty && providerId.isNotEmpty && modelId.isNotEmpty; AppSettings copyWith({ String? baseUrl, diff --git a/lib/ui/pages/agent_group_page.dart b/lib/ui/pages/agent_group_page.dart new file mode 100644 index 0000000..aaba3ae --- /dev/null +++ b/lib/ui/pages/agent_group_page.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../state/gateway_providers.dart'; +import '../widgets/agent_badge.dart'; +import 'gateway_chat_page.dart'; +import 'gateway_ui_adapters.dart'; + +class AgentGroupPage extends ConsumerStatefulWidget { + const AgentGroupPage({ + super.key, + required this.project, + }); + + final GatewayProjectView project; + + @override + ConsumerState createState() => _AgentGroupPageState(); +} + +class _AgentGroupPageState extends ConsumerState { + GatewayAgentView? _selectedAgent; + GatewayModelView? _selectedModel; + Future>? _modelsFuture; + bool _modelLookupComplete = false; + bool _creating = false; + + @override + Widget build(BuildContext context) { + final catalog = ref.watch(agentCatalogProvider); + final agents = readAgents(catalog); + + return Scaffold( + appBar: AppBar( + title: const Text('New conversation'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.project.directory, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), + children: [ + Text('Agent', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (agents.isEmpty) + const _EmptyCatalog() + else + for (final agent in agents) + _AgentOption( + agent: agent, + selected: _selectedAgent?.id == agent.id, + onTap: () => setState(() { + _selectedAgent = agent; + _selectedModel = null; + _modelLookupComplete = false; + _modelsFuture = _loadModels(agent.id); + }), + ), + if (_selectedAgent != null && _selectedAgent!.supportsModels) ...[ + const SizedBox(height: 20), + Text('Model', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + FutureBuilder>( + future: _modelsFuture ?? _loadModels(_selectedAgent!.id), + builder: (context, snapshot) { + final models = snapshot.data ?? const []; + if (snapshot.connectionState == ConnectionState.waiting && + models.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (models.isNotEmpty && + _selectedModel == null && + _selectedAgent != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedModel == null) { + setState(() => _selectedModel = models.first); + } + }); + } + if (snapshot.connectionState == ConnectionState.done && + !_modelLookupComplete) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_modelLookupComplete) { + setState(() => _modelLookupComplete = true); + } + }); + } + return _ModelPicker( + models: models, + selected: _selectedModel, + onSelected: (model) => setState(() => _selectedModel = model), + ); + }, + ), + ], + ], + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FilledButton.icon( + icon: _creating + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_forward), + label: const Text('Create session'), + onPressed: _canCreate ? _createSession : null, + ), + ), + ), + ); + } + + Future> _loadModels(String agentId) async { + final notifier = ref.read(agentCatalogProvider.notifier); + try { + final models = await notifier.modelsFor(agentId); + return models + .map( + (model) => GatewayModelView( + id: model.id, + displayName: model.displayName.trim().isEmpty + ? model.id + : model.displayName, + ), + ) + .toList(growable: false); + } catch (_) { + return _selectedAgent?.models ?? const []; + } + } + + bool get _canCreate { + if (_creating || _selectedAgent == null) return false; + if (_selectedAgent!.supportsModels) { + return _selectedModel != null || + (_modelLookupComplete && _selectedAgent!.models.isEmpty); + } + return true; + } + + Future _createSession() async { + final agent = _selectedAgent; + if (agent == null) return; + setState(() => _creating = true); + try { + final notifier = + ref.read(gatewaySessionListProvider(widget.project.id).notifier); + final created = await notifier.createSession( + agentId: agent.id, + modelId: _selectedModel?.id, + ); + if (!mounted) return; + final session = readSession(created); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => GatewayChatPage( + session: session, + project: widget.project, + agent: agent, + ), + ), + ); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Create failed: $err')), + ); + } finally { + if (mounted) setState(() => _creating = false); + } + } +} + +class _AgentOption extends StatelessWidget { + const _AgentOption({ + required this.agent, + required this.selected, + required this.onTap, + }); + + final GatewayAgentView agent; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: selected + ? theme.colorScheme.secondaryContainer.withValues(alpha: 0.55) + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + leading: AgentBadge( + agentId: agent.id, + label: agent.displayName, + compact: true, + ), + title: Text(agent.displayName), + subtitle: Text( + [ + if (agent.supportsModels) 'models', + if (agent.supportsSlashCommands) 'slash commands', + ].join(' / '), + ), + trailing: selected ? const Icon(Icons.check_circle) : null, + onTap: onTap, + ), + ), + ); + } +} + +class _ModelPicker extends StatelessWidget { + const _ModelPicker({ + required this.models, + required this.selected, + required this.onSelected, + }); + + final List models; + final GatewayModelView? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + if (models.isEmpty) { + return Text( + 'Gateway did not report selectable models.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final model in models) + ChoiceChip( + label: Text(model.displayName), + selected: selected?.id == model.id, + onSelected: (_) => onSelected(model), + ), + ], + ); + } +} + +class _EmptyCatalog extends StatelessWidget { + const _EmptyCatalog(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + 'No agents reported by the gateway.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} diff --git a/lib/ui/pages/chat_page.dart b/lib/ui/pages/chat_page.dart index ab4bc0b..a365bd2 100644 --- a/lib/ui/pages/chat_page.dart +++ b/lib/ui/pages/chat_page.dart @@ -196,8 +196,7 @@ class _ChatPageState extends ConsumerState { vertical: 8, ), itemCount: messages.length, - itemBuilder: (_, i) => - MessageBubble(message: messages[i]), + itemBuilder: (_, i) => MessageBubble(message: messages[i]), ), ), // Attachment preview strip @@ -212,8 +211,7 @@ class _ChatPageState extends ConsumerState { onSend: _send, onAbort: _abort, onAttach: _pickAttachment, - isStreaming: - messages.any((m) => m.status == MessageStatus.running), + isStreaming: messages.any((m) => m.status == MessageStatus.running), hasAttachments: _attachments.isNotEmpty, ), ], diff --git a/lib/ui/pages/chat_tab.dart b/lib/ui/pages/chat_tab.dart index 0bf5d8f..84cdf96 100644 --- a/lib/ui/pages/chat_tab.dart +++ b/lib/ui/pages/chat_tab.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'codex_thread_list_page.dart'; +import 'project_list_page.dart'; -/// Chat tab with its own nested [Navigator] so that tapping a thread -/// pushes [CodexChatPage] within this tab without affecting the bottom nav. +/// Project tab with its own nested [Navigator] so that deeper project flows +/// stay inside the tab without affecting the bottom nav. class ChatTab extends StatefulWidget { const ChatTab({super.key}); @@ -30,7 +30,7 @@ class _ChatTabState extends State { onGenerateRoute: (settings) { return MaterialPageRoute( settings: settings, - builder: (_) => const CodexThreadListPage(), + builder: (_) => const ProjectListPage(), ); }, ), diff --git a/lib/ui/pages/codex_chat_page.dart b/lib/ui/pages/codex_chat_page.dart index 061be2c..e97b71a 100644 --- a/lib/ui/pages/codex_chat_page.dart +++ b/lib/ui/pages/codex_chat_page.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/message.dart'; -import '../../state/codex_chat_store.dart'; import '../../state/codex_providers.dart'; import '../../state/codex_thread_store.dart'; import '../../state/settings_store.dart'; @@ -80,9 +79,8 @@ class _CodexChatPageState extends ConsumerState { // include filenames inline so the agent can read them via shell tools. var prompt = text; if (_attachments.isNotEmpty) { - final names = _attachments - .map((a) => '- ${a.fileName} (${a.mimeType})') - .join('\n'); + final names = + _attachments.map((a) => '- ${a.fileName} (${a.mimeType})').join('\n'); prompt = '$prompt\n\nAttachments available in working directory:\n$names'; } @@ -102,9 +100,7 @@ class _CodexChatPageState extends ConsumerState { .read(codexThreadListProvider.notifier) .updateThreadId(widget.localKey, tid); } - await ref - .read(codexThreadListProvider.notifier) - .touch(widget.localKey); + await ref.read(codexThreadListProvider.notifier).touch(widget.localKey); _scrollToBottom(force: true); } catch (err) { if (!mounted) return; @@ -202,8 +198,7 @@ class _CodexChatPageState extends ConsumerState { vertical: 8, ), itemCount: messages.length, - itemBuilder: (_, i) => - MessageBubble(message: messages[i]), + itemBuilder: (_, i) => MessageBubble(message: messages[i]), ), ), if (_attachments.isNotEmpty) diff --git a/lib/ui/pages/codex_thread_list_page.dart b/lib/ui/pages/codex_thread_list_page.dart index e94695c..e4190d7 100644 --- a/lib/ui/pages/codex_thread_list_page.dart +++ b/lib/ui/pages/codex_thread_list_page.dart @@ -72,8 +72,7 @@ class CodexThreadListPage extends ConsumerWidget { Future _create(BuildContext context, WidgetRef ref) async { final settings = ref.read(settingsControllerProvider); - final qqbotUrl = - 'http://${Uri.parse(settings.baseUrl).host}:8787'; + final qqbotUrl = 'http://${Uri.parse(settings.baseUrl).host}:8787'; final dir = await showDirectoryPicker( context, qqbotBaseUrl: qqbotUrl, diff --git a/lib/ui/pages/files_page.dart b/lib/ui/pages/files_page.dart index c76879c..85ccede 100644 --- a/lib/ui/pages/files_page.dart +++ b/lib/ui/pages/files_page.dart @@ -1,3 +1,5 @@ +// ignore_for_file: unintended_html_in_doc_comment + /// File tree browser for the current session's working directory. /// /// Shows a recursive file/folder tree fetched from the QQBot server endpoint: @@ -256,8 +258,11 @@ class _FilesPageState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_off_outlined, - size: 48, color: theme.colorScheme.outline), + Icon( + Icons.folder_off_outlined, + size: 48, + color: theme.colorScheme.outline, + ), const SizedBox(height: 12), Text( 'Could not load files', @@ -343,8 +348,11 @@ class _DirectoryHeader extends StatelessWidget { const SizedBox(height: 4), Row( children: [ - Icon(Icons.folder_outlined, - size: 14, color: theme.colorScheme.outline), + Icon( + Icons.folder_outlined, + size: 14, + color: theme.colorScheme.outline, + ), const SizedBox(width: 6), Expanded( child: Text( @@ -560,8 +568,11 @@ class _FileViewerPageState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, - size: 48, color: theme.colorScheme.error), + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), const SizedBox(height: 12), Text(_error!, textAlign: TextAlign.center), const SizedBox(height: 16), @@ -699,7 +710,7 @@ class _CodeLine extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', fontSize: 12, - color: theme.colorScheme.outline.withOpacity(0.5), + color: theme.colorScheme.outline.withValues(alpha: 0.5), ), ), ), @@ -742,7 +753,10 @@ class _HighlightedText extends StatelessWidget { } static List _highlight( - String text, String language, ThemeData theme) { + String text, + String language, + ThemeData theme, + ) { if (text.isEmpty) return [const TextSpan(text: ' ')]; final colorScheme = theme.colorScheme; @@ -767,7 +781,7 @@ class _HighlightedText extends StatelessWidget { final spans = []; final pattern = RegExp( - '(${_stringPattern})|' + '($_stringPattern)|' '(\\b(?:${keywords.join('|')})\\b)|' '(\\b\\d+\\.?\\d*\\b)', ); @@ -776,10 +790,12 @@ class _HighlightedText extends StatelessWidget { for (final match in pattern.allMatches(text)) { // Text before match if (match.start > lastEnd) { - spans.add(TextSpan( - text: text.substring(lastEnd, match.start), - style: TextStyle(color: defaultColor), - )); + spans.add( + TextSpan( + text: text.substring(lastEnd, match.start), + style: TextStyle(color: defaultColor), + ), + ); } final matched = match.group(0)!; Color color; @@ -790,21 +806,25 @@ class _HighlightedText extends StatelessWidget { } else { color = numberColor; } - spans.add(TextSpan( - text: matched, - style: TextStyle( - color: color, - fontWeight: - match.group(2) != null ? FontWeight.w600 : FontWeight.normal, + spans.add( + TextSpan( + text: matched, + style: TextStyle( + color: color, + fontWeight: + match.group(2) != null ? FontWeight.w600 : FontWeight.normal, + ), ), - )); + ); lastEnd = match.end; } if (lastEnd < text.length) { - spans.add(TextSpan( - text: text.substring(lastEnd), - style: TextStyle(color: defaultColor), - )); + spans.add( + TextSpan( + text: text.substring(lastEnd), + style: TextStyle(color: defaultColor), + ), + ); } return spans.isEmpty ? [TextSpan(text: text, style: TextStyle(color: defaultColor))] @@ -816,48 +836,210 @@ class _HighlightedText extends StatelessWidget { static List _keywordsFor(String language) { return switch (language) { 'dart' => [ - 'import', 'export', 'library', 'part', 'class', 'abstract', - 'extends', 'implements', 'mixin', 'enum', 'typedef', - 'final', 'const', 'var', 'late', 'static', 'dynamic', - 'void', 'int', 'double', 'String', 'bool', 'List', 'Map', 'Set', - 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', - 'break', 'continue', 'return', 'throw', 'try', 'catch', 'finally', - 'async', 'await', 'yield', 'sync', - 'true', 'false', 'null', 'this', 'super', 'new', - 'required', 'override', 'factory', 'get', 'set', 'with', + 'import', + 'export', + 'library', + 'part', + 'class', + 'abstract', + 'extends', + 'implements', + 'mixin', + 'enum', + 'typedef', + 'final', + 'const', + 'var', + 'late', + 'static', + 'dynamic', + 'void', + 'int', + 'double', + 'String', + 'bool', + 'List', + 'Map', + 'Set', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'default', + 'break', + 'continue', + 'return', + 'throw', + 'try', + 'catch', + 'finally', + 'async', + 'await', + 'yield', + 'sync', + 'true', + 'false', + 'null', + 'this', + 'super', + 'new', + 'required', + 'override', + 'factory', + 'get', + 'set', + 'with', ], 'typescript' || 'javascript' => [ - 'import', 'export', 'from', 'default', 'as', - 'const', 'let', 'var', 'function', 'class', 'extends', - 'interface', 'type', 'enum', 'namespace', - 'if', 'else', 'for', 'while', 'do', 'switch', 'case', - 'break', 'continue', 'return', 'throw', 'try', 'catch', 'finally', - 'async', 'await', 'yield', - 'true', 'false', 'null', 'undefined', 'this', 'new', - 'void', 'string', 'number', 'boolean', 'any', 'never', + 'import', + 'export', + 'from', + 'default', + 'as', + 'const', + 'let', + 'var', + 'function', + 'class', + 'extends', + 'interface', + 'type', + 'enum', + 'namespace', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'return', + 'throw', + 'try', + 'catch', + 'finally', + 'async', + 'await', + 'yield', + 'true', + 'false', + 'null', + 'undefined', + 'this', + 'new', + 'void', + 'string', + 'number', + 'boolean', + 'any', + 'never', ], 'json' => [], 'yaml' => ['true', 'false', 'null'], 'python' => [ - 'import', 'from', 'as', 'class', 'def', 'lambda', - 'if', 'elif', 'else', 'for', 'while', 'break', 'continue', - 'return', 'yield', 'raise', 'try', 'except', 'finally', 'with', - 'True', 'False', 'None', 'self', 'and', 'or', 'not', 'in', 'is', - 'async', 'await', 'pass', 'global', 'nonlocal', + 'import', + 'from', + 'as', + 'class', + 'def', + 'lambda', + 'if', + 'elif', + 'else', + 'for', + 'while', + 'break', + 'continue', + 'return', + 'yield', + 'raise', + 'try', + 'except', + 'finally', + 'with', + 'True', + 'False', + 'None', + 'self', + 'and', + 'or', + 'not', + 'in', + 'is', + 'async', + 'await', + 'pass', + 'global', + 'nonlocal', ], 'go' => [ - 'package', 'import', 'func', 'type', 'struct', 'interface', - 'var', 'const', 'map', 'chan', 'range', - 'if', 'else', 'for', 'switch', 'case', 'default', 'select', - 'break', 'continue', 'return', 'go', 'defer', 'fallthrough', - 'true', 'false', 'nil', 'iota', + 'package', + 'import', + 'func', + 'type', + 'struct', + 'interface', + 'var', + 'const', + 'map', + 'chan', + 'range', + 'if', + 'else', + 'for', + 'switch', + 'case', + 'default', + 'select', + 'break', + 'continue', + 'return', + 'go', + 'defer', + 'fallthrough', + 'true', + 'false', + 'nil', + 'iota', ], 'rust' => [ - 'use', 'mod', 'pub', 'fn', 'struct', 'enum', 'trait', 'impl', - 'let', 'mut', 'const', 'static', 'type', 'where', - 'if', 'else', 'for', 'while', 'loop', 'match', - 'break', 'continue', 'return', 'async', 'await', 'move', - 'true', 'false', 'self', 'Self', 'super', 'crate', + 'use', + 'mod', + 'pub', + 'fn', + 'struct', + 'enum', + 'trait', + 'impl', + 'let', + 'mut', + 'const', + 'static', + 'type', + 'where', + 'if', + 'else', + 'for', + 'while', + 'loop', + 'match', + 'break', + 'continue', + 'return', + 'async', + 'await', + 'move', + 'true', + 'false', + 'self', + 'Self', + 'super', + 'crate', ], _ => [], }; diff --git a/lib/ui/pages/gateway_chat_page.dart b/lib/ui/pages/gateway_chat_page.dart new file mode 100644 index 0000000..5615350 --- /dev/null +++ b/lib/ui/pages/gateway_chat_page.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../state/gateway_providers.dart'; +import '../widgets/agent_badge.dart'; +import '../widgets/message_bubble.dart'; +import '../widgets/session_status_chip.dart'; +import 'gateway_ui_adapters.dart'; + +class GatewayChatPage extends ConsumerStatefulWidget { + const GatewayChatPage({ + super.key, + required this.session, + required this.project, + this.agent, + }); + + final GatewaySessionView session; + final GatewayProjectView project; + final GatewayAgentView? agent; + + @override + ConsumerState createState() => _GatewayChatPageState(); +} + +class _GatewayChatPageState extends ConsumerState { + final _input = TextEditingController(); + final _focus = FocusNode(); + final _scroll = ScrollController(); + bool _showCommands = false; + + @override + void initState() { + super.initState(); + _input.addListener(_onInputChanged); + } + + @override + void dispose() { + _input.removeListener(_onInputChanged); + _input.dispose(); + _focus.dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _onInputChanged() { + final shouldShow = _input.text.startsWith('/'); + if (shouldShow != _showCommands) { + setState(() => _showCommands = shouldShow); + } + } + + @override + Widget build(BuildContext context) { + final chatState = ref.watch(gatewayChatProvider(widget.session.id)); + final messages = chatState.orderedMessages.toList(growable: false); + final status = _statusFromState(chatState); + final agent = + widget.agent ?? _agentFromCatalog(ref, widget.session.agentId); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + + return Scaffold( + appBar: AppBar( + title: Text(widget.session.title), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(54), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Column( + children: [ + Row( + children: [ + AgentBadge( + agentId: widget.session.agentId, + label: agent?.displayName, + compact: true, + ), + const SizedBox(width: 8), + SessionStatusChip(status: status, compact: true), + if (widget.session.modelId?.isNotEmpty == true) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + widget.session.modelId!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ] else + const Spacer(), + ], + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: Text( + widget.project.directory, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + body: Column( + children: [ + Expanded( + child: messages.isEmpty + ? const _EmptyChat() + : ListView.builder( + controller: _scroll, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: messages.length, + itemBuilder: (_, index) => + MessageBubble(message: messages[index]), + ), + ), + if (_showCommands) + FutureBuilder>( + future: _commandsFuture(agent), + builder: (context, snapshot) { + final commands = _filteredCommands(snapshot.data ?? const []); + if (snapshot.connectionState == ConnectionState.waiting && + commands.isEmpty) { + return const SizedBox.shrink(); + } + if (commands.isEmpty) return const SizedBox.shrink(); + return _CommandSuggestions( + commands: commands, + onSelected: (command) { + _input.text = '${command.name} '; + _input.selection = + TextSelection.collapsed(offset: _input.text.length); + _focus.requestFocus(); + }, + ); + }, + ), + _InputBar( + controller: _input, + focusNode: _focus, + running: status == 'running', + onSend: _send, + onAbort: _abort, + ), + ], + ), + ); + } + + String _statusFromState(dynamic state) { + if (state.isStreaming == true) return 'running'; + final session = state.session; + if (session != null) { + final status = session.status.wireName as String; + if (status != 'unknown') return status; + } + return widget.session.status; + } + + List _filteredCommands( + List commands, + ) { + final query = _input.text.trim(); + if (query == '/') return commands; + return commands.where((c) => c.name.startsWith(query)).toList(); + } + + Future> _commandsFuture( + GatewayAgentView? agent, + ) async { + final local = agent?.commands ?? const []; + if (local.isNotEmpty) return local; + if (agent == null) return const []; + final notifier = ref.read(agentCatalogProvider.notifier); + final commands = await notifier.commandsFor(agent.id); + final fromGateway = commands + .map( + (command) => GatewayCommandView( + name: command.name, + description: command.description, + ), + ) + .toList(growable: false); + return fromGateway.isNotEmpty ? fromGateway : _fallbackCommands(agent.id); + } + + Future _send() async { + final text = _input.text.trim(); + if (text.isEmpty) return; + _input.clear(); + try { + final notifier = + ref.read(gatewayChatProvider(widget.session.id).notifier); + if (text.startsWith('/')) { + await notifier.sendSlashCommand(text); + } else { + await notifier.sendMessage(text); + } + _scrollToBottom(); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Send failed: $err')), + ); + } + } + + Future _abort() async { + final notifier = ref.read(gatewayChatProvider(widget.session.id).notifier); + await notifier.abort(); + } + + void _scrollToBottom() { + if (!_scroll.hasClients) return; + final position = _scroll.position; + if (position.maxScrollExtent - position.pixels > 160) return; + _scroll.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + ); + } +} + +class _CommandSuggestions extends StatelessWidget { + const _CommandSuggestions({ + required this.commands, + required this.onSelected, + }); + + final List commands; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surfaceContainerHigh, + child: SafeArea( + top: false, + bottom: false, + child: SizedBox( + height: 112, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + scrollDirection: Axis.horizontal, + itemCount: commands.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, index) { + final command = commands[index]; + return SizedBox( + width: 220, + child: ActionChip( + avatar: const Icon(Icons.keyboard_command_key, size: 16), + label: Align( + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + command.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (command.description.isNotEmpty) + Text( + command.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + onPressed: () => onSelected(command), + ), + ); + }, + ), + ), + ), + ); + } +} + +class _InputBar extends StatelessWidget { + const _InputBar({ + required this.controller, + required this.focusNode, + required this.running, + required this.onSend, + required this.onAbort, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final bool running; + final Future Function() onSend; + final Future Function() onAbort; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + minLines: 1, + maxLines: 6, + decoration: InputDecoration( + hintText: 'Message or /command', + filled: true, + fillColor: theme.colorScheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: Icon(running ? Icons.stop : Icons.send), + tooltip: running ? 'Stop agent' : 'Send', + onPressed: running ? onAbort : onSend, + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyChat extends StatelessWidget { + const _EmptyChat(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'Start this agent session.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ); + } +} + +GatewayAgentView? _agentFromCatalog(WidgetRef ref, String agentId) { + final agents = readAgents(ref.watch(agentCatalogProvider)); + for (final agent in agents) { + if (agent.id == agentId) return agent; + } + return null; +} + +List _fallbackCommands(String agentId) { + final commands = switch (agentId) { + 'codex' => const [ + '/permissions', + '/model', + '/fast', + '/plan', + '/status', + '/stop', + ], + 'claude-code' => const [ + '/help', + '/clear', + '/compact', + '/model', + '/permissions', + '/status', + ], + 'opencode' => const [ + '/help', + '/new', + '/models', + '/compact', + '/undo', + '/redo', + ], + _ => const [], + }; + return commands + .map( + (command) => GatewayCommandView( + name: command, + description: '', + ), + ) + .toList(growable: false); +} diff --git a/lib/ui/pages/gateway_ui_adapters.dart b/lib/ui/pages/gateway_ui_adapters.dart new file mode 100644 index 0000000..c7045e4 --- /dev/null +++ b/lib/ui/pages/gateway_ui_adapters.dart @@ -0,0 +1,497 @@ +library; + +import 'package:flutter/foundation.dart'; + +import '../../models/message.dart'; +import '../../models/part.dart'; + +@immutable +class GatewayProjectView { + const GatewayProjectView({ + required this.id, + required this.name, + required this.directory, + required this.updatedAtMs, + }); + + final String id; + final String name; + final String directory; + final int updatedAtMs; +} + +@immutable +class GatewayAgentView { + const GatewayAgentView({ + required this.id, + required this.displayName, + required this.supportsModels, + required this.supportsSlashCommands, + required this.commands, + required this.models, + }); + + final String id; + final String displayName; + final bool supportsModels; + final bool supportsSlashCommands; + final List commands; + final List models; +} + +@immutable +class GatewayModelView { + const GatewayModelView({ + required this.id, + required this.displayName, + }); + + final String id; + final String displayName; +} + +@immutable +class GatewayCommandView { + const GatewayCommandView({ + required this.name, + required this.description, + }); + + final String name; + final String description; +} + +@immutable +class GatewaySessionView { + const GatewaySessionView({ + required this.id, + required this.projectId, + required this.directory, + required this.agentId, + required this.modelId, + required this.title, + required this.status, + required this.createdAtMs, + required this.updatedAtMs, + }); + + final String id; + final String projectId; + final String directory; + final String agentId; + final String? modelId; + final String title; + final String status; + final int createdAtMs; + final int updatedAtMs; +} + +@immutable +class GatewayMessageView { + const GatewayMessageView({ + required this.id, + required this.role, + required this.text, + required this.createdAtMs, + required this.streaming, + }); + + final String id; + final String role; + final String text; + final int createdAtMs; + final bool streaming; +} + +List readProjects(dynamic state) { + final items = _readList(state, ['projects', 'items', 'value']); + return items.map(readProject).where((p) => p.id.isNotEmpty).toList() + ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); +} + +GatewayProjectView readProject(dynamic value) { + final map = _asMap(value); + final directory = _string(map, value, ['directory', 'path']); + return GatewayProjectView( + id: _string(map, value, ['id', 'projectId']), + name: _string(map, value, ['name', 'title']) + .ifEmpty(_shortDirName(directory)), + directory: directory, + updatedAtMs: _int(map, value, ['updatedAt', 'updatedAtMs']), + ); +} + +List readAgents(dynamic state) { + final items = _readList(state, ['agents', 'items', 'value']); + return items.map(readAgent).where((a) => a.id.isNotEmpty).toList(); +} + +GatewayAgentView readAgent(dynamic value) { + final map = _asMap(value); + final commands = _readList(value, ['commands']) + .map(readCommand) + .where((c) => c.name.isNotEmpty) + .toList(); + final models = _readList(value, ['models']) + .map(readModel) + .where((m) => m.id.isNotEmpty) + .toList(); + final rawModels = _readList(_asMap(map['raw']), ['models']) + .map(readModel) + .where((m) => m.id.isNotEmpty) + .toList(); + final id = _string(map, value, ['id', 'agentId']); + return GatewayAgentView( + id: id, + displayName: + _string(map, value, ['displayName', 'name']).ifEmpty(_agentLabel(id)), + supportsModels: _bool( + map, + value, + ['supportsModels'], + fallback: models.isNotEmpty || rawModels.isNotEmpty, + ), + supportsSlashCommands: _bool( + map, + value, + ['supportsSlashCommands'], + fallback: commands.isNotEmpty, + ), + commands: commands, + models: models.isNotEmpty ? models : rawModels, + ); +} + +GatewayModelView readModel(dynamic value) { + final map = _asMap(value); + final id = _string(map, value, ['id', 'modelId']); + return GatewayModelView( + id: id, + displayName: _string(map, value, ['displayName', 'name']).ifEmpty(id), + ); +} + +GatewayCommandView readCommand(dynamic value) { + final map = _asMap(value); + var name = _string(map, value, ['name', 'id', 'command']); + if (name.isNotEmpty && !name.startsWith('/')) name = '/$name'; + return GatewayCommandView( + name: name, + description: _string(map, value, ['description', 'summary']), + ); +} + +List readSessions(dynamic state) { + final items = _readList(state, ['sessions', 'items', 'value']); + return items.map(readSession).where((s) => s.id.isNotEmpty).toList() + ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); +} + +GatewaySessionView readSession(dynamic value) { + try { + final nested = _property(value, 'session'); + if (nested != null) return readSession(nested); + } catch (_) {/* ignore */} + + final map = _asMap(value); + if (map.isEmpty) { + final nested = _property(value, 'session'); + if (nested != null) return readSession(nested); + } + final model = _asMap(map['model']); + final objectStatus = _property(value, 'status'); + final status = _string(map, null, ['status']) + .ifEmpty(_property(objectStatus, 'wireName')?.toString() ?? '') + .ifEmpty(_statusName(objectStatus)); + return GatewaySessionView( + id: _string(map, value, ['id', 'sessionId']), + projectId: _string(map, value, ['projectId']), + directory: _string(map, value, ['directory', 'path']), + agentId: _string(map, value, ['agentId', 'agent']).ifEmpty('opencode'), + modelId: + _string(map, value, ['modelId']).ifEmpty(_string(model, null, ['id'])), + title: _string(map, value, ['title']).ifEmpty('(untitled)'), + status: status.ifEmpty('idle'), + createdAtMs: _int(map, value, ['createdAt', 'createdAtMs']), + updatedAtMs: _int(map, value, ['updatedAt', 'updatedAtMs']), + ); +} + +List readMessages(dynamic state) { + var items = + _readList(state, ['orderedMessages', 'messages', 'items', 'value']); + if (items.isEmpty) { + final map = _asMap(_property(state, 'messages')); + if (map.isNotEmpty) items = map.values.toList(); + } + return items.map(readMessage).where((m) => m.text.isNotEmpty).toList(); +} + +GatewayMessageView readMessage(dynamic value) { + if (value is Message) { + final text = value.orderedParts + .whereType() + .map((part) => part.text) + .where((text) => text.isNotEmpty) + .join('\n'); + return GatewayMessageView( + id: value.id, + role: switch (value.role) { + MessageRole.user => 'user', + MessageRole.assistant => 'assistant', + MessageRole.system => 'system', + MessageRole.unknown => 'assistant', + }, + text: text, + createdAtMs: value.createdAtMs ?? 0, + streaming: value.status == MessageStatus.running, + ); + } + + final map = _asMap(value); + final partsText = _readPartsText(value); + return GatewayMessageView( + id: _string(map, value, ['id', 'messageId']), + role: _string(map, value, ['role', 'author']).ifEmpty('assistant'), + text: + _string(map, value, ['text', 'content', 'message']).ifEmpty(partsText), + createdAtMs: _int(map, value, ['createdAt', 'createdAtMs', 'timestamp']), + streaming: _bool(map, value, ['streaming', 'isStreaming']), + ); +} + +bool readLoading(dynamic state) => + _bool(_asMap(state), state, ['loading', 'isLoading']); + +String? readError(dynamic state) { + final map = _asMap(state); + final error = _string(map, state, ['error']); + return error.isEmpty ? null : error; +} + +bool readStreaming(dynamic state) => + _bool(_asMap(state), state, ['isStreaming', 'streaming']); + +Map _asMap(dynamic value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + return const {}; +} + +List _readList(dynamic value, List keys) { + if (value is List) return value; + if (value is Iterable) return value.toList(); + final map = _asMap(value); + for (final key in keys) { + final v = map[key]; + if (v is List) return v; + if (v is Iterable) return v.toList(); + if (v is Map) return v.values.toList(); + } + if (value is Map) return value.values.toList(); + for (final key in keys) { + try { + final v = _property(value, key); + if (v is List) return v; + if (v is Iterable) return v.toList(); + if (v is Map) return v.values.toList(); + } catch (_) {/* ignore */} + } + return const []; +} + +String _string(Map map, dynamic object, List keys) { + for (final key in keys) { + final value = map[key]; + if (value != null) return '$value'; + } + for (final key in keys) { + try { + final value = _property(object, key); + if (value != null) return '$value'; + } catch (_) {/* ignore */} + } + return ''; +} + +int _int(Map map, dynamic object, List keys) { + for (final key in keys) { + final value = map[key]; + final parsed = _parseInt(value); + if (parsed != null) return parsed; + } + final time = _asMap(map['time']); + for (final key in const ['updated', 'created']) { + final parsed = _parseInt(time[key]); + if (parsed != null) return parsed; + } + for (final key in keys) { + try { + final parsed = _parseInt(_property(object, key)); + if (parsed != null) return parsed; + } catch (_) {/* ignore */} + } + return 0; +} + +int? _parseInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is DateTime) return value.millisecondsSinceEpoch; + if (value is String) return int.tryParse(value); + return null; +} + +bool _bool( + Map map, + dynamic object, + List keys, { + bool fallback = false, +}) { + for (final key in keys) { + final value = map[key]; + if (value is bool) return value; + } + for (final key in keys) { + try { + final value = _property(object, key); + if (value is bool) return value; + } catch (_) {/* ignore */} + } + return fallback; +} + +dynamic _property(dynamic object, String name) { + if (object == null) return null; + switch (name) { + case 'id': + return (object as dynamic).id; + case 'projectId': + return (object as dynamic).projectId; + case 'sessionId': + return (object as dynamic).sessionId; + case 'agentId': + return (object as dynamic).agentId; + case 'modelId': + return (object as dynamic).modelId; + case 'name': + return (object as dynamic).name; + case 'displayName': + return (object as dynamic).displayName; + case 'title': + return (object as dynamic).title; + case 'directory': + return (object as dynamic).directory; + case 'path': + return (object as dynamic).path; + case 'updatedAt': + return (object as dynamic).updatedAt; + case 'updatedAtMs': + return (object as dynamic).updatedAtMs; + case 'createdAt': + return (object as dynamic).createdAt; + case 'createdAtMs': + return (object as dynamic).createdAtMs; + case 'status': + return (object as dynamic).status; + case 'agent': + return (object as dynamic).agent; + case 'model': + return (object as dynamic).model; + case 'wireName': + return (object as dynamic).wireName; + case 'commands': + return (object as dynamic).commands; + case 'models': + return (object as dynamic).models; + case 'supportsModels': + return (object as dynamic).supportsModels; + case 'supportsSlashCommands': + return (object as dynamic).supportsSlashCommands; + case 'description': + return (object as dynamic).description; + case 'summary': + return (object as dynamic).summary; + case 'command': + return (object as dynamic).command; + case 'messages': + return (object as dynamic).messages; + case 'orderedMessages': + return (object as dynamic).orderedMessages; + case 'items': + return (object as dynamic).items; + case 'projects': + return (object as dynamic).projects; + case 'agents': + return (object as dynamic).agents; + case 'sessions': + return (object as dynamic).sessions; + case 'session': + return (object as dynamic).session; + case 'loading': + return (object as dynamic).loading; + case 'isLoading': + return (object as dynamic).isLoading; + case 'error': + return (object as dynamic).error; + case 'role': + return (object as dynamic).role; + case 'author': + return (object as dynamic).author; + case 'text': + return (object as dynamic).text; + case 'content': + return (object as dynamic).content; + case 'message': + return (object as dynamic).message; + case 'timestamp': + return (object as dynamic).timestamp; + case 'streaming': + return (object as dynamic).streaming; + case 'isStreaming': + return (object as dynamic).isStreaming; + } + return null; +} + +String _readPartsText(dynamic value) { + final parts = _readList(value, ['orderedParts', 'parts']); + final buffer = StringBuffer(); + for (final part in parts) { + final map = _asMap(part); + final text = _string(map, part, ['text']); + if (text.isEmpty) continue; + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(text); + } + return buffer.toString(); +} + +String _agentLabel(String id) { + switch (id) { + case 'codex': + return 'Codex'; + case 'claude-code': + return 'Claude Code'; + case 'opencode': + return 'OpenCode'; + default: + return id; + } +} + +String _statusName(Object? value) { + if (value == null) return ''; + final raw = value.toString(); + final dot = raw.lastIndexOf('.'); + return dot == -1 ? raw : raw.substring(dot + 1); +} + +String _shortDirName(String path) { + final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty); + return parts.isEmpty ? path : parts.last; +} + +extension GatewayStringX on String { + String ifEmpty(String fallback) => isEmpty ? fallback : this; +} diff --git a/lib/ui/pages/git_page.dart b/lib/ui/pages/git_page.dart index 12c49a0..c3e4bbb 100644 --- a/lib/ui/pages/git_page.dart +++ b/lib/ui/pages/git_page.dart @@ -10,7 +10,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../api/git_client.dart'; import '../../state/codex_thread_store.dart'; -import '../../state/providers.dart'; import '../../state/settings_store.dart'; // --------------------------------------------------------------------------- @@ -322,8 +321,11 @@ class _DirectoryHeader extends StatelessWidget { color: theme.colorScheme.surfaceContainerLow, child: Row( children: [ - Icon(Icons.folder_outlined, - size: 18, color: theme.colorScheme.primary), + Icon( + Icons.folder_outlined, + size: 18, + color: theme.colorScheme.primary, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -416,10 +418,8 @@ class _StatusCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final lines = statusOutput - .split('\n') - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = + statusOutput.split('\n').where((l) => l.trim().isNotEmpty).toList(); return Card( elevation: 0, @@ -434,7 +434,11 @@ class _StatusCard extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.list_alt, size: 20, color: theme.colorScheme.primary), + Icon( + Icons.list_alt, + size: 20, + color: theme.colorScheme.primary, + ), const SizedBox(width: 8), Text('Status', style: theme.textTheme.titleSmall), const Spacer(), @@ -485,7 +489,8 @@ class _StatusLine extends StatelessWidget { final statusCode = line.length >= 2 ? line.substring(0, 2) : '??'; final fileName = line.length > 3 ? line.substring(3) : line; - final (Color color, IconData icon, String label) = switch (statusCode.trim()) { + final (Color color, IconData icon, String label) = + switch (statusCode.trim()) { 'M' || 'MM' => (Colors.orange, Icons.edit_outlined, 'Modified'), 'A' || 'AM' => (Colors.green, Icons.add_circle_outline, 'Added'), 'D' => (Colors.red, Icons.remove_circle_outline, 'Deleted'), @@ -505,7 +510,7 @@ class _StatusLine extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -559,8 +564,11 @@ class _DiffCard extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.difference_outlined, - size: 20, color: theme.colorScheme.primary), + Icon( + Icons.difference_outlined, + size: 20, + color: theme.colorScheme.primary, + ), const SizedBox(width: 8), Text('Diff', style: theme.textTheme.titleSmall), ], @@ -591,9 +599,8 @@ class _DiffCard extends StatelessWidget { padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: lines - .map((line) => _DiffLine(line: line)) - .toList(), + children: + lines.map((line) => _DiffLine(line: line)).toList(), ), ), ), @@ -638,26 +645,32 @@ class _DiffLine extends StatelessWidget { if (line.startsWith('+++') || line.startsWith('---')) { // File header lines return ( - isDark ? Colors.blue.shade900.withOpacity(0.3) : Colors.blue.shade50, + isDark + ? Colors.blue.shade900.withValues(alpha: 0.3) + : Colors.blue.shade50, isDark ? Colors.blue.shade200 : Colors.blue.shade900, ); } if (line.startsWith('+')) { return ( - isDark ? Colors.green.shade900.withOpacity(0.3) : Colors.green.shade50, + isDark + ? Colors.green.shade900.withValues(alpha: 0.3) + : Colors.green.shade50, isDark ? Colors.green.shade300 : Colors.green.shade900, ); } if (line.startsWith('-')) { return ( - isDark ? Colors.red.shade900.withOpacity(0.3) : Colors.red.shade50, + isDark + ? Colors.red.shade900.withValues(alpha: 0.3) + : Colors.red.shade50, isDark ? Colors.red.shade300 : Colors.red.shade900, ); } if (line.startsWith('@@')) { return ( isDark - ? Colors.purple.shade900.withOpacity(0.2) + ? Colors.purple.shade900.withValues(alpha: 0.2) : Colors.purple.shade50, isDark ? Colors.purple.shade200 : Colors.purple.shade700, ); diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 6595d32..d4af43a 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -20,9 +20,9 @@ class _HomePageState extends State { static const _destinations = [ NavigationDestination( - icon: Icon(Icons.chat_bubble_outline), - selectedIcon: Icon(Icons.chat_bubble), - label: 'Chat', + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: 'Projects', ), NavigationDestination( icon: Icon(Icons.difference_outlined), diff --git a/lib/ui/pages/project_detail_page.dart b/lib/ui/pages/project_detail_page.dart new file mode 100644 index 0000000..4dd4863 --- /dev/null +++ b/lib/ui/pages/project_detail_page.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../state/gateway_providers.dart'; +import '../widgets/agent_badge.dart'; +import '../widgets/session_status_chip.dart'; +import 'agent_group_page.dart'; +import 'gateway_chat_page.dart'; +import 'gateway_ui_adapters.dart'; + +class ProjectDetailPage extends ConsumerWidget { + const ProjectDetailPage({ + super.key, + required this.project, + }); + + final GatewayProjectView project; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gatewaySessionListProvider(project.id)); + final sessions = readSessions(state); + final grouped = _groupSessions(sessions); + + return Scaffold( + appBar: AppBar( + title: Text(project.name), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + project.directory, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + body: RefreshIndicator( + onRefresh: () => _refresh(ref), + child: _SessionGroups(grouped: grouped, project: project), + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add_comment_outlined), + label: const Text('New conversation'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AgentGroupPage(project: project), + ), + ), + ), + ); + } + + Future _refresh(WidgetRef ref) async { + final notifier = ref.read(gatewaySessionListProvider(project.id).notifier); + await notifier.refresh(); + } +} + +class _SessionGroups extends StatelessWidget { + const _SessionGroups({ + required this.grouped, + required this.project, + }); + + final Map>> grouped; + final GatewayProjectView project; + + @override + Widget build(BuildContext context) { + if (grouped.isEmpty) { + return ListView( + padding: const EdgeInsets.all(32), + children: [ + Icon( + Icons.chat_bubble_outline, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No conversations in this project.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ); + } + + final agents = grouped.keys.toList()..sort(); + return ListView.builder( + padding: const EdgeInsets.only(bottom: 88), + itemCount: agents.length, + itemBuilder: (context, agentIndex) { + final agentId = agents[agentIndex]; + final models = grouped[agentId]!; + return _AgentSection( + project: project, + agentId: agentId, + modelGroups: models, + ); + }, + ); + } +} + +class _AgentSection extends StatelessWidget { + const _AgentSection({ + required this.project, + required this.agentId, + required this.modelGroups, + }); + + final GatewayProjectView project; + final String agentId; + final Map> modelGroups; + + @override + Widget build(BuildContext context) { + final modelIds = modelGroups.keys.toList()..sort(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AgentBadge(agentId: agentId), + const SizedBox(height: 8), + for (final modelId in modelIds) + _ModelSection( + project: project, + modelId: modelId, + sessions: modelGroups[modelId]!, + ), + ], + ), + ); + } +} + +class _ModelSection extends StatelessWidget { + const _ModelSection({ + required this.project, + required this.modelId, + required this.sessions, + }); + + final GatewayProjectView project; + final String modelId; + final List sessions; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + tilePadding: EdgeInsets.zero, + initiallyExpanded: true, + title: Text(modelId == '_default' ? 'Default model' : modelId), + children: [ + for (final session in sessions) + ListTile( + contentPadding: const EdgeInsets.only(left: 8, right: 0), + title: Text( + session.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(_relativeTime(session.updatedAtMs)), + trailing: SessionStatusChip(status: session.status, compact: true), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GatewayChatPage( + session: session, + project: project, + ), + ), + ), + ), + ], + ); + } +} + +Map>> _groupSessions( + List sessions, +) { + final grouped = >>{}; + for (final session in sessions) { + final models = grouped.putIfAbsent(session.agentId, () => {}); + final modelId = + session.modelId?.isNotEmpty == true ? session.modelId! : '_default'; + models.putIfAbsent(modelId, () => []).add(session); + } + return grouped; +} + +String _relativeTime(int ms) { + if (ms == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return DateFormat.yMd().format(dt); +} diff --git a/lib/ui/pages/project_list_page.dart b/lib/ui/pages/project_list_page.dart new file mode 100644 index 0000000..421da33 --- /dev/null +++ b/lib/ui/pages/project_list_page.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../state/gateway_providers.dart'; +import '../../state/settings_store.dart'; +import '../widgets/directory_picker.dart'; +import 'gateway_ui_adapters.dart'; +import 'project_detail_page.dart'; + +class ProjectListPage extends ConsumerWidget { + const ProjectListPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(projectStoreProvider); + final projects = readProjects(state); + final loading = readLoading(state); + final error = readError(state); + + return Scaffold( + appBar: AppBar( + title: const Text('Projects'), + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh projects', + onPressed: () => _refresh(ref), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () => _refresh(ref), + child: _ProjectListBody( + projects: projects, + loading: loading, + error: error, + ), + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.create_new_folder_outlined), + label: const Text('Add project'), + onPressed: () => _addProject(context, ref), + ), + ); + } + + Future _refresh(WidgetRef ref) async { + final notifier = ref.read(projectStoreProvider.notifier); + await notifier.refresh(); + } + + Future _addProject(BuildContext context, WidgetRef ref) async { + final settings = ref.read(settingsControllerProvider); + final gatewayUrl = 'http://${Uri.parse(settings.baseUrl).host}:8787'; + final directory = await showDirectoryPicker( + context, + qqbotBaseUrl: gatewayUrl, + bearerToken: settings.bearerToken, + initialPath: 'D:\\', + ); + if (directory == null || !context.mounted) return; + + try { + final notifier = ref.read(projectStoreProvider.notifier); + final created = await notifier.addProject(directory); + if (!context.mounted) return; + final project = readProject(created); + if (project.id.isNotEmpty) { + _openProject(context, project); + } + } catch (err) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not add project: $err')), + ); + } + } +} + +class _ProjectListBody extends StatelessWidget { + const _ProjectListBody({ + required this.projects, + required this.loading, + required this.error, + }); + + final List projects; + final bool loading; + final String? error; + + @override + Widget build(BuildContext context) { + if (loading && projects.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (error != null && projects.isEmpty) { + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Projects unavailable', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ); + } + if (projects.isEmpty) { + return ListView( + padding: const EdgeInsets.all(32), + children: [ + Icon( + Icons.folder_open, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No projects yet.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ); + } + return ListView.separated( + padding: const EdgeInsets.only(bottom: 88), + itemCount: projects.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (context, index) => _ProjectTile(project: projects[index]), + ); + } +} + +class _ProjectTile extends StatelessWidget { + const _ProjectTile({required this.project}); + + final GatewayProjectView project; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + child: const Icon(Icons.folder_outlined), + ), + title: Text( + project.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${project.directory}\n${_relativeTime(project.updatedAtMs)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () => _openProject(context, project), + ); + } +} + +void _openProject(BuildContext context, GatewayProjectView project) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProjectDetailPage(project: project), + ), + ); +} + +String _relativeTime(int ms) { + if (ms == 0) return 'not synced'; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return DateFormat.yMd().format(dt); +} diff --git a/lib/ui/pages/session_list_page.dart b/lib/ui/pages/session_list_page.dart index e7f6b2f..cd790ee 100644 --- a/lib/ui/pages/session_list_page.dart +++ b/lib/ui/pages/session_list_page.dart @@ -61,8 +61,7 @@ class SessionListPage extends ConsumerWidget { SessionListController controller, ) async { final settings = ref.read(settingsControllerProvider); - final qqbotUrl = - 'http://${Uri.parse(settings.baseUrl).host}:8787'; + final qqbotUrl = 'http://${Uri.parse(settings.baseUrl).host}:8787'; final selectedDir = await showDirectoryPicker( context, @@ -156,11 +155,14 @@ class _SessionTile extends ConsumerWidget { }, ), ListTile( - leading: Icon(Icons.delete_outline, - color: Theme.of(context).colorScheme.error), - title: Text('Delete', - style: TextStyle( - color: Theme.of(context).colorScheme.error)), + leading: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + 'Delete', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), onTap: () { Navigator.pop(ctx); _confirmDelete(context, ref); @@ -203,7 +205,8 @@ class _SessionTile extends ConsumerWidget { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Rename failed: $e'))); + SnackBar(content: Text('Rename failed: $e')), + ); } } }, @@ -220,7 +223,8 @@ class _SessionTile extends ConsumerWidget { builder: (ctx) => AlertDialog( title: const Text('Delete session?'), content: Text( - 'This will permanently delete "${session.title.isEmpty ? "(untitled)" : session.title}".'), + 'This will permanently delete "${session.title.isEmpty ? "(untitled)" : session.title}".', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), @@ -239,7 +243,8 @@ class _SessionTile extends ConsumerWidget { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Delete failed: $e'))); + SnackBar(content: Text('Delete failed: $e')), + ); } } }, @@ -276,8 +281,10 @@ class _ErrorView extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - Text(error, - style: TextStyle(color: Theme.of(context).colorScheme.error)), + Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), const SizedBox(height: 16), const Text('Pull to retry, or check Settings ›'), ], diff --git a/lib/ui/widgets/agent_badge.dart b/lib/ui/widgets/agent_badge.dart new file mode 100644 index 0000000..a924269 --- /dev/null +++ b/lib/ui/widgets/agent_badge.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +class AgentBadge extends StatelessWidget { + const AgentBadge({ + super.key, + required this.agentId, + this.label, + this.compact = false, + }); + + final String agentId; + final String? label; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final spec = _AgentSpec.forId(agentId); + final text = label?.trim().isNotEmpty == true ? label!.trim() : spec.label; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 10, + vertical: compact ? 4 : 6, + ), + decoration: BoxDecoration( + color: spec.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: spec.color.withValues(alpha: 0.28)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(spec.icon, size: compact ? 14 : 16, color: spec.color), + const SizedBox(width: 6), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: spec.color, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} + +class _AgentSpec { + const _AgentSpec({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; + + static _AgentSpec forId(String id) { + switch (id) { + case 'codex': + return const _AgentSpec( + label: 'Codex', + icon: Icons.terminal, + color: Color(0xFF2E7D32), + ); + case 'claude-code': + return const _AgentSpec( + label: 'Claude Code', + icon: Icons.auto_awesome, + color: Color(0xFFB05A2A), + ); + case 'opencode': + return const _AgentSpec( + label: 'OpenCode', + icon: Icons.code, + color: Color(0xFF1565C0), + ); + default: + return const _AgentSpec( + label: 'Agent', + icon: Icons.smart_toy_outlined, + color: Color(0xFF546E7A), + ); + } + } +} diff --git a/lib/ui/widgets/attachment_picker.dart b/lib/ui/widgets/attachment_picker.dart index 4f0b4fa..8dc4cea 100644 --- a/lib/ui/widgets/attachment_picker.dart +++ b/lib/ui/widgets/attachment_picker.dart @@ -79,7 +79,9 @@ class _AttachmentPickerSheet extends StatelessWidget { width: 40, height: 4, decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant.withOpacity(0.4), + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -161,21 +163,25 @@ class _AttachmentPickerSheet extends StatelessWidget { final attachments = []; for (final file in result.files) { if (file.bytes != null) { - attachments.add(Attachment( - fileName: file.name, - mimeType: _guessMimeType(file.name, file.extension), - base64Data: base64Encode(file.bytes!), - bytes: file.bytes, - )); + attachments.add( + Attachment( + fileName: file.name, + mimeType: _guessMimeType(file.name, file.extension), + base64Data: base64Encode(file.bytes!), + bytes: file.bytes, + ), + ); } else if (file.path != null) { // On mobile, read from path final bytes = await File(file.path!).readAsBytes(); - attachments.add(Attachment( - fileName: file.name, - mimeType: _guessMimeType(file.name, file.extension), - base64Data: base64Encode(bytes), - bytes: bytes, - )); + attachments.add( + Attachment( + fileName: file.name, + mimeType: _guessMimeType(file.name, file.extension), + base64Data: base64Encode(bytes), + bytes: bytes, + ), + ); } } if (attachments.isNotEmpty && context.mounted) { @@ -260,7 +266,6 @@ class AttachmentPreviewStrip extends StatelessWidget { Widget build(BuildContext context) { if (attachments.isEmpty) return const SizedBox.shrink(); - final theme = Theme.of(context); return Container( height: 80, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), diff --git a/lib/ui/widgets/connection_chip.dart b/lib/ui/widgets/connection_chip.dart index 3c88271..5a628ce 100644 --- a/lib/ui/widgets/connection_chip.dart +++ b/lib/ui/widgets/connection_chip.dart @@ -9,12 +9,9 @@ class ConnectionChip extends StatelessWidget { @override Widget build(BuildContext context) { final (label, color, icon) = switch (state) { - SseState.connected => - ('Live', Colors.green, Icons.circle), - SseState.connecting => - ('Connecting', Colors.orange, Icons.sync), - SseState.disconnected => - ('Offline', Colors.red, Icons.cloud_off), + SseState.connected => ('Live', Colors.green, Icons.circle), + SseState.connecting => ('Connecting', Colors.orange, Icons.sync), + SseState.disconnected => ('Offline', Colors.red, Icons.cloud_off), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), @@ -29,7 +26,11 @@ class ConnectionChip extends StatelessWidget { const SizedBox(width: 6), Text( label, - style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ], ), diff --git a/lib/ui/widgets/directory_picker.dart b/lib/ui/widgets/directory_picker.dart index 2857240..f8c454f 100644 --- a/lib/ui/widgets/directory_picker.dart +++ b/lib/ui/widgets/directory_picker.dart @@ -60,15 +60,17 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { void initState() { super.initState(); _currentPath = widget.initialPath; - _dio = Dio(BaseOptions( - baseUrl: widget.qqbotBaseUrl.replaceAll(RegExp(r'/$'), ''), - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: { - if (widget.bearerToken.isNotEmpty) - 'Authorization': 'Bearer ${widget.bearerToken}', - }, - )); + _dio = Dio( + BaseOptions( + baseUrl: widget.qqbotBaseUrl.replaceAll(RegExp(r'/$'), ''), + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + if (widget.bearerToken.isNotEmpty) + 'Authorization': 'Bearer ${widget.bearerToken}', + }, + ), + ); _loadDirs(); } @@ -91,10 +93,12 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { ); final data = res.data ?? {}; final dirs = (data['dirs'] as List?) - ?.map((d) => _DirEntry( - name: (d as Map)['name'] as String? ?? '', - path: d['path'] as String? ?? '', - )) + ?.map( + (d) => _DirEntry( + name: (d as Map)['name'] as String? ?? '', + path: d['path'] as String? ?? '', + ), + ) .toList() ?? []; if (!mounted) return; @@ -218,7 +222,10 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { decoration: InputDecoration( hintText: 'New folder name', isDense: true, - prefixIcon: const Icon(Icons.create_new_folder_outlined, size: 20), + prefixIcon: const Icon( + Icons.create_new_folder_outlined, + size: 20, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -273,10 +280,12 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { Widget _buildList(ThemeData theme) { if (_loading) { - return const Center(child: Padding( - padding: EdgeInsets.all(24), - child: CircularProgressIndicator(), - )); + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ); } if (_error != null) { return Center( diff --git a/lib/ui/widgets/message_bubble.dart b/lib/ui/widgets/message_bubble.dart index d6e9b88..90af10e 100644 --- a/lib/ui/widgets/message_bubble.dart +++ b/lib/ui/widgets/message_bubble.dart @@ -40,7 +40,8 @@ class MessageBubble extends StatelessWidget { children: [ Container( constraints: const BoxConstraints(maxWidth: 720), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: isUser ? Theme.of(context).colorScheme.primaryContainer diff --git a/lib/ui/widgets/model_picker.dart b/lib/ui/widgets/model_picker.dart index 3c9e016..0b10230 100644 --- a/lib/ui/widgets/model_picker.dart +++ b/lib/ui/widgets/model_picker.dart @@ -58,8 +58,7 @@ class _ModelPickerSheetState extends State<_ModelPickerSheet> { final q = _query.text.trim().toLowerCase(); if (q.isEmpty) return widget.models; return widget.models.where((m) { - final hay = - '${m.providerId} ${m.modelId} ${m.label}'.toLowerCase(); + final hay = '${m.providerId} ${m.modelId} ${m.label}'.toLowerCase(); return hay.contains(q); }).toList(growable: false); } diff --git a/lib/ui/widgets/parts/image_part_view.dart b/lib/ui/widgets/parts/image_part_view.dart index e052408..c2615a5 100644 --- a/lib/ui/widgets/parts/image_part_view.dart +++ b/lib/ui/widgets/parts/image_part_view.dart @@ -57,8 +57,7 @@ class ImagePartView extends ConsumerWidget { color: Theme.of(context).colorScheme.surfaceContainerHighest, child: CircularProgressIndicator( value: progress.expectedTotalBytes != null - ? progress.cumulativeBytesLoaded / - progress.expectedTotalBytes! + ? progress.cumulativeBytesLoaded / progress.expectedTotalBytes! : null, strokeWidth: 2, ), @@ -120,7 +119,7 @@ class ImagePartView extends ConsumerWidget { void _openFullScreen(BuildContext context, WidgetRef ref) { Navigator.of(context).push( - MaterialPageRoute( + MaterialPageRoute( builder: (_) => _FullScreenImagePage( part: part, imageUrl: part.isDataUrl ? null : _resolveUrl(ref), diff --git a/lib/ui/widgets/parts/text_part_view.dart b/lib/ui/widgets/parts/text_part_view.dart index 9a4ad31..2072527 100644 --- a/lib/ui/widgets/parts/text_part_view.dart +++ b/lib/ui/widgets/parts/text_part_view.dart @@ -50,7 +50,7 @@ class TextPartView extends StatelessWidget { blockquoteDecoration: BoxDecoration( border: Border( left: BorderSide( - color: theme.colorScheme.primary.withOpacity(0.5), + color: theme.colorScheme.primary.withValues(alpha: 0.5), width: 3, ), ), @@ -97,8 +97,10 @@ class TextPartView extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.broken_image, - color: Theme.of(context).colorScheme.onErrorContainer), + Icon( + Icons.broken_image, + color: Theme.of(context).colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Flexible( child: Text( @@ -176,7 +178,7 @@ class _CodeBlockWidget extends StatelessWidget { color: isDark ? const Color(0xFF282C34) : const Color(0xFFF8F8F8), borderRadius: BorderRadius.circular(8), border: Border.all( - color: theme.colorScheme.outlineVariant.withOpacity(0.3), + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3), ), ), child: Column( @@ -186,7 +188,9 @@ class _CodeBlockWidget extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), ), diff --git a/lib/ui/widgets/parts/tool_part_view.dart b/lib/ui/widgets/parts/tool_part_view.dart index fc54fe0..36da7f8 100644 --- a/lib/ui/widgets/parts/tool_part_view.dart +++ b/lib/ui/widgets/parts/tool_part_view.dart @@ -136,7 +136,8 @@ class _ToolPartViewState extends State { _Section( title: 'Input', child: _CodeBlock( - text: const JsonEncoder.withIndent(' ').convert(widget.part.input), + text: const JsonEncoder.withIndent(' ') + .convert(widget.part.input), ), ), if (widget.part.output != null) diff --git a/lib/ui/widgets/session_status_chip.dart b/lib/ui/widgets/session_status_chip.dart new file mode 100644 index 0000000..21b3d1c --- /dev/null +++ b/lib/ui/widgets/session_status_chip.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +class SessionStatusChip extends StatelessWidget { + const SessionStatusChip({ + super.key, + required this.status, + this.compact = false, + }); + + final String status; + final bool compact; + + @override + Widget build(BuildContext context) { + final normalized = status.trim().isEmpty ? 'idle' : status.trim(); + final spec = _StatusSpec.forStatus(normalized, Theme.of(context)); + + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 7 : 9, + vertical: compact ? 3 : 5, + ), + decoration: BoxDecoration( + color: spec.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(spec.icon, size: compact ? 12 : 14, color: spec.color), + const SizedBox(width: 5), + Text( + spec.label, + style: TextStyle( + color: spec.color, + fontSize: compact ? 11 : 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _StatusSpec { + const _StatusSpec({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; + + static _StatusSpec forStatus(String status, ThemeData theme) { + switch (status) { + case 'running': + return _StatusSpec( + label: 'Running', + icon: Icons.play_arrow, + color: theme.colorScheme.primary, + ); + case 'waiting-for-approval': + return const _StatusSpec( + label: 'Approval', + icon: Icons.rule, + color: Color(0xFF8A5B00), + ); + case 'error': + return _StatusSpec( + label: 'Error', + icon: Icons.error_outline, + color: theme.colorScheme.error, + ); + case 'completed': + return const _StatusSpec( + label: 'Done', + icon: Icons.check_circle_outline, + color: Color(0xFF2E7D32), + ); + case 'idle': + default: + return _StatusSpec( + label: 'Idle', + icon: Icons.circle_outlined, + color: theme.colorScheme.onSurfaceVariant, + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 93b1c47..bc96eed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,26 +37,26 @@ packages: dependency: transitive description: name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.11.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" build: dependency: transitive description: name: build - sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.4.2" build_config: dependency: transitive description: @@ -69,34 +69,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "8.0.0" built_collection: dependency: transitive description: @@ -117,18 +117,18 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.3.0" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.0.3" ci: dependency: transitive description: @@ -149,34 +149,26 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf url: "https://pub.dev" source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.1.1" code_builder: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.10.1" collection: dependency: "direct main" description: name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.19.1" + version: "1.19.0" convert: dependency: transitive description: @@ -189,10 +181,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.4+2" crypto: dependency: transitive description: @@ -205,10 +197,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.9" + version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -245,10 +237,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.0.1" dio: dependency: "direct main" description: @@ -269,18 +261,18 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.1" ffi: dependency: transitive description: name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.3" file: dependency: transitive description: @@ -301,34 +293,34 @@ packages: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.3+2" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.4+3" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.6.2" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+4" fixnum: dependency: transitive description: @@ -362,18 +354,18 @@ packages: dependency: "direct main" description: name: flutter_markdown_plus - sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + sha256: fe74214c5ac2f850d93efda290dcde3f18006e90a87caa9e3e6c13222a5db4de url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.3" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.29" flutter_riverpod: dependency: "direct main" description: @@ -386,10 +378,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -436,10 +428,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.0" graphs: dependency: transitive description: @@ -456,14 +448,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.dev" - source: hosted - version: "1.0.3" hotreloader: dependency: transitive description: @@ -500,34 +484,34 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c url: "https://pub.dev" source: hosted - version: "0.8.13+17" + version: "0.8.13" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.0" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e url: "https://pub.dev" source: hosted - version: "0.8.13+6" + version: "0.8.13" image_picker_linux: dependency: transitive description: @@ -540,18 +524,18 @@ packages: dependency: transitive description: name: image_picker_macos - sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 url: "https://pub.dev" source: hosted - version: "0.2.2+1" + version: "0.2.2" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.11.0" image_picker_windows: dependency: transitive description: @@ -576,30 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" js: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -620,26 +588,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -660,34 +628,34 @@ packages: dependency: "direct main" description: name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.15.0" mime: dependency: transitive description: @@ -696,22 +664,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" package_config: dependency: transitive description: @@ -724,10 +676,10 @@ packages: dependency: transitive description: name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.9.0" path_parsing: dependency: transitive description: @@ -748,18 +700,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -788,10 +740,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "6.0.2" platform: dependency: transitive description: @@ -832,14 +784,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" riverpod: dependency: transitive description: @@ -892,26 +836,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.11" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: @@ -924,10 +868,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: @@ -985,18 +929,18 @@ packages: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.0" state_notifier: dependency: transitive description: @@ -1009,10 +953,10 @@ packages: dependency: transitive description: name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1025,26 +969,26 @@ packages: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.3.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.3" timing: dependency: transitive description: @@ -1073,34 +1017,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" url: "https://pub.dev" source: hosted - version: "6.3.29" + version: "6.3.17" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.3.3" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: @@ -1113,18 +1057,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.4" uuid: dependency: transitive description: @@ -1137,10 +1081,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: @@ -1153,26 +1097,26 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" + sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.1.18" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "14.3.0" watcher: dependency: transitive description: @@ -1209,10 +1153,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "5.10.1" xdg_directories: dependency: transitive description: @@ -1225,10 +1169,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "6.5.0" yaml: dependency: transitive description: @@ -1238,5 +1182,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.1" diff --git a/pubspec.yaml b/pubspec.yaml index 3559d20..106c524 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: json_annotation: ^4.9.0 # UI building blocks - flutter_markdown_plus: ^1.0.4 # flutter_markdown is discontinued; this is the maintained fork + flutter_markdown_plus: ^1.0.3 # flutter_markdown is discontinued; this is the maintained fork flutter_highlight: ^0.7.0 # Syntax highlight inside markdown code blocks flutter_svg: ^2.0.10 google_fonts: ^6.2.1 diff --git a/test/api/gateway_client_test.dart b/test/api/gateway_client_test.dart new file mode 100644 index 0000000..ad9421a --- /dev/null +++ b/test/api/gateway_client_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:remote_multi_agent/api/gateway_client.dart'; + +void main() { + group('GatewayClient.events', () { + test('parses SSE event/data envelope including multiline data', () async { + final client = GatewayClient( + baseUrl: Uri.parse('http://gateway.test'), + httpClient: _StreamingClient((request) async { + expect(request.url.path, '/sessions/s1/events'); + expect(request.headers['Accept'], 'text/event-stream'); + return http.StreamedResponse( + Stream>.fromIterable(>[ + utf8.encode('event: gateway\n'), + utf8.encode('data: {"type":"message.delta",'), + utf8.encode('"sessionId":"s1",'), + utf8.encode('"data":{"text":"hello"}}\n\n'), + ]), + 200, + headers: const { + 'content-type': 'text/event-stream', + }, + ); + }), + ); + addTearDown(client.close); + + final event = await client.events('s1').single; + + expect(event.sseEvent, 'gateway'); + expect(event.type, 'message.delta'); + expect(event.sessionId, 's1'); + expect(event.data['text'], 'hello'); + expect(event.raw['type'], 'message.delta'); + }); + + test('throws on non-success SSE handshake', () async { + final client = GatewayClient( + baseUrl: Uri.parse('http://gateway.test'), + httpClient: _StreamingClient((request) async { + return http.StreamedResponse(const Stream>.empty(), 500); + }), + ); + addTearDown(client.close); + + expect(client.events('s1').drain(), throwsStateError); + }); + }); +} + +class _StreamingClient extends http.BaseClient { + _StreamingClient(this._handler); + + final Future Function(http.BaseRequest request) + _handler; + + @override + Future send(http.BaseRequest request) { + return _handler(request); + } +} diff --git a/test/models/gateway_models_test.dart b/test/models/gateway_models_test.dart new file mode 100644 index 0000000..0d5f190 --- /dev/null +++ b/test/models/gateway_models_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:remote_multi_agent/models/agent.dart'; +import 'package:remote_multi_agent/models/gateway_event.dart'; +import 'package:remote_multi_agent/models/gateway_session.dart'; +import 'package:remote_multi_agent/models/project.dart'; + +void main() { + group('Project.fromJson', () { + test('uses defaults for missing fields', () { + final project = Project.fromJson(const {}); + + expect(project.id, ''); + expect(project.name, ''); + expect(project.directory, ''); + expect(project.updatedAtMs, 0); + }); + }); + + group('Agent.fromJson', () { + test('decodes capabilities and commands', () { + final agent = Agent.fromJson(const { + 'id': 'codex', + 'displayName': 'Codex', + 'supportsModels': true, + 'supportsSlashCommands': true, + 'commands': [ + {'name': '/fast', 'description': 'Switch model behavior'}, + ], + }); + + expect(agent.id, 'codex'); + expect(agent.supportsModels, isTrue); + expect(agent.supportsSlashCommands, isTrue); + expect(agent.supportsAttachments, isFalse); + expect(agent.supportsPermissions, isFalse); + expect(agent.sessionKind, 'thread'); + expect(agent.commands.single.name, '/fast'); + }); + }); + + group('GatewaySession.fromJson', () { + test('decodes known status values', () { + final session = GatewaySession.fromJson(const { + 'id': 's1', + 'projectId': 'p1', + 'agentId': 'codex', + 'status': 'waiting-for-approval', + 'createdAt': '1779177600000', + }); + + expect(session.id, 's1'); + expect(session.projectId, 'p1'); + expect(session.directory, ''); + expect(session.status, GatewaySessionStatus.waitingForApproval); + expect(session.createdAtMs, 1779177600000); + expect(session.updatedAtMs, 0); + }); + }); + + group('GatewayEvent', () { + test('decodes JSON SSE envelope and preserves raw', () { + final event = GatewayEvent.fromSseData( + sseEvent: 'gateway', + data: ''' +{"type":"message.delta","sessionId":"s1","agentId":"codex","timestamp":1779177600000,"data":{"text":"hi"},"raw":{"provider":"cli"}}''', + ); + + expect(event.type, 'message.delta'); + expect(event.sseEvent, 'gateway'); + expect(event.sessionId, 's1'); + expect(event.agentId, 'codex'); + expect(event.timestampMs, 1779177600000); + expect(event.data['text'], 'hi'); + expect(event.raw['provider'], 'cli'); + }); + + test('falls back to raw map for malformed SSE data', () { + final event = GatewayEvent.fromSseData( + sseEvent: 'message', + data: 'not json', + ); + + expect(event.type, 'message'); + expect(event.data['_raw'], 'not json'); + expect(event.raw['_raw'], 'not json'); + }); + }); +} diff --git a/test/state/gateway_chat_store_test.dart b/test/state/gateway_chat_store_test.dart new file mode 100644 index 0000000..1df8fd5 --- /dev/null +++ b/test/state/gateway_chat_store_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:remote_multi_agent/api/gateway_client.dart'; +import 'package:remote_multi_agent/models/gateway_event.dart'; +import 'package:remote_multi_agent/models/gateway_session.dart'; +import 'package:remote_multi_agent/state/gateway_chat_store.dart'; + +void main() { + test('message.delta appends gateway text into a renderable message', + () async { + final controller = GatewayChatStore( + client: _FakeGatewayClient( + eventsStream: Stream.fromIterable([ + const GatewayEvent( + type: 'message.delta', + sessionId: 's1', + agentId: 'codex', + timestampMs: 1, + data: { + 'messageId': 'm1', + 'delta': 'hello', + }, + raw: {}, + sseEvent: 'message', + ), + ]), + ), + sessionId: 's1', + ); + addTearDown(controller.dispose); + + await Future.delayed(Duration.zero); + + final message = controller.state.messages['m1']; + expect(message, isNotNull); + expect(message!.orderedParts.single.id, 'm1_text'); + }); +} + +class _FakeGatewayClient extends GatewayClient { + _FakeGatewayClient({required this.eventsStream}) + : super(baseUrl: Uri.parse('http://gateway.test')); + + final Stream eventsStream; + + @override + Future getSession(String sessionId) async { + return GatewaySession( + id: sessionId, + projectId: 'p1', + directory: '/tmp/project', + agentId: 'codex', + title: 'Test', + status: GatewaySessionStatus.idle, + createdAtMs: 1, + updatedAtMs: 1, + raw: const {}, + ); + } + + @override + Future>> listMessages(String sessionId) async { + return const []; + } + + @override + Stream events(String sessionId) { + return eventsStream; + } + + @override + void close() {} +} From 1c1f7e1d98c501958432710d5a176c8725bbe628 Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 19:35:41 +0800 Subject: [PATCH 02/10] feat: add real local agent gateway --- .github/workflows/ci.yml | 4 + .gitignore | 2 + README.md | 36 +- gateway/README.md | 96 ++++ gateway/package.json | 14 + gateway/src/agents.js | 572 +++++++++++++++++++++++ gateway/src/cli.js | 222 +++++++++ gateway/src/events.js | 46 ++ gateway/src/index.js | 26 ++ gateway/src/server.js | 385 +++++++++++++++ gateway/src/store.js | 319 +++++++++++++ gateway/test/server.test.js | 164 +++++++ lib/ui/pages/codex_thread_list_page.dart | 2 +- lib/ui/pages/project_list_page.dart | 3 +- lib/ui/pages/session_list_page.dart | 2 +- lib/ui/widgets/directory_picker.dart | 14 +- 16 files changed, 1879 insertions(+), 28 deletions(-) create mode 100644 gateway/README.md create mode 100644 gateway/package.json create mode 100644 gateway/src/agents.js create mode 100644 gateway/src/cli.js create mode 100644 gateway/src/events.js create mode 100644 gateway/src/index.js create mode 100644 gateway/src/server.js create mode 100644 gateway/src/store.js create mode 100644 gateway/test/server.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a32619..d66bee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,7 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test --reporter expanded + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test --prefix gateway diff --git a/.gitignore b/.gitignore index d385db2..cbe8544 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ app.*.map.json /android/app/release .docker-pub-cache/ analyze.txt +gateway/.data/ +gateway/node_modules/ diff --git a/README.md b/README.md index d9b7407..93a5d7e 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,35 @@ # remote_multi_agent -A Flutter mobile client for [OpenCode](https://opencode.ai). Connects to a -remote OpenCode server (running on your laptop), tails its `/event` SSE stream, -and renders the live message + tool + reasoning flow on your phone. +A Flutter mobile client for local coding agents. It connects to the gateway +running on your laptop, streams normalized agent events, and renders Codex, +Claude Code, and OpenCode sessions in one project workspace. ## Why - OpenClaw + 一来一回式 IM bots can't show you the agent's *progress* — only the final answer. -- OpenCode emits a structured live event stream (`message.updated`, - `message.part.updated`, etc.) that is perfect for a real-time mobile UI. -- This app is a thin client: it carries no model keys; the OpenCode server you - point it at owns provider auth. +- Agent CLIs emit progress, tool use, and final answers; the gateway normalizes + that stream into one app protocol. +- This app is a thin client: it carries no model keys; your local gateway owns + provider auth and project filesystem access. ## Architecture -``` -[Phone] remote_multi_agent (this app) - │ HTTPS / Bearer token - ▼ +```text +[Phone] remote_multi_agent (Flutter) + HTTPS / SSE + | [Tailscale] 100.x.x.x:4096 - │ - ▼ -[Laptop] opencode serve --port 4096 --hostname 0.0.0.0 - │ - ▼ - AI provider of your choice (Anthropic, OpenAI, local, …) + | +[Laptop] gateway/ Node server + | + Codex CLI / Claude Code CLI / OpenCode CLI ``` +The gateway in `gateway/` owns local project directories, sessions, CLI +processes, and event normalization. The Flutter app does not execute shell +commands or read project files directly. + ## Run / build matrix | Target | Where to build | How to install | diff --git a/gateway/README.md b/gateway/README.md new file mode 100644 index 0000000..ea52fe7 --- /dev/null +++ b/gateway/README.md @@ -0,0 +1,96 @@ +# Remote Multi Agent Gateway + +Local HTTP/SSE gateway for the Flutter client. It owns filesystem access and +agent execution; the app only talks to this server. + +## Supported Agents + +- Codex: `codex exec --json` +- Claude Code: `claude -p --output-format stream-json --verbose` +- OpenCode: `opencode run --format json` + +The gateway uses the CLI login state already configured on this machine. + +## Run + +```powershell +cd gateway +npm start +``` + +Default URL: + +```text +http://127.0.0.1:4096 +``` + +For LAN or Tailscale access: + +```powershell +$env:GATEWAY_HOST='0.0.0.0' +$env:GATEWAY_PORT='4096' +npm start +``` + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `GATEWAY_HOST` | Bind host, default `127.0.0.1`. | +| `GATEWAY_PORT` | Bind port, default `4096`. | +| `GATEWAY_DATA_FILE` | JSON store path, default `gateway/.data/store.json`. | +| `GATEWAY_DIRECTORIES` | Extra roots returned by `GET /directories`, separated by OS path delimiter. | +| `CODEX_BIN` | Override Codex executable path. | +| `CODEX_SANDBOX` | Codex sandbox mode, default `workspace-write`. | +| `CODEX_APPROVAL_POLICY` | Codex approval policy, default `never`. | +| `CLAUDE_CODE_BIN` | Override Claude Code executable path. | +| `CLAUDE_CODE_MODELS` | Comma-separated Claude model aliases to show in the picker. | +| `CLAUDE_CODE_PERMISSION_MODE` | Optional Claude permission mode, for example `acceptEdits` or `dontAsk`. | +| `OPENCODE_BIN` | Override OpenCode executable path. | + +## API + +The gateway implements the app contract from `docs/development-spec.md`: + +```text +GET /health +GET /projects +POST /projects +GET /projects/:projectId +DELETE /projects/:projectId +GET /directories +GET /files/dirs?path= +POST /files/mkdir +GET /agents +GET /agents/:agentId +GET /agents/:agentId/models +GET /agents/:agentId/commands +GET /projects/:projectId/sessions +POST /projects/:projectId/sessions +GET /sessions/:sessionId +PATCH /sessions/:sessionId +DELETE /sessions/:sessionId +GET /sessions/:sessionId/messages +POST /sessions/:sessionId/messages +POST /sessions/:sessionId/abort +GET /sessions/:sessionId/events +``` + +`/sessions/:sessionId/events` is SSE. Each event uses the normalized envelope: + +```json +{ + "type": "message.delta", + "sessionId": "session-id", + "agentId": "codex", + "timestamp": 1779177600000, + "data": {}, + "raw": {} +} +``` + +## Test + +```powershell +npm test --prefix gateway +``` diff --git a/gateway/package.json b/gateway/package.json new file mode 100644 index 0000000..22818ba --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,14 @@ +{ + "name": "remote-multi-agent-gateway", + "version": "0.1.0", + "private": true, + "description": "Local gateway for Claude Code, Codex, and OpenCode CLIs.", + "type": "commonjs", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + } +} diff --git a/gateway/src/agents.js b/gateway/src/agents.js new file mode 100644 index 0000000..aa89dd1 --- /dev/null +++ b/gateway/src/agents.js @@ -0,0 +1,572 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +const { + commandExists, + killProcessTree, + readLines, + resolveClaudeCommand, + resolveCodexCommand, + resolveOpenCodeCommand, + runCapture, + spawnCli, +} = require('./cli'); + +const CODEX_COMMANDS = [ + '/permissions', + '/ide', + '/keymap', + '/vim', + '/sandbox-add-read-dir', + '/agent', + '/apps', + '/plugins', + '/hooks', + '/clear', + '/compact', + '/copy', + '/diff', + '/exit', + '/quit', + '/experimental', + '/approve', + '/memories', + '/skills', + '/feedback', + '/init', + '/logout', + '/mcp', + '/mention', + '/model', + '/fast', + '/plan', + '/goal', + '/personality', + '/ps', + '/stop', + '/fork', + '/side', + '/raw', + '/status', + '/debug-config', +]; + +const CLAUDE_COMMANDS = [ + '/add-dir', + '/agents', + '/bug', + '/clear', + '/compact', + '/config', + '/cost', + '/doctor', + '/help', + '/init', + '/login', + '/logout', + '/mcp', + '/memory', + '/model', + '/permissions', + '/pr_comments', + '/review', + '/status', + '/terminal-setup', + '/vim', +]; + +const OPENCODE_COMMANDS = [ + '/help', + '/editor', + '/export', + '/new', + '/clear', + '/sessions', + '/resume', + '/continue', + '/share', + '/unshare', + '/compact', + '/summarize', + '/details', + '/models', + '/themes', + '/init', + '/undo', + '/redo', + '/exit', + '/quit', + '/q', +]; + +class AgentRegistry { + constructor() { + this.adapters = new Map( + [new CodexAdapter(), new ClaudeCodeAdapter(), new OpenCodeAdapter()].map( + (adapter) => [adapter.id, adapter], + ), + ); + } + + get(agentId) { + return this.adapters.get(agentId) || null; + } + + async list(projectDirectory) { + return Promise.all( + [...this.adapters.values()].map((adapter) => adapter.metadata(projectDirectory)), + ); + } +} + +class CodexAdapter { + constructor() { + this.id = 'codex'; + this.displayName = 'Codex'; + this.command = resolveCodexCommand(); + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: false, + supportsPermissions: true, + sessionKind: 'thread', + commands: commands(CODEX_COMMANDS), + raw: { + available: commandExists(this.command), + command: publicCommand(this.command), + projectDirectory, + }, + }; + } + + async models() { + const result = await runCapture(this.command, ['debug', 'models', '--bundled']); + if (result.exitCode === 0) { + try { + const parsed = JSON.parse(result.stdout); + const models = Array.isArray(parsed.models) ? parsed.models : []; + return models + .filter((model) => model.visibility !== 'hidden') + .map((model) => ({ + id: model.slug, + displayName: model.display_name || model.slug, + raw: compactCodexModel(model), + })); + } catch (_) { + // Fall through to static list. + } + } + return [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.2', + ].map((id) => ({ id, displayName: id, raw: { id } })); + } + + async commands() { + return commands(CODEX_COMMANDS); + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const args = session.agentSessionId + ? ['exec', 'resume', '--json'] + : [ + 'exec', + '--json', + '--color', + 'never', + '--cd', + session.directory, + '--sandbox', + process.env.CODEX_SANDBOX || 'workspace-write', + '--ask-for-approval', + process.env.CODEX_APPROVAL_POLICY || 'never', + '--skip-git-repo-check', + ]; + if (session.modelId) args.push('--model', session.modelId); + if (session.agentSessionId) args.push(session.agentSessionId); + args.push('-'); + return runJsonCli({ + command: this.command, + args, + cwd: session.directory, + stdin: prompt, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } +} + +class ClaudeCodeAdapter { + constructor() { + this.id = 'claude-code'; + this.displayName = 'Claude Code'; + this.command = resolveClaudeCommand(); + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: true, + supportsPermissions: true, + sessionKind: 'thread', + commands: await this.commands(projectDirectory), + raw: { + available: commandExists(this.command), + command: publicCommand(this.command), + projectDirectory, + }, + }; + } + + async models() { + return (process.env.CLAUDE_CODE_MODELS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .map((id) => ({ id, displayName: id, raw: { id } })); + } + + async commands(projectDirectory) { + return commands([ + ...CLAUDE_COMMANDS, + ...(await markdownCommands(path.join(projectDirectory || '', '.claude', 'commands'))), + ...(await markdownCommands(path.join(os.homedir(), '.claude', 'commands'))), + ]); + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const args = [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + ]; + if (process.env.CLAUDE_CODE_PERMISSION_MODE) { + args.push('--permission-mode', process.env.CLAUDE_CODE_PERMISSION_MODE); + } + if (session.modelId) args.push('--model', session.modelId); + if (session.agentSessionId) args.push('--resume', session.agentSessionId); + args.push(prompt); + return runJsonCli({ + command: this.command, + args, + cwd: session.directory, + stdin: null, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } +} + +class OpenCodeAdapter { + constructor() { + this.id = 'opencode'; + this.displayName = 'OpenCode'; + this.command = resolveOpenCodeCommand(); + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: true, + supportsPermissions: true, + sessionKind: 'session', + commands: await this.commands(projectDirectory), + raw: { + available: commandExists(this.command), + command: publicCommand(this.command), + projectDirectory, + }, + }; + } + + async models() { + const result = await runCapture(this.command, ['models']); + if (result.exitCode === 0) { + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^[^/\s]+\/[^/\s]+$/.test(line)) + .map((id) => ({ id, displayName: id, raw: { id } })); + } + return [{ id: 'opencode/big-pickle', displayName: 'opencode/big-pickle', raw: {} }]; + } + + async commands(projectDirectory) { + return commands([ + ...OPENCODE_COMMANDS, + ...(await markdownCommands(path.join(projectDirectory || '', '.opencode', 'commands'))), + ...(await opencodeJsonCommands(projectDirectory)), + ]); + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const args = ['run', '--format', 'json', '--dir', session.directory]; + if (session.modelId) args.push('--model', session.modelId); + if (session.agentSessionId) args.push('--session', session.agentSessionId); + args.push(prompt); + return runJsonCli({ + command: this.command, + args, + cwd: session.directory, + stdin: null, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } +} + +function runJsonCli({ + command, + args, + cwd, + stdin, + agentId, + onEvent, + onText, + onAgentSessionId, + onExit, +}) { + const child = spawnCli(command, args, { cwd }); + const state = { + lastFullTextByKey: new Map(), + sawText: false, + }; + readLines(child.stdout, (line) => { + const raw = parseJsonLine(line); + if (!raw) { + onText(line.endsWith('\n') ? line : `${line}\n`); + return; + } + const eventType = raw.type || raw.event || 'cli.event'; + onEvent({ + type: 'command.updated', + data: { stream: 'stdout', eventType }, + raw, + }); + const agentSessionId = extractAgentSessionId(raw); + if (agentSessionId) onAgentSessionId(agentSessionId, raw); + const delta = extractTextDelta(raw, state); + if (delta) { + state.sawText = true; + onText(delta); + } + }); + readLines(child.stderr, (line) => { + onEvent({ + type: 'command.updated', + data: { stream: 'stderr', text: line }, + raw: { line }, + }); + }); + if (stdin !== null && stdin !== undefined) { + child.stdin.end(stdin); + } else { + child.stdin.end(); + } + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + onExit(result); + }; + child.on('error', (error) => { + finish({ + exitCode: -1, + error: error.message, + }); + }); + child.on('close', (exitCode) => { + finish({ exitCode }); + }); + return { + pid: child.pid, + abort() { + killProcessTree(child); + }, + }; +} + +function extractTextDelta(raw, state) { + if (typeof raw.delta === 'string') return raw.delta; + if (typeof raw.text_delta === 'string') return raw.text_delta; + if (typeof raw.content_delta === 'string') return raw.content_delta; + + const properties = raw.properties || raw.data || {}; + const part = properties.part || raw.part; + if (part && typeof part.text === 'string') { + return suffixDelta(`part:${part.id || raw.type || 'text'}`, part.text, state); + } + + if (raw.type === 'assistant' && raw.message) { + const text = contentArrayText(raw.message.content); + if (text) return suffixDelta('claude:assistant', text, state); + } + + if (raw.item && raw.item.role === 'assistant') { + const text = contentArrayText(raw.item.content); + if (text) return suffixDelta(`item:${raw.item.id || raw.type || 'assistant'}`, text, state); + } + + if (raw.message && raw.message.role === 'assistant') { + const text = + typeof raw.message.content === 'string' + ? raw.message.content + : contentArrayText(raw.message.content); + if (text) return suffixDelta(`message:${raw.message.id || raw.type || 'assistant'}`, text, state); + } + + if (raw.role === 'assistant') { + const text = + typeof raw.content === 'string' ? raw.content : contentArrayText(raw.content); + if (text) return suffixDelta(`assistant:${raw.id || raw.type || 'content'}`, text, state); + } + + if (!state.sawText && typeof raw.result === 'string') return raw.result; + return ''; +} + +function suffixDelta(key, fullText, state) { + const previous = state.lastFullTextByKey.get(key) || ''; + state.lastFullTextByKey.set(key, fullText); + if (!previous) return fullText; + return fullText.startsWith(previous) ? fullText.slice(previous.length) : fullText; +} + +function contentArrayText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((item) => { + if (typeof item === 'string') return item; + if (!item || typeof item !== 'object') return ''; + if (typeof item.text === 'string') return item.text; + if (typeof item.content === 'string') return item.content; + return ''; + }) + .join(''); +} + +function extractAgentSessionId(raw) { + if (raw.thread_id) return raw.thread_id; + if (raw.threadId) return raw.threadId; + if (raw.session_id) return raw.session_id; + if (raw.sessionId) return raw.sessionId; + if (raw.conversation_id) return raw.conversation_id; + if (raw.conversationId) return raw.conversationId; + if (raw.id && /session|thread|conversation/.test(String(raw.type || ''))) { + return raw.id; + } + return null; +} + +function parseJsonLine(line) { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_) { + return null; + } +} + +function commands(items) { + const seen = new Set(); + const out = []; + for (const item of items) { + const name = typeof item === 'string' ? item : item.name; + if (!name) continue; + const normalized = name.startsWith('/') ? name : `/${name}`; + if (seen.has(normalized)) continue; + seen.add(normalized); + out.push({ + name: normalized, + description: typeof item === 'object' ? item.description || '' : '', + }); + } + return out; +} + +async function markdownCommands(directory) { + if (!directory) return []; + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); + const out = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = await markdownCommands(path.join(directory, entry.name)); + out.push(...nested.map((command) => `${entry.name}:${command}`)); + continue; + } + if (!entry.name.endsWith('.md')) continue; + out.push(entry.name.slice(0, -3)); + } + return out; +} + +async function opencodeJsonCommands(projectDirectory) { + if (!projectDirectory) return []; + const file = path.join(projectDirectory, 'opencode.json'); + try { + const parsed = JSON.parse(await fs.readFile(file, 'utf8')); + if (Array.isArray(parsed.commands)) { + return parsed.commands.map((command) => + typeof command === 'string' ? command : command.name || command.id || '', + ); + } + if (parsed.commands && typeof parsed.commands === 'object') { + return Object.keys(parsed.commands); + } + } catch (_) { + // Ignore invalid or missing project config. + } + return []; +} + +function publicCommand(command) { + return { + command: command.command, + prefixArgs: command.prefixArgs || [], + shell: Boolean(command.shell), + }; +} + +function compactCodexModel(model) { + return { + id: model.slug, + description: model.description, + defaultReasoningLevel: model.default_reasoning_level, + supportedReasoningLevels: model.supported_reasoning_levels, + additionalSpeedTiers: model.additional_speed_tiers, + serviceTiers: model.service_tiers, + }; +} + +module.exports = { + AgentRegistry, +}; diff --git a/gateway/src/cli.js b/gateway/src/cli.js new file mode 100644 index 0000000..0d2cdd2 --- /dev/null +++ b/gateway/src/cli.js @@ -0,0 +1,222 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +function resolveCodexCommand() { + if (process.env.CODEX_BIN) return commandFromPath(process.env.CODEX_BIN); + if (process.platform === 'win32') { + const script = path.join( + process.env.APPDATA || '', + 'npm', + 'node_modules', + '@openai', + 'codex', + 'bin', + 'codex.js', + ); + if (fs.existsSync(script)) { + return { command: process.execPath, prefixArgs: [script], shell: false }; + } + } + return { command: 'codex', prefixArgs: [], shell: process.platform === 'win32' }; +} + +function resolveOpenCodeCommand() { + if (process.env.OPENCODE_BIN) return commandFromPath(process.env.OPENCODE_BIN); + if (process.platform === 'win32') { + const exe = path.join( + process.env.APPDATA || '', + 'npm', + 'node_modules', + 'opencode-ai', + 'bin', + 'opencode.exe', + ); + if (fs.existsSync(exe)) { + return { command: exe, prefixArgs: [], shell: false }; + } + } + return { + command: 'opencode', + prefixArgs: [], + shell: process.platform === 'win32', + }; +} + +function resolveClaudeCommand() { + if (process.env.CLAUDE_CODE_BIN) { + return commandFromPath(process.env.CLAUDE_CODE_BIN); + } + if (process.platform === 'win32') { + const exe = path.join( + process.env.APPDATA || '', + 'npm', + 'node_modules', + '@anthropic-ai', + 'claude-code', + 'bin', + 'claude.exe', + ); + if (fs.existsSync(exe)) { + return { command: exe, prefixArgs: [], shell: false }; + } + const shim = path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'); + if (fs.existsSync(shim)) { + return { command: shim, prefixArgs: [], shell: true }; + } + } + return { command: 'claude', prefixArgs: [], shell: process.platform === 'win32' }; +} + +function commandFromPath(command) { + const ext = path.extname(command).toLowerCase(); + if (process.platform === 'win32' && (ext === '.cmd' || ext === '.bat')) { + return { command, prefixArgs: [], shell: true }; + } + if (process.platform === 'win32' && ext === '.ps1') { + return { + command: 'powershell.exe', + prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command], + shell: false, + }; + } + return { command, prefixArgs: [], shell: false }; +} + +function commandExists(spec) { + if (spec.command === process.execPath) { + return (spec.prefixArgs || []).every((arg) => fs.existsSync(arg)); + } + if (path.isAbsolute(spec.command)) return fs.existsSync(spec.command); + return findOnPath(spec.command) !== null; +} + +function spawnCli(spec, args, options = {}) { + const allArgs = [...(spec.prefixArgs || []), ...args]; + if (spec.shell && process.platform === 'win32') { + return spawnWindowsShell(spec.command, allArgs, options); + } + return spawn(spec.command, allArgs, { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function spawnWindowsShell(command, args, options) { + const line = [quoteWindowsArg(command), ...args.map(quoteWindowsArg)].join(' '); + return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', line], { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsVerbatimArguments: false, + }); +} + +function quoteWindowsArg(value) { + const raw = String(value); + if (raw.length === 0) return '""'; + return `"${raw.replace(/(["\\])/g, '\\$1')}"`; +} + +function killProcessTree(child) { + if (!child || child.killed) return; + if (process.platform === 'win32' && child.pid) { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + return; + } + child.kill('SIGTERM'); +} + +function readLines(stream, onLine, onChunk) { + let buffer = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + onChunk?.(chunk); + buffer += chunk; + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + for (const line of lines) { + if (line.trim().length > 0) onLine(line); + } + }); + stream.on('end', () => { + if (buffer.trim().length > 0) onLine(buffer); + }); +} + +async function runCapture(spec, args, options = {}) { + return new Promise((resolve) => { + const child = spawnCli(spec, args, options); + let stdout = ''; + let stderr = ''; + let settled = false; + const timeoutMs = options.timeoutMs || 15000; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + killProcessTree(child); + finish({ + exitCode: -1, + stdout, + stderr: stderr || `command timed out after ${timeoutMs}ms`, + }); + }, timeoutMs); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + child.on('error', (error) => { + finish({ exitCode: -1, stdout, stderr: stderr || error.message }); + }); + child.on('close', (exitCode) => { + finish({ exitCode, stdout, stderr }); + }); + child.stdin.end(); + }); +} + +function findOnPath(command) { + const directories = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + const names = candidateNames(command); + for (const directory of directories) { + for (const name of names) { + const candidate = path.join(directory, name); + if (fs.existsSync(candidate)) return candidate; + } + } + return null; +} + +function candidateNames(command) { + if (process.platform !== 'win32' || path.extname(command)) return [command]; + const extensions = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') + .split(';') + .filter(Boolean); + return extensions.flatMap((ext) => [ + `${command}${ext.toLowerCase()}`, + `${command}${ext.toUpperCase()}`, + ]); +} + +module.exports = { + commandExists, + killProcessTree, + readLines, + resolveClaudeCommand, + resolveCodexCommand, + resolveOpenCodeCommand, + runCapture, + spawnCli, +}; diff --git a/gateway/src/events.js b/gateway/src/events.js new file mode 100644 index 0000000..7c2771b --- /dev/null +++ b/gateway/src/events.js @@ -0,0 +1,46 @@ +'use strict'; + +class EventBus { + constructor() { + this.subscribers = new Map(); + } + + subscribe(sessionId, response) { + let set = this.subscribers.get(sessionId); + if (!set) { + set = new Set(); + this.subscribers.set(sessionId, set); + } + set.add(response); + response.write(': connected\n\n'); + return () => { + set.delete(response); + if (set.size === 0) this.subscribers.delete(sessionId); + }; + } + + emit(event) { + const subscribers = this.subscribers.get(event.sessionId); + if (!subscribers || subscribers.size === 0) return; + const payload = `event: gateway\ndata: ${JSON.stringify(event)}\n\n`; + for (const response of subscribers) { + response.write(payload); + } + } +} + +function makeEvent({ type, sessionId, agentId, data = {}, raw = {} }) { + return { + type, + sessionId, + agentId, + timestamp: Date.now(), + data, + raw, + }; +} + +module.exports = { + EventBus, + makeEvent, +}; diff --git a/gateway/src/index.js b/gateway/src/index.js new file mode 100644 index 0000000..0d30443 --- /dev/null +++ b/gateway/src/index.js @@ -0,0 +1,26 @@ +'use strict'; + +const path = require('node:path'); + +const { createGatewayServer } = require('./server'); + +const port = Number.parseInt(process.env.GATEWAY_PORT || '4096', 10); +const host = process.env.GATEWAY_HOST || '127.0.0.1'; +const dataFile = + process.env.GATEWAY_DATA_FILE || + path.join(__dirname, '..', '.data', 'store.json'); + +async function main() { + const server = await createGatewayServer({ dataFile }); + server.listen(port, host, () => { + const address = server.address(); + const actualPort = + typeof address === 'object' && address !== null ? address.port : port; + console.log(`remote-multi-agent gateway listening on http://${host}:${actualPort}`); + }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/gateway/src/server.js b/gateway/src/server.js new file mode 100644 index 0000000..09952cb --- /dev/null +++ b/gateway/src/server.js @@ -0,0 +1,385 @@ +'use strict'; + +const http = require('node:http'); +const { URL } = require('node:url'); + +const { AgentRegistry } = require('./agents'); +const { EventBus, makeEvent } = require('./events'); +const { + JsonStore, + appendTextToMessage, + browseDirectories, + completeMessage, + createTextMessage, + defaultDirectories, + mkdir, +} = require('./store'); + +async function createGatewayServer({ dataFile, adapters } = {}) { + const store = new JsonStore(dataFile); + await store.load(); + const registry = adapters || new AgentRegistry(); + const bus = new EventBus(); + const activeRuns = new Map(); + + const server = http.createServer(async (request, response) => { + setCors(response); + if (request.method === 'OPTIONS') { + response.writeHead(204); + response.end(); + return; + } + + try { + const url = new URL(request.url, 'http://gateway.local'); + const segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + + if (request.method === 'GET' && url.pathname === '/health') { + return sendJson(response, { + ok: true, + version: '0.1.0', + agents: (await registry.list()).map((agent) => agent.id), + }); + } + + if (request.method === 'GET' && url.pathname === '/directories') { + return sendJson(response, { directories: defaultDirectories(store) }); + } + + if (request.method === 'GET' && url.pathname === '/files/dirs') { + return sendJson(response, await browseDirectories(url.searchParams.get('path'))); + } + + if (request.method === 'POST' && url.pathname === '/files/mkdir') { + return sendJson(response, await mkdir((await readJson(request)).path)); + } + + if (segments[0] === 'projects') { + return await handleProjects({ + request, + response, + segments, + store, + registry, + }); + } + + if (segments[0] === 'agents') { + return await handleAgents({ + request, + response, + segments, + store, + registry, + }); + } + + if (segments[0] === 'sessions') { + return await handleSessions({ + request, + response, + segments, + store, + registry, + bus, + activeRuns, + }); + } + + throw httpError(404, 'not found'); + } catch (error) { + if (response.headersSent) { + response.destroy(error); + return; + } + sendJson( + response, + { + error: error.message || 'internal server error', + }, + error.statusCode || 500, + ); + } + }); + + server.closeAllRuns = () => { + for (const run of activeRuns.values()) run.abort(); + activeRuns.clear(); + }; + return server; +} + +async function handleProjects({ request, response, segments, store, registry }) { + if (segments.length === 1 && request.method === 'GET') { + return sendJson(response, store.listProjects()); + } + if (segments.length === 1 && request.method === 'POST') { + const body = await readJson(request); + return sendJson( + response, + await store.createProject({ + directory: body.directory, + name: body.name, + }), + 201, + ); + } + const project = store.getProject(segments[1]); + if (!project) throw httpError(404, 'project not found'); + if (segments.length === 2 && request.method === 'GET') { + return sendJson(response, project); + } + if (segments.length === 2 && request.method === 'DELETE') { + await store.deleteProject(project.id); + return sendJson(response, { ok: true }); + } + if (segments.length === 3 && segments[2] === 'sessions') { + if (request.method === 'GET') { + return sendJson(response, store.listSessions(project.id)); + } + if (request.method === 'POST') { + const body = await readJson(request); + const adapter = registry.get(body.agentId); + if (!adapter) throw httpError(400, `unknown agent: ${body.agentId}`); + const session = await store.createSession({ + project, + agentId: body.agentId, + modelId: body.modelId, + title: body.title || `${adapter.displayName} session`, + }); + return sendJson(response, session, 201); + } + } + throw httpError(404, 'not found'); +} + +async function handleAgents({ request, response, segments, store, registry }) { + if (segments.length === 1 && request.method === 'GET') { + return sendJson(response, await registry.list()); + } + const adapter = registry.get(segments[1]); + if (!adapter) throw httpError(404, 'agent not found'); + if (segments.length === 2 && request.method === 'GET') { + return sendJson(response, await adapter.metadata()); + } + if (segments.length === 3 && segments[2] === 'models' && request.method === 'GET') { + return sendJson(response, { models: await adapter.models() }); + } + if (segments.length === 3 && segments[2] === 'commands' && request.method === 'GET') { + const project = store.listProjects()[0]; + return sendJson(response, { + commands: await adapter.commands(project?.directory), + }); + } + throw httpError(404, 'not found'); +} + +async function handleSessions({ + request, + response, + segments, + store, + registry, + bus, + activeRuns, +}) { + const session = store.getSession(segments[1]); + if (!session) throw httpError(404, 'session not found'); + + if (segments.length === 2 && request.method === 'GET') { + return sendJson(response, session); + } + if (segments.length === 2 && request.method === 'PATCH') { + return sendJson(response, await store.updateSession(session.id, await readJson(request))); + } + if (segments.length === 2 && request.method === 'DELETE') { + const run = activeRuns.get(session.id); + if (run) run.abort(); + activeRuns.delete(session.id); + await store.deleteSession(session.id); + return sendJson(response, { ok: true }); + } + + if (segments.length === 3 && segments[2] === 'messages') { + if (request.method === 'GET') { + return sendJson(response, store.listMessages(session.id)); + } + if (request.method === 'POST') { + const body = await readJson(request); + const text = typeof body.text === 'string' ? body.text : ''; + if (!text.trim()) throw httpError(400, 'text is required'); + await startTurn({ + session, + text, + store, + registry, + bus, + activeRuns, + }); + return sendJson(response, { accepted: true, sessionId: session.id }, 202); + } + } + + if (segments.length === 3 && segments[2] === 'abort' && request.method === 'POST') { + const run = activeRuns.get(session.id); + if (run) run.abort(); + activeRuns.delete(session.id); + const updated = await store.updateSession(session.id, { status: 'idle' }); + emit(bus, 'session.updated', updated, { session: updated }); + return sendJson(response, { ok: true }); + } + + if (segments.length === 3 && segments[2] === 'events' && request.method === 'GET') { + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const unsubscribe = bus.subscribe(session.id, response); + request.on('close', unsubscribe); + return; + } + + throw httpError(404, 'not found'); +} + +async function startTurn({ session, text, store, registry, bus, activeRuns }) { + if (activeRuns.has(session.id)) { + throw httpError(409, 'session already running'); + } + const adapter = registry.get(session.agentId); + if (!adapter) throw httpError(400, `unknown agent: ${session.agentId}`); + + const userMessage = createTextMessage({ + sessionId: session.id, + role: 'user', + text, + }); + await store.appendMessage(session.id, userMessage); + emit(bus, 'message.created', session, { message: userMessage }, userMessage); + + const running = await store.updateSession(session.id, { status: 'running' }); + emit(bus, 'session.started', running, { session: running }); + emit(bus, 'session.updated', running, { session: running }); + + let assistantMessage = createTextMessage({ + sessionId: session.id, + role: 'assistant', + text: '', + status: 'running', + modelId: session.modelId, + }); + let textWrite = Promise.resolve(); + await store.appendMessage(session.id, assistantMessage); + emit(bus, 'message.created', session, { message: assistantMessage }, assistantMessage); + + const partId = assistantMessage.parts[0].id; + const run = adapter.run({ + session: running, + prompt: text, + onEvent: ({ type, data, raw }) => emit(bus, type, running, data, raw), + onText: (delta) => { + if (!delta) return; + textWrite = textWrite.then(async () => { + assistantMessage = appendTextToMessage(assistantMessage, delta); + await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); + emit( + bus, + 'message.delta', + running, + { + messageId: assistantMessage.id, + partId, + field: 'text', + delta, + }, + { delta }, + ); + }); + return textWrite; + }, + onAgentSessionId: async (agentSessionId, raw) => { + if (agentSessionId && agentSessionId !== session.agentSessionId) { + session.agentSessionId = agentSessionId; + await store.updateSession(session.id, { + agentSessionId, + raw: { agentSession: raw }, + }); + } + }, + onExit: async ({ exitCode, error }) => { + activeRuns.delete(session.id); + await textWrite; + const finalStatus = exitCode === 0 ? 'completed' : 'error'; + assistantMessage = completeMessage(assistantMessage, finalStatus); + await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); + emit(bus, 'message.completed', running, { message: assistantMessage }, assistantMessage); + + const updated = await store.updateSession(session.id, { + status: exitCode === 0 ? 'idle' : 'error', + raw: { + lastExitCode: exitCode, + lastError: error || null, + }, + }); + if (exitCode === 0) { + emit(bus, 'session.completed', updated, { session: updated }); + } else { + emit( + bus, + 'session.error', + updated, + { error: error || `agent exited with code ${exitCode}` }, + { exitCode, error }, + ); + } + emit(bus, 'session.updated', updated, { session: updated }); + }, + }); + activeRuns.set(session.id, run); +} + +function emit(bus, type, session, data = {}, raw = {}) { + bus.emit( + makeEvent({ + type, + sessionId: session.id, + agentId: session.agentId, + data, + raw, + }), + ); +} + +function setCors(response) { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); +} + +function sendJson(response, data, status = 200) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(data)); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (chunks.length === 0) return {}; + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch (_) { + throw httpError(400, 'invalid JSON body'); + } +} + +function httpError(statusCode, message) { + return Object.assign(new Error(message), { statusCode }); +} + +module.exports = { + createGatewayServer, +}; diff --git a/gateway/src/store.js b/gateway/src/store.js new file mode 100644 index 0000000..2910e87 --- /dev/null +++ b/gateway/src/store.js @@ -0,0 +1,319 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +class JsonStore { + constructor(file) { + this.file = file; + this.data = { + projects: [], + sessions: [], + messagesBySession: {}, + }; + } + + async load() { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + try { + const raw = await fs.readFile(this.file, 'utf8'); + const parsed = JSON.parse(raw); + this.data = { + projects: Array.isArray(parsed.projects) ? parsed.projects : [], + sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [], + messagesBySession: + parsed.messagesBySession && typeof parsed.messagesBySession === 'object' + ? parsed.messagesBySession + : {}, + }; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + await this.save(); + } + } + + async save() { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + const temp = `${this.file}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(temp, `${JSON.stringify(this.data, null, 2)}\n`, 'utf8'); + await fs.rename(temp, this.file); + } + + listProjects() { + return [...this.data.projects].sort((a, b) => b.updatedAt - a.updatedAt); + } + + async createProject({ directory, name }) { + const normalized = await normalizeDirectory(directory); + const now = Date.now(); + const existing = this.data.projects.find( + (project) => samePath(project.directory, normalized), + ); + if (existing) { + existing.name = name || existing.name || path.basename(normalized); + existing.updatedAt = now; + await this.save(); + return existing; + } + const project = { + id: crypto.randomUUID(), + name: name || path.basename(normalized) || normalized, + directory: normalized, + updatedAt: now, + }; + this.data.projects.push(project); + await this.save(); + return project; + } + + getProject(projectId) { + return this.data.projects.find((project) => project.id === projectId) || null; + } + + async deleteProject(projectId) { + this.data.projects = this.data.projects.filter( + (project) => project.id !== projectId, + ); + const removedSessions = this.data.sessions + .filter((session) => session.projectId === projectId) + .map((session) => session.id); + this.data.sessions = this.data.sessions.filter( + (session) => session.projectId !== projectId, + ); + for (const sessionId of removedSessions) { + delete this.data.messagesBySession[sessionId]; + } + await this.save(); + } + + listSessions(projectId) { + return this.data.sessions + .filter((session) => session.projectId === projectId) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + + async createSession({ project, agentId, modelId, title }) { + const now = Date.now(); + const session = { + id: crypto.randomUUID(), + projectId: project.id, + directory: project.directory, + agentId, + modelId: modelId || null, + title: title || 'New session', + status: 'idle', + createdAt: now, + updatedAt: now, + agentSessionId: null, + raw: {}, + }; + this.data.sessions.push(session); + this.data.messagesBySession[session.id] = []; + await this.save(); + return session; + } + + getSession(sessionId) { + return this.data.sessions.find((session) => session.id === sessionId) || null; + } + + async updateSession(sessionId, patch) { + const session = this.getSession(sessionId); + if (!session) return null; + for (const key of ['title', 'modelId', 'status', 'agentSessionId']) { + if (Object.prototype.hasOwnProperty.call(patch, key)) { + session[key] = patch[key]; + } + } + if (patch.raw && typeof patch.raw === 'object') { + session.raw = { ...(session.raw || {}), ...patch.raw }; + } + session.updatedAt = Date.now(); + await this.save(); + return session; + } + + async deleteSession(sessionId) { + this.data.sessions = this.data.sessions.filter( + (session) => session.id !== sessionId, + ); + delete this.data.messagesBySession[sessionId]; + await this.save(); + } + + listMessages(sessionId) { + return [...(this.data.messagesBySession[sessionId] || [])]; + } + + async appendMessage(sessionId, message) { + const list = this.data.messagesBySession[sessionId] || []; + list.push(message); + this.data.messagesBySession[sessionId] = list; + await this.touchSession(sessionId); + await this.save(); + return message; + } + + async updateMessage(sessionId, messageId, updater) { + const list = this.data.messagesBySession[sessionId] || []; + const index = list.findIndex((message) => message.id === messageId); + if (index === -1) return null; + list[index] = updater(list[index]); + await this.touchSession(sessionId, false); + await this.save(); + return list[index]; + } + + async touchSession(sessionId, save = false) { + const session = this.getSession(sessionId); + if (session) session.updatedAt = Date.now(); + if (save) await this.save(); + } +} + +function createTextMessage({ + sessionId, + role, + text, + status = 'completed', + modelId, +}) { + const now = Date.now(); + const id = crypto.randomUUID(); + const partId = `${id}_text`; + const message = { + id, + role, + sessionID: sessionId, + status, + time: { + created: now, + ...(status === 'completed' ? { completed: now } : {}), + }, + parts: [ + { + id: partId, + messageID: id, + sessionID: sessionId, + type: 'text', + text, + }, + ], + }; + if (modelId) message.modelID = modelId; + return message; +} + +function appendTextToMessage(message, delta) { + const next = structuredClone(message); + if (!next.parts || next.parts.length === 0) { + next.parts = [ + { + id: `${next.id}_text`, + messageID: next.id, + sessionID: next.sessionID, + type: 'text', + text: '', + }, + ]; + } + next.parts[0].text = `${next.parts[0].text || ''}${delta}`; + return next; +} + +function completeMessage(message, status = 'completed') { + const next = structuredClone(message); + next.status = status; + next.time = { + ...(next.time || {}), + completed: Date.now(), + }; + return next; +} + +async function normalizeDirectory(directory) { + if (!directory || typeof directory !== 'string') { + throw Object.assign(new Error('directory is required'), { statusCode: 400 }); + } + const resolved = path.resolve(directory); + const stat = await fs.stat(resolved).catch(() => null); + if (!stat || !stat.isDirectory()) { + throw Object.assign(new Error(`directory does not exist: ${resolved}`), { + statusCode: 400, + }); + } + return fs.realpath(resolved); +} + +async function browseDirectories(targetPath) { + const root = targetPath ? path.resolve(targetPath) : defaultDirectoryRoot(); + const stat = await fs.stat(root).catch(() => null); + if (!stat || !stat.isDirectory()) { + throw Object.assign(new Error(`directory does not exist: ${root}`), { + statusCode: 400, + }); + } + const entries = await fs.readdir(root, { withFileTypes: true }); + const dirs = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + dirs.push({ + name: entry.name, + path: path.join(root, entry.name), + }); + } + return { + path: root, + dirs: dirs.sort((a, b) => a.name.localeCompare(b.name)), + }; +} + +async function mkdir(targetPath) { + if (!targetPath || typeof targetPath !== 'string') { + throw Object.assign(new Error('path is required'), { statusCode: 400 }); + } + await fs.mkdir(path.resolve(targetPath), { recursive: true }); + return { path: path.resolve(targetPath) }; +} + +function defaultDirectoryRoot() { + if (process.platform === 'win32') { + return process.env.SystemDrive ? `${process.env.SystemDrive}\\` : 'C:\\'; + } + return os.homedir(); +} + +function samePath(a, b) { + const left = path.resolve(a); + const right = path.resolve(b); + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function defaultDirectories(store) { + const configured = (process.env.GATEWAY_DIRECTORIES || '') + .split(path.delimiter) + .map((item) => item.trim()) + .filter(Boolean); + return Array.from( + new Set([ + ...configured, + ...store.listProjects().map((project) => project.directory), + process.cwd(), + os.homedir(), + ]), + ); +} + +module.exports = { + JsonStore, + appendTextToMessage, + browseDirectories, + completeMessage, + createTextMessage, + defaultDirectories, + mkdir, +}; diff --git a/gateway/test/server.test.js b/gateway/test/server.test.js new file mode 100644 index 0000000..778485b --- /dev/null +++ b/gateway/test/server.test.js @@ -0,0 +1,164 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createGatewayServer } = require('../src/server'); + +test('gateway exposes projects, sessions, messages, and SSE events', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-gateway-')); + const dataFile = path.join(root, 'store.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const adapters = new FakeRegistry(); + const server = await createGatewayServer({ dataFile, adapters }); + await listen(server); + t.after(() => { + server.closeAllRuns?.(); + server.close(); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const project = await postJson(`${base}/projects`, { directory: projectDir }); + assert.equal(project.name, 'project'); + + const agents = await getJson(`${base}/agents`); + assert.equal(agents[0].id, 'fake'); + + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'fake', + }); + assert.equal(session.agentId, 'fake'); + + const events = collectSseUntil( + `${base}/sessions/${session.id}/events`, + (event) => event.type === 'session.completed', + ); + const accepted = await postJson(`${base}/sessions/${session.id}/messages`, { + text: 'hello', + }); + assert.equal(accepted.accepted, true); + + const received = await events; + assert(received.some((event) => event.type === 'message.delta')); + assert(received.some((event) => event.type === 'session.completed')); + + const messages = await getJson(`${base}/sessions/${session.id}/messages`); + assert.equal(messages.length, 2); + assert.equal(messages[1].parts[0].text, 'fake response'); +}); + +class FakeRegistry { + constructor() { + this.adapter = new FakeAdapter(); + } + + get(agentId) { + return agentId === 'fake' ? this.adapter : null; + } + + async list() { + return [await this.adapter.metadata()]; + } +} + +class FakeAdapter { + constructor() { + this.id = 'fake'; + this.displayName = 'Fake'; + } + + async metadata() { + return { + id: 'fake', + displayName: 'Fake', + supportsModels: false, + supportsSlashCommands: false, + supportsAttachments: false, + supportsPermissions: false, + sessionKind: 'thread', + commands: [], + }; + } + + async models() { + return []; + } + + async commands() { + return []; + } + + run({ onText, onExit }) { + setImmediate(async () => { + await onText('fake response'); + onExit({ exitCode: 0 }); + }); + return { + abort() {}, + }; + } +} + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); +} + +async function getJson(url) { + const response = await fetch(url); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} + +async function collectSseUntil(url, predicate) { + const response = await fetch(url); + assert.equal(response.ok, true); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const events = []; + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let index; + while ((index = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + const data = block + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (data) { + const event = JSON.parse(data); + events.push(event); + if (predicate(event)) { + await reader.cancel(); + return events; + } + } + } + } + return events; +} diff --git a/lib/ui/pages/codex_thread_list_page.dart b/lib/ui/pages/codex_thread_list_page.dart index e4190d7..bde802a 100644 --- a/lib/ui/pages/codex_thread_list_page.dart +++ b/lib/ui/pages/codex_thread_list_page.dart @@ -75,7 +75,7 @@ class CodexThreadListPage extends ConsumerWidget { final qqbotUrl = 'http://${Uri.parse(settings.baseUrl).host}:8787'; final dir = await showDirectoryPicker( context, - qqbotBaseUrl: qqbotUrl, + gatewayBaseUrl: qqbotUrl, bearerToken: settings.bearerToken, initialPath: 'D:\\', ); diff --git a/lib/ui/pages/project_list_page.dart b/lib/ui/pages/project_list_page.dart index 421da33..c6571ce 100644 --- a/lib/ui/pages/project_list_page.dart +++ b/lib/ui/pages/project_list_page.dart @@ -53,10 +53,9 @@ class ProjectListPage extends ConsumerWidget { Future _addProject(BuildContext context, WidgetRef ref) async { final settings = ref.read(settingsControllerProvider); - final gatewayUrl = 'http://${Uri.parse(settings.baseUrl).host}:8787'; final directory = await showDirectoryPicker( context, - qqbotBaseUrl: gatewayUrl, + gatewayBaseUrl: settings.baseUrl, bearerToken: settings.bearerToken, initialPath: 'D:\\', ); diff --git a/lib/ui/pages/session_list_page.dart b/lib/ui/pages/session_list_page.dart index cd790ee..f264dbd 100644 --- a/lib/ui/pages/session_list_page.dart +++ b/lib/ui/pages/session_list_page.dart @@ -65,7 +65,7 @@ class SessionListPage extends ConsumerWidget { final selectedDir = await showDirectoryPicker( context, - qqbotBaseUrl: qqbotUrl, + gatewayBaseUrl: qqbotUrl, bearerToken: 'qqbot-dev-token-please-rotate-me', initialPath: 'D:\\', ); diff --git a/lib/ui/widgets/directory_picker.dart b/lib/ui/widgets/directory_picker.dart index f8c454f..9f76954 100644 --- a/lib/ui/widgets/directory_picker.dart +++ b/lib/ui/widgets/directory_picker.dart @@ -1,5 +1,5 @@ -/// Bottom-sheet directory picker that browses the remote computer's file system -/// via the QQBot server's `/files/dirs` endpoint. +/// Bottom-sheet directory picker that browses the gateway host's file system +/// via the gateway `/files/dirs` endpoint. /// /// Features: /// - Browse directories on the remote machine @@ -14,7 +14,7 @@ import 'package:flutter/material.dart'; /// or null if dismissed. Future showDirectoryPicker( BuildContext context, { - required String qqbotBaseUrl, + required String gatewayBaseUrl, required String bearerToken, String? initialPath, }) { @@ -24,7 +24,7 @@ Future showDirectoryPicker( showDragHandle: true, useSafeArea: true, builder: (_) => _DirectoryPickerSheet( - qqbotBaseUrl: qqbotBaseUrl, + gatewayBaseUrl: gatewayBaseUrl, bearerToken: bearerToken, initialPath: initialPath ?? 'D:\\', ), @@ -33,12 +33,12 @@ Future showDirectoryPicker( class _DirectoryPickerSheet extends StatefulWidget { const _DirectoryPickerSheet({ - required this.qqbotBaseUrl, + required this.gatewayBaseUrl, required this.bearerToken, required this.initialPath, }); - final String qqbotBaseUrl; + final String gatewayBaseUrl; final String bearerToken; final String initialPath; @@ -62,7 +62,7 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { _currentPath = widget.initialPath; _dio = Dio( BaseOptions( - baseUrl: widget.qqbotBaseUrl.replaceAll(RegExp(r'/$'), ''), + baseUrl: widget.gatewayBaseUrl.replaceAll(RegExp(r'/$'), ''), connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { From a6561b0cecdae55a00cdc5c958f1e153b2f0b42d Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 19:39:08 +0800 Subject: [PATCH 03/10] fix: harden gateway process and store handling --- gateway/README.md | 4 ++++ gateway/src/agents.js | 14 +++++++++++++- gateway/src/cli.js | 8 +++++++- gateway/src/store.js | 12 +++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/gateway/README.md b/gateway/README.md index ea52fe7..55c69a2 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -32,6 +32,10 @@ $env:GATEWAY_PORT='4096' npm start ``` +The first gateway version has no authentication, matching +`docs/development-spec.md`. Bind to `127.0.0.1` by default, and expose +`0.0.0.0` only behind a trusted network such as Tailscale. + ## Configuration | Variable | Purpose | diff --git a/gateway/src/agents.js b/gateway/src/agents.js index aa89dd1..150a985 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -354,7 +354,19 @@ function runJsonCli({ onAgentSessionId, onExit, }) { - const child = spawnCli(command, args, { cwd }); + let child; + try { + child = spawnCli(command, args, { cwd }); + } catch (error) { + onExit({ + exitCode: -1, + error: error.message, + }); + return { + pid: null, + abort() {}, + }; + } const state = { lastFullTextByKey: new Map(), sawText: false, diff --git a/gateway/src/cli.js b/gateway/src/cli.js index 0d2cdd2..eb8a324 100644 --- a/gateway/src/cli.js +++ b/gateway/src/cli.js @@ -152,7 +152,13 @@ function readLines(stream, onLine, onChunk) { async function runCapture(spec, args, options = {}) { return new Promise((resolve) => { - const child = spawnCli(spec, args, options); + let child; + try { + child = spawnCli(spec, args, options); + } catch (error) { + resolve({ exitCode: -1, stdout: '', stderr: error.message }); + return; + } let stdout = ''; let stderr = ''; let settled = false; diff --git a/gateway/src/store.js b/gateway/src/store.js index 2910e87..c4e1382 100644 --- a/gateway/src/store.js +++ b/gateway/src/store.js @@ -8,6 +8,7 @@ const path = require('node:path'); class JsonStore { constructor(file) { this.file = file; + this.saveQueue = Promise.resolve(); this.data = { projects: [], sessions: [], @@ -35,8 +36,17 @@ class JsonStore { } async save() { + const run = this.saveQueue.then( + () => this.writeSnapshot(), + () => this.writeSnapshot(), + ); + this.saveQueue = run.catch(() => {}); + return run; + } + + async writeSnapshot() { await fs.mkdir(path.dirname(this.file), { recursive: true }); - const temp = `${this.file}.${process.pid}.${Date.now()}.tmp`; + const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`; await fs.writeFile(temp, `${JSON.stringify(this.data, null, 2)}\n`, 'utf8'); await fs.rename(temp, this.file); } From 19f03c64d5c53ff894a023b905a6ffc72291fdde Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 20:29:19 +0800 Subject: [PATCH 04/10] feat: proxy OpenCode through server API --- gateway/README.md | 15 +- gateway/src/agents.js | 316 ++++++++++++++++++++++++++- gateway/src/index.js | 11 + gateway/src/opencode_server.js | 272 +++++++++++++++++++++++ gateway/src/server.js | 247 ++++++++++++++------- gateway/src/store.js | 6 +- gateway/test/opencode_server.test.js | 56 +++++ gateway/test/server.test.js | 282 +++++++++++++++++++++++- 8 files changed, 1112 insertions(+), 93 deletions(-) create mode 100644 gateway/src/opencode_server.js create mode 100644 gateway/test/opencode_server.test.js diff --git a/gateway/README.md b/gateway/README.md index 55c69a2..7e217d7 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -7,7 +7,8 @@ agent execution; the app only talks to this server. - Codex: `codex exec --json` - Claude Code: `claude -p --output-format stream-json --verbose` -- OpenCode: `opencode run --format json` +- OpenCode: `opencode serve` HTTP/SSE proxy, with `opencode run --format json` + fallback when server mode is unavailable The gateway uses the CLI login state already configured on this machine. @@ -51,6 +52,18 @@ The first gateway version has no authentication, matching | `CLAUDE_CODE_MODELS` | Comma-separated Claude model aliases to show in the picker. | | `CLAUDE_CODE_PERMISSION_MODE` | Optional Claude permission mode, for example `acceptEdits` or `dontAsk`. | | `OPENCODE_BIN` | Override OpenCode executable path. | +| `OPENCODE_SERVER_URL` | Use an existing OpenCode server instead of starting `opencode serve`. | +| `OPENCODE_SERVER_PASSWORD` | Password for an existing OpenCode server; sent with OpenCode's Basic auth scheme. Gateway-started OpenCode is local-only and does not set one by default. | +| `OPENCODE_SERVER_HOST` | Host for gateway-started OpenCode server, default `127.0.0.1`. | +| `OPENCODE_SERVER_PORT` | Port for gateway-started OpenCode server, default is a free port. | +| `OPENCODE_SERVER_START_TIMEOUT_MS` | Startup wait for `opencode serve`, default `45000`. | +| `OPENCODE_DEFAULT_MODEL` | Fallback model id when the app did not choose one, default `opencode/big-pickle`. | +| `OPENCODE_MODE` | OpenCode message mode, default `build`. | + +For OpenCode, the gateway creates a native OpenCode session through +`POST /session?directory=...`, stores that id as `agentSessionId`, sends turns +through `POST /session/:id/message`, and bridges the global `/event` SSE stream +back into the gateway's per-session event endpoint. ## API diff --git a/gateway/src/agents.js b/gateway/src/agents.js index 150a985..28c6a6e 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -14,6 +14,7 @@ const { runCapture, spawnCli, } = require('./cli'); +const { OpenCodeServerManager } = require('./opencode_server'); const CODEX_COMMANDS = [ '/permissions', @@ -103,11 +104,13 @@ const OPENCODE_COMMANDS = [ ]; class AgentRegistry { - constructor() { + constructor({ openCodeServer } = {}) { this.adapters = new Map( - [new CodexAdapter(), new ClaudeCodeAdapter(), new OpenCodeAdapter()].map( - (adapter) => [adapter.id, adapter], - ), + [ + new CodexAdapter(), + new ClaudeCodeAdapter(), + new OpenCodeAdapter({ server: openCodeServer }), + ].map((adapter) => [adapter.id, adapter]), ); } @@ -120,6 +123,12 @@ class AgentRegistry { [...this.adapters.values()].map((adapter) => adapter.metadata(projectDirectory)), ); } + + close() { + for (const adapter of this.adapters.values()) { + adapter.close?.(); + } + } } class CodexAdapter { @@ -280,10 +289,11 @@ class ClaudeCodeAdapter { } class OpenCodeAdapter { - constructor() { + constructor({ command, server } = {}) { this.id = 'opencode'; this.displayName = 'OpenCode'; - this.command = resolveOpenCodeCommand(); + this.command = command || resolveOpenCodeCommand(); + this.server = server || new OpenCodeServerManager({ command: this.command }); } async metadata(projectDirectory) { @@ -297,14 +307,22 @@ class OpenCodeAdapter { sessionKind: 'session', commands: await this.commands(projectDirectory), raw: { - available: commandExists(this.command), + available: commandExists(this.command) || Boolean(this.server.externalBaseUrl), command: publicCommand(this.command), + serverUrl: this.server.baseUrl || this.server.externalBaseUrl || null, projectDirectory, }, }; } async models() { + try { + const providers = await this.server.request('/provider'); + const models = providerModels(providers); + if (models.length > 0) return models; + } catch (_) { + // Fall back to the CLI's static model list when server mode is unavailable. + } const result = await runCapture(this.command, ['models']); if (result.exitCode === 0) { return result.stdout @@ -324,6 +342,122 @@ class OpenCodeAdapter { ]); } + async createSession({ project, title }) { + const query = project?.directory + ? `?directory=${encodeURIComponent(project.directory)}` + : ''; + const raw = await this.server.request(`/session${query}`, { + method: 'POST', + body: {}, + }); + const agentSessionId = raw && typeof raw.id === 'string' ? raw.id : null; + if (!agentSessionId) throw new Error('OpenCode did not return a session id'); + return { + agentSessionId, + title: + typeof raw.title === 'string' && raw.title.trim() + ? raw.title + : title || 'OpenCode session', + raw, + }; + } + + async listMessages(session) { + if (!session.agentSessionId) return null; + return await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}/message`, + ); + } + + async abort(session) { + if (!session.agentSessionId) return false; + await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}/abort`, + { method: 'POST' }, + ); + return true; + } + + async deleteSession(session) { + if (!session.agentSessionId) return false; + await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}`, + { method: 'DELETE' }, + ); + return true; + } + + async runNative({ session, prompt, parts = [], onEvent, onExit }) { + if (!session.agentSessionId) return null; + + const abortController = new AbortController(); + let settled = false; + let sent = false; + let completionTimer = null; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(completionTimer); + abortController.abort(); + onExit(result); + }; + const markCompletedSoon = (result = { exitCode: 0 }) => { + clearTimeout(completionTimer); + completionTimer = setTimeout(() => finish(result), 250); + }; + + const stream = this.server.openEventStream({ + signal: abortController.signal, + onEvent: (raw, eventName) => { + const event = normalizeOpenCodeEvent(raw, eventName); + if (!event) return; + const remoteSessionId = openCodeEventSessionId(raw); + if (remoteSessionId && remoteSessionId !== session.agentSessionId) return; + onEvent(event); + const terminal = openCodeTerminalResult(raw); + if (terminal) markCompletedSoon(terminal); + }, + }); + await stream.opened; + + const { providerId, modelId } = splitOpenCodeModel(session.modelId); + const messageParts = [ + ...(prompt.trim() ? [{ type: 'text', text: prompt }] : []), + ...parts.filter((part) => part && typeof part === 'object'), + ]; + sent = true; + this.server + .request(`/session/${encodeURIComponent(session.agentSessionId)}/message`, { + method: 'POST', + body: { + providerID: providerId, + modelID: modelId, + mode: process.env.OPENCODE_MODE || 'build', + parts: messageParts, + }, + signal: abortController.signal, + }) + .then(() => {}) + .catch((error) => { + if (!abortController.signal.aborted) { + finish({ exitCode: -1, error: error.message }); + } + }); + stream.done.catch((error) => { + if (!abortController.signal.aborted && sent) { + finish({ exitCode: -1, error: error.message }); + } + }); + + return { + pid: null, + abort: () => { + this.abort(session).catch(() => {}); + finish({ exitCode: -1, error: 'aborted' }); + }, + }; + } + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { const args = ['run', '--format', 'json', '--dir', session.directory]; if (session.modelId) args.push('--model', session.modelId); @@ -341,6 +475,172 @@ class OpenCodeAdapter { onExit, }); } + + close() { + this.server.close?.(); + } +} + +function providerModels(payload) { + const all = Array.isArray(payload?.all) ? payload.all : []; + const out = []; + for (const provider of all) { + if (!provider || typeof provider !== 'object') continue; + const providerId = provider.id || provider.providerID; + if (!providerId) continue; + const providerName = provider.name || providerId; + const models = provider.models || {}; + for (const [modelId, model] of Object.entries(models)) { + out.push({ + id: `${providerId}/${modelId}`, + displayName: `${providerName} / ${model?.name || modelId}`, + raw: compactOpenCodeModel(providerId, modelId, model), + }); + } + } + return out; +} + +function compactOpenCodeModel(providerId, modelId, model) { + return { + providerID: providerId, + modelID: modelId, + name: model?.name, + toolCall: model?.tool_call, + attachment: model?.attachment, + reasoning: model?.reasoning, + limit: model?.limit, + }; +} + +function splitOpenCodeModel(value) { + const fallback = process.env.OPENCODE_DEFAULT_MODEL || 'opencode/big-pickle'; + const text = String(value || fallback); + const slash = text.indexOf('/'); + if (slash === -1) { + return { + providerId: process.env.OPENCODE_DEFAULT_PROVIDER || 'opencode', + modelId: text, + }; + } + return { + providerId: text.slice(0, slash), + modelId: text.slice(slash + 1), + }; +} + +function normalizeOpenCodeEvent(raw, eventName = 'message') { + if (!raw || typeof raw !== 'object') return null; + const type = raw.type || eventName; + const properties = raw.properties || raw.data || {}; + switch (type) { + case 'message.updated': { + const info = properties.info || raw.info || raw.message || null; + return { + type, + data: info ? { info } : properties, + raw, + }; + } + case 'message.part.updated': { + const part = properties.part || raw.part || null; + return { + type, + data: part ? { part } : properties, + raw, + }; + } + case 'message.part.delta': + return { + type, + data: { + sessionID: properties.sessionID || raw.sessionID, + messageID: properties.messageID || raw.messageID, + partID: properties.partID || raw.partID, + field: properties.field || raw.field || 'text', + delta: properties.delta ?? raw.delta ?? '', + }, + raw, + }; + case 'session.updated': + return { + type: 'status.updated', + data: { + status: 'running', + source: 'opencode', + eventType: type, + session: properties.info || properties.session || raw.session || properties, + }, + raw, + }; + case 'session.error': + return { + type, + data: { error: openCodeErrorMessage(raw) }, + raw, + }; + case 'session.idle': + return { + type: 'status.updated', + data: { + status: 'idle', + source: 'opencode', + eventType: type, + session: properties.info || properties.session || raw.session || properties, + }, + raw, + }; + default: + return { + type: 'command.updated', + data: { source: 'opencode', eventType: type, properties }, + raw, + }; + } +} + +function openCodeEventSessionId(raw) { + const properties = raw.properties || raw.data || {}; + return ( + properties.sessionID || + raw.sessionID || + properties.sessionId || + raw.sessionId || + properties.session?.id || + raw.session?.id || + properties.info?.sessionID || + raw.info?.sessionID || + raw.message?.sessionID || + properties.part?.sessionID || + raw.part?.sessionID || + (String(raw.type || '').startsWith('session.') ? properties.info?.id : null) || + null + ); +} + +function openCodeTerminalResult(raw) { + const type = raw?.type; + const properties = raw?.properties || raw?.data || {}; + const info = properties.info || raw?.info || raw?.message || {}; + if (type === 'session.error') { + return { exitCode: -1, error: openCodeErrorMessage(raw) }; + } + if (info.status === 'error') { + return { exitCode: -1, error: openCodeErrorMessage(raw) }; + } + if (type === 'session.idle' || type === 'session.completed') { + return { exitCode: 0 }; + } + if (info.role === 'assistant' && info.status === 'completed') { + return { exitCode: 0 }; + } + return null; +} + +function openCodeErrorMessage(raw) { + const properties = raw?.properties || raw?.data || {}; + const error = properties.error || raw?.error || {}; + return error.message || raw?.message || 'OpenCode error'; } function runJsonCli({ @@ -581,4 +881,6 @@ function compactCodexModel(model) { module.exports = { AgentRegistry, + OpenCodeAdapter, + normalizeOpenCodeEvent, }; diff --git a/gateway/src/index.js b/gateway/src/index.js index 0d30443..cf3d211 100644 --- a/gateway/src/index.js +++ b/gateway/src/index.js @@ -12,6 +12,17 @@ const dataFile = async function main() { const server = await createGatewayServer({ dataFile }); + let closing = false; + const shutdown = () => { + if (closing) return; + closing = true; + server.closeAllRuns?.(); + server.close(() => { + process.exitCode = 0; + }); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); server.listen(port, host, () => { const address = server.address(); const actualPort = diff --git a/gateway/src/opencode_server.js b/gateway/src/opencode_server.js new file mode 100644 index 0000000..6537b99 --- /dev/null +++ b/gateway/src/opencode_server.js @@ -0,0 +1,272 @@ +'use strict'; + +const net = require('node:net'); + +const { killProcessTree, spawnCli } = require('./cli'); + +class OpenCodeServerManager { + constructor({ command, baseUrl, password, startTimeoutMs } = {}) { + this.command = command; + this.externalBaseUrl = normalizeBaseUrl( + baseUrl || process.env.OPENCODE_SERVER_URL || '', + ); + this.baseUrl = this.externalBaseUrl || null; + this.password = + password ?? (this.externalBaseUrl ? process.env.OPENCODE_SERVER_PASSWORD || '' : ''); + this.startTimeoutMs = + startTimeoutMs ?? (Number(process.env.OPENCODE_SERVER_START_TIMEOUT_MS) || 45000); + this.child = null; + this.ensurePromise = null; + this.logs = ''; + } + + async ensure() { + if (this.ensurePromise) return this.ensurePromise; + this.ensurePromise = this.baseUrl ? this.waitUntilReady() : this.start(); + try { + return await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + async start() { + if (!this.command) throw new Error('OpenCode command is not configured'); + const hostname = process.env.OPENCODE_SERVER_HOST || '127.0.0.1'; + const port = Number(process.env.OPENCODE_SERVER_PORT) || (await getFreePort()); + this.baseUrl = `http://${hostname}:${port}`; + + const args = [ + 'serve', + '--hostname', + hostname, + '--port', + String(port), + '--log-level', + process.env.OPENCODE_LOG_LEVEL || 'ERROR', + ]; + if (process.env.OPENCODE_PURE === '1') args.push('--pure'); + + try { + this.child = spawnCli(this.command, args, { + env: { + ...(this.password ? { OPENCODE_SERVER_PASSWORD: this.password } : {}), + }, + }); + } catch (error) { + this.baseUrl = this.externalBaseUrl || null; + throw error; + } + captureLogs(this.child.stdout, (chunk) => this.appendLog(chunk)); + captureLogs(this.child.stderr, (chunk) => this.appendLog(chunk)); + this.child.once('exit', (code) => { + if (code !== 0) this.appendLog(`OpenCode server exited with code ${code}`); + }); + + try { + return await this.waitUntilReady(); + } catch (error) { + this.close(); + this.baseUrl = null; + throw error; + } + } + + async waitUntilReady() { + const startedAt = Date.now(); + let lastError = null; + while (Date.now() - startedAt < this.startTimeoutMs) { + try { + const response = await fetch(new URL('/session', this.baseUrl), { + headers: this.headers({ Accept: 'application/json' }), + signal: AbortSignal.timeout(1500), + }); + if (response.ok) return this.baseUrl; + if (response.status === 401 || response.status === 403) { + throw Object.assign( + new Error(`OpenCode server rejected gateway credentials: HTTP ${response.status}`), + { nonRetryable: true }, + ); + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + if (error.nonRetryable) throw error; + lastError = error; + } + await delay(150); + } + const suffix = this.logs ? `\n${this.logs}` : lastError ? `: ${lastError.message}` : ''; + throw new Error(`OpenCode server did not become ready at ${this.baseUrl}${suffix}`); + } + + async request(route, { method = 'GET', body, signal, headers = {} } = {}) { + const baseUrl = await this.ensure(); + const response = await fetch(new URL(route, ensureTrailingSlash(baseUrl)), { + method, + headers: this.headers({ + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...headers, + }), + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `OpenCode ${method} ${route} failed: HTTP ${response.status}${text ? ` ${trim(text)}` : ''}`, + ); + } + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch (_) { + return text; + } + } + + openEventStream({ signal, onEvent }) { + const opened = {}; + opened.promise = new Promise((resolve, reject) => { + opened.resolve = resolve; + opened.reject = reject; + }); + + const done = (async () => { + const baseUrl = await this.ensure(); + const response = await fetch(new URL('/event', ensureTrailingSlash(baseUrl)), { + headers: this.headers({ Accept: 'text/event-stream' }), + signal, + }); + if (!response.ok) { + throw new Error(`OpenCode event stream failed: HTTP ${response.status}`); + } + opened.resolve(); + await parseSse(response, signal, onEvent); + })(); + + done.catch((error) => opened.reject(error)); + return { opened: opened.promise, done }; + } + + headers(extra = {}) { + return { + ...extra, + ...(this.password ? { Authorization: `Basic ${basicPassword(this.password)}` } : {}), + }; + } + + appendLog(chunk) { + this.logs = `${this.logs}${chunk.toString('utf8')}`; + if (this.logs.length > 4000) this.logs = this.logs.slice(-4000); + } + + close() { + if (this.child) { + killProcessTree(this.child); + this.child = null; + } + } +} + +async function parseSse(response, signal, onEvent) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let eventName = 'message'; + const dataLines = []; + + const abort = () => { + reader.cancel().catch(() => {}); + }; + signal?.addEventListener('abort', abort, { once: true }); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.search(/\r?\n/)) !== -1) { + const rawLine = buffer.slice(0, newline); + buffer = buffer.slice(buffer[newline] === '\r' ? newline + 2 : newline + 1); + if (rawLine === '') { + dispatchSseEvent(eventName, dataLines, onEvent); + eventName = 'message'; + dataLines.length = 0; + continue; + } + if (rawLine.startsWith(':')) continue; + const colon = rawLine.indexOf(':'); + const field = colon === -1 ? rawLine : rawLine.slice(0, colon); + const value = + colon === -1 + ? '' + : rawLine[colon + 1] === ' ' + ? rawLine.slice(colon + 2) + : rawLine.slice(colon + 1); + if (field === 'event') eventName = value || 'message'; + if (field === 'data') dataLines.push(value); + } + } + dispatchSseEvent(eventName, dataLines, onEvent); + } finally { + signal?.removeEventListener('abort', abort); + } +} + +function dispatchSseEvent(eventName, dataLines, onEvent) { + if (dataLines.length === 0) return; + const data = dataLines.join('\n'); + let parsed; + try { + parsed = JSON.parse(data); + } catch (_) { + parsed = { type: eventName, _raw: data }; + } + onEvent(parsed, eventName); +} + +function captureLogs(stream, onChunk) { + stream.setEncoding('utf8'); + stream.on('data', onChunk); +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +function normalizeBaseUrl(value) { + const text = String(value || '').trim(); + if (!text) return ''; + return text.replace(/\/+$/, ''); +} + +function ensureTrailingSlash(value) { + return value.endsWith('/') ? value : `${value}/`; +} + +function trim(value) { + const text = String(value).replace(/\s+/g, ' ').trim(); + return text.length > 500 ? `${text.slice(0, 500)}...` : text; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function basicPassword(password) { + return Buffer.from(`opencode:${password}`, 'utf8').toString('base64'); +} + +module.exports = { + OpenCodeServerManager, +}; diff --git a/gateway/src/server.js b/gateway/src/server.js index 09952cb..803fab5 100644 --- a/gateway/src/server.js +++ b/gateway/src/server.js @@ -105,6 +105,7 @@ async function createGatewayServer({ dataFile, adapters } = {}) { server.closeAllRuns = () => { for (const run of activeRuns.values()) run.abort(); activeRuns.clear(); + registry.close?.(); }; return server; } @@ -141,11 +142,25 @@ async function handleProjects({ request, response, segments, store, registry }) const body = await readJson(request); const adapter = registry.get(body.agentId); if (!adapter) throw httpError(400, `unknown agent: ${body.agentId}`); + let nativeSession = null; + if (adapter.createSession) { + try { + nativeSession = await adapter.createSession({ + project, + modelId: body.modelId, + title: body.title || `${adapter.displayName} session`, + }); + } catch (_) { + nativeSession = null; + } + } const session = await store.createSession({ project, agentId: body.agentId, modelId: body.modelId, - title: body.title || `${adapter.displayName} session`, + title: body.title || nativeSession?.title || `${adapter.displayName} session`, + agentSessionId: nativeSession?.agentSessionId, + raw: nativeSession ? { agentSession: nativeSession.raw } : {}, }); return sendJson(response, session, 201); } @@ -196,21 +211,34 @@ async function handleSessions({ const run = activeRuns.get(session.id); if (run) run.abort(); activeRuns.delete(session.id); + const adapter = registry.get(session.agentId); + if (adapter?.deleteSession) { + await adapter.deleteSession(session).catch(() => false); + } await store.deleteSession(session.id); return sendJson(response, { ok: true }); } if (segments.length === 3 && segments[2] === 'messages') { if (request.method === 'GET') { + const adapter = registry.get(session.agentId); + if (adapter?.listMessages) { + const messages = await adapter.listMessages(session).catch(() => null); + if (Array.isArray(messages)) return sendJson(response, messages); + } return sendJson(response, store.listMessages(session.id)); } if (request.method === 'POST') { const body = await readJson(request); const text = typeof body.text === 'string' ? body.text : ''; - if (!text.trim()) throw httpError(400, 'text is required'); + const parts = Array.isArray(body.parts) ? body.parts : []; + if (!text.trim() && parts.length === 0) { + throw httpError(400, 'text or parts are required'); + } await startTurn({ session, text, + parts, store, registry, bus, @@ -224,7 +252,14 @@ async function handleSessions({ const run = activeRuns.get(session.id); if (run) run.abort(); activeRuns.delete(session.id); - const updated = await store.updateSession(session.id, { status: 'idle' }); + const adapter = registry.get(session.agentId); + if (!run && adapter?.abort) { + await adapter.abort(session).catch(() => false); + } + const updated = await store.updateSession(session.id, { + status: 'idle', + raw: run ? { lastAborted: true } : {}, + }); emit(bus, 'session.updated', updated, { session: updated }); return sendJson(response, { ok: true }); } @@ -244,100 +279,152 @@ async function handleSessions({ throw httpError(404, 'not found'); } -async function startTurn({ session, text, store, registry, bus, activeRuns }) { +async function startTurn({ session, text, parts = [], store, registry, bus, activeRuns }) { if (activeRuns.has(session.id)) { throw httpError(409, 'session already running'); } const adapter = registry.get(session.agentId); if (!adapter) throw httpError(400, `unknown agent: ${session.agentId}`); - - const userMessage = createTextMessage({ - sessionId: session.id, - role: 'user', - text, - }); - await store.appendMessage(session.id, userMessage); - emit(bus, 'message.created', session, { message: userMessage }, userMessage); + const canRunNative = Boolean(adapter.runNative && session.agentSessionId); const running = await store.updateSession(session.id, { status: 'running' }); emit(bus, 'session.started', running, { session: running }); emit(bus, 'session.updated', running, { session: running }); - let assistantMessage = createTextMessage({ - sessionId: session.id, - role: 'assistant', - text: '', - status: 'running', - modelId: session.modelId, - }); + let assistantMessage = null; let textWrite = Promise.resolve(); - await store.appendMessage(session.id, assistantMessage); - emit(bus, 'message.created', session, { message: assistantMessage }, assistantMessage); - - const partId = assistantMessage.parts[0].id; - const run = adapter.run({ - session: running, - prompt: text, - onEvent: ({ type, data, raw }) => emit(bus, type, running, data, raw), - onText: (delta) => { - if (!delta) return; - textWrite = textWrite.then(async () => { - assistantMessage = appendTextToMessage(assistantMessage, delta); - await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); - emit( - bus, - 'message.delta', - running, - { - messageId: assistantMessage.id, - partId, - field: 'text', - delta, - }, - { delta }, - ); + let partId = null; + let run = null; + let aborted = false; + + if (canRunNative) { + try { + run = await adapter.runNative({ + session: running, + prompt: text, + parts, + onEvent: ({ type, data, raw }) => emit(bus, type, running, data, raw), + onExit: handleExit, }); - return textWrite; - }, - onAgentSessionId: async (agentSessionId, raw) => { - if (agentSessionId && agentSessionId !== session.agentSessionId) { - session.agentSessionId = agentSessionId; - await store.updateSession(session.id, { - agentSessionId, - raw: { agentSession: raw }, + } catch (error) { + emit( + bus, + 'command.updated', + running, + { + source: running.agentId, + eventType: 'native-fallback', + error: error.message, + }, + { error: error.message }, + ); + run = null; + } + } + + if (!run) { + const userMessage = createTextMessage({ + sessionId: session.id, + role: 'user', + text, + }); + await store.appendMessage(session.id, userMessage); + emit(bus, 'message.created', session, { message: userMessage }, userMessage); + + assistantMessage = createTextMessage({ + sessionId: session.id, + role: 'assistant', + text: '', + status: 'running', + modelId: session.modelId, + }); + await store.appendMessage(session.id, assistantMessage); + emit(bus, 'message.created', session, { message: assistantMessage }, assistantMessage); + partId = assistantMessage.parts[0].id; + + run = adapter.run({ + session: running, + prompt: text, + onEvent: ({ type, data, raw }) => emit(bus, type, running, data, raw), + onText: (delta) => { + if (!delta) return; + textWrite = textWrite.then(async () => { + assistantMessage = appendTextToMessage(assistantMessage, delta); + await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); + emit( + bus, + 'message.delta', + running, + { + messageId: assistantMessage.id, + partId, + field: 'text', + delta, + }, + { delta }, + ); }); - } + return textWrite; + }, + onAgentSessionId: async (agentSessionId, raw) => { + if (agentSessionId && agentSessionId !== session.agentSessionId) { + session.agentSessionId = agentSessionId; + await store.updateSession(session.id, { + agentSessionId, + raw: { agentSession: raw }, + }); + } + }, + onExit: handleExit, + }); + } + + activeRuns.set(session.id, { + ...run, + abort() { + aborted = true; + run.abort(); }, - onExit: async ({ exitCode, error }) => { - activeRuns.delete(session.id); - await textWrite; - const finalStatus = exitCode === 0 ? 'completed' : 'error'; + }); + + async function handleExit({ exitCode, error }) { + activeRuns.delete(session.id); + await textWrite; + if (assistantMessage) { + const finalStatus = exitCode === 0 || aborted ? 'completed' : 'error'; assistantMessage = completeMessage(assistantMessage, finalStatus); await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); - emit(bus, 'message.completed', running, { message: assistantMessage }, assistantMessage); + } + emit( + bus, + 'message.completed', + running, + assistantMessage ? { message: assistantMessage } : {}, + assistantMessage || {}, + ); - const updated = await store.updateSession(session.id, { - status: exitCode === 0 ? 'idle' : 'error', - raw: { - lastExitCode: exitCode, - lastError: error || null, - }, - }); - if (exitCode === 0) { - emit(bus, 'session.completed', updated, { session: updated }); - } else { - emit( - bus, - 'session.error', - updated, - { error: error || `agent exited with code ${exitCode}` }, - { exitCode, error }, - ); - } - emit(bus, 'session.updated', updated, { session: updated }); - }, - }); - activeRuns.set(session.id, run); + const updated = await store.updateSession(session.id, { + status: exitCode === 0 || aborted ? 'idle' : 'error', + raw: { + lastExitCode: exitCode, + lastError: error || null, + lastAborted: aborted || null, + }, + }); + if (!updated) return; + if (!aborted && exitCode === 0) { + emit(bus, 'session.completed', updated, { session: updated }); + } else if (!aborted) { + emit( + bus, + 'session.error', + updated, + { error: error || `agent exited with code ${exitCode}` }, + { exitCode, error }, + ); + } + emit(bus, 'session.updated', updated, { session: updated }); + } } function emit(bus, type, session, data = {}, raw = {}) { diff --git a/gateway/src/store.js b/gateway/src/store.js index c4e1382..f0fc4f9 100644 --- a/gateway/src/store.js +++ b/gateway/src/store.js @@ -104,7 +104,7 @@ class JsonStore { .sort((a, b) => b.updatedAt - a.updatedAt); } - async createSession({ project, agentId, modelId, title }) { + async createSession({ project, agentId, modelId, title, agentSessionId, raw }) { const now = Date.now(); const session = { id: crypto.randomUUID(), @@ -116,8 +116,8 @@ class JsonStore { status: 'idle', createdAt: now, updatedAt: now, - agentSessionId: null, - raw: {}, + agentSessionId: agentSessionId || null, + raw: raw && typeof raw === 'object' ? raw : {}, }; this.data.sessions.push(session); this.data.messagesBySession[session.id] = []; diff --git a/gateway/test/opencode_server.test.js b/gateway/test/opencode_server.test.js new file mode 100644 index 0000000..4712587 --- /dev/null +++ b/gateway/test/opencode_server.test.js @@ -0,0 +1,56 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const http = require('node:http'); +const test = require('node:test'); + +const { OpenCodeServerManager } = require('../src/opencode_server'); + +test('OpenCodeServerManager uses OpenCode Basic auth for external servers', async (t) => { + let authorization = null; + const server = http.createServer((request, response) => { + authorization = request.headers.authorization || null; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify([])); + }); + await listen(server); + t.after(() => server.close()); + + const manager = new OpenCodeServerManager({ + baseUrl: `http://127.0.0.1:${server.address().port}`, + password: 'secret', + startTimeoutMs: 500, + }); + await manager.request('/session'); + + assert.equal( + authorization, + `Basic ${Buffer.from('opencode:secret', 'utf8').toString('base64')}`, + ); +}); + +test('OpenCodeServerManager fails fast on rejected credentials', async (t) => { + const server = http.createServer((_, response) => { + response.writeHead(401); + response.end(); + }); + await listen(server); + t.after(() => server.close()); + + const manager = new OpenCodeServerManager({ + baseUrl: `http://127.0.0.1:${server.address().port}`, + password: 'wrong', + startTimeoutMs: 30000, + }); + + await assert.rejects( + manager.request('/session'), + /OpenCode server rejected gateway credentials/, + ); +}); + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); +} diff --git a/gateway/test/server.test.js b/gateway/test/server.test.js index 778485b..5e22679 100644 --- a/gateway/test/server.test.js +++ b/gateway/test/server.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const test = require('node:test'); const { createGatewayServer } = require('../src/server'); +const { OpenCodeAdapter } = require('../src/agents'); test('gateway exposes projects, sessions, messages, and SSE events', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-gateway-')); @@ -52,9 +53,149 @@ test('gateway exposes projects, sessions, messages, and SSE events', async (t) = assert.equal(messages[1].parts[0].text, 'fake response'); }); +test('gateway proxies OpenCode sessions through server API and SSE', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-opencode-')); + const dataFile = path.join(root, 'store.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const fakeOpenCode = new FakeOpenCodeServer(); + const adapters = new SingleAdapterRegistry( + new OpenCodeAdapter({ + command: { command: 'missing-opencode', prefixArgs: [], shell: false }, + server: fakeOpenCode, + }), + ); + const server = await createGatewayServer({ dataFile, adapters }); + await listen(server); + t.after(() => { + server.closeAllRuns?.(); + server.close(); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const project = await postJson(`${base}/projects`, { directory: projectDir }); + const models = await getJson(`${base}/agents/opencode/models`); + assert.equal(models.models[0].id, 'anthropic/claude-sonnet-4'); + + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'opencode', + modelId: 'anthropic/claude-sonnet-4', + }); + assert.equal(session.agentSessionId, 'oc-1'); + + const events = collectSseUntil( + `${base}/sessions/${session.id}/events`, + (event) => event.type === 'session.completed', + ); + const accepted = await postJson(`${base}/sessions/${session.id}/messages`, { + text: 'hello', + parts: [{ type: 'file', path: 'README.md' }], + }); + assert.equal(accepted.accepted, true); + + const received = await events; + assert(received.some((event) => event.type === 'message.updated')); + assert(received.some((event) => event.type === 'message.part.delta')); + assert(received.some((event) => event.type === 'message.completed')); + assert.equal(fakeOpenCode.sentMessages[0].providerID, 'anthropic'); + assert.equal(fakeOpenCode.sentMessages[0].modelID, 'claude-sonnet-4'); + assert.deepEqual(fakeOpenCode.sentMessages[0].parts, [ + { type: 'text', text: 'hello' }, + { type: 'file', path: 'README.md' }, + ]); + + const messages = await getJson(`${base}/sessions/${session.id}/messages`); + assert.equal(messages[0].info.id, 'oc-message-1'); + assert.equal(messages[0].parts[0].text, 'native response'); + + const deleted = await deleteJson(`${base}/sessions/${session.id}`); + assert.equal(deleted.ok, true); + assert.equal(fakeOpenCode.deletedSessionId, 'oc-1'); +}); + +test('OpenCode abort delegates to native server when no local run is active', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-opencode-abort-')); + const dataFile = path.join(root, 'store.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const fakeOpenCode = new FakeOpenCodeServer(); + const adapters = new SingleAdapterRegistry( + new OpenCodeAdapter({ + command: { command: 'missing-opencode', prefixArgs: [], shell: false }, + server: fakeOpenCode, + }), + ); + const server = await createGatewayServer({ dataFile, adapters }); + await listen(server); + t.after(() => { + server.closeAllRuns?.(); + server.close(); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const project = await postJson(`${base}/projects`, { directory: projectDir }); + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'opencode', + }); + + const aborted = await postJson(`${base}/sessions/${session.id}/abort`, {}); + assert.equal(aborted.ok, true); + assert.equal(fakeOpenCode.abortedSessionId, 'oc-1'); +}); + +test('aborting an active run returns the session to idle without error state', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-gateway-abort-')); + const dataFile = path.join(root, 'store.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const adapters = new FakeRegistry(new HangingAdapter()); + const server = await createGatewayServer({ dataFile, adapters }); + await listen(server); + t.after(() => { + server.closeAllRuns?.(); + server.close(); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const project = await postJson(`${base}/projects`, { directory: projectDir }); + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'fake', + }); + + await postJson(`${base}/sessions/${session.id}/messages`, { text: 'wait' }); + const aborted = await postJson(`${base}/sessions/${session.id}/abort`, {}); + assert.equal(aborted.ok, true); + + const updated = await getJson(`${base}/sessions/${session.id}`); + assert.equal(updated.status, 'idle'); + assert.equal(updated.raw.lastAborted, true); + assert.notEqual(updated.status, 'error'); +}); + +class SingleAdapterRegistry { + constructor(adapter) { + this.adapter = adapter; + } + + get(agentId) { + return agentId === this.adapter.id ? this.adapter : null; + } + + async list() { + return [await this.adapter.metadata()]; + } + + close() { + this.adapter.close?.(); + } +} + class FakeRegistry { - constructor() { - this.adapter = new FakeAdapter(); + constructor(adapter = new FakeAdapter()) { + this.adapter = adapter; } get(agentId) { @@ -104,6 +245,135 @@ class FakeAdapter { } } +class HangingAdapter extends FakeAdapter { + run({ onExit }) { + return { + abort() { + onExit({ exitCode: -1, error: 'aborted' }); + }, + }; + } +} + +class FakeOpenCodeServer { + constructor() { + this.sentMessages = []; + this.deletedSessionId = null; + this.abortedSessionId = null; + this.eventHandlers = []; + } + + async request(route, { method = 'GET', body } = {}) { + if (method === 'GET' && route === '/provider') { + return { + all: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + tool_call: true, + }, + }, + }, + ], + }; + } + if (method === 'POST' && route.startsWith('/session?')) { + return { + id: 'oc-1', + title: 'OpenCode native', + directory: route, + time: { created: 1, updated: 1 }, + }; + } + if (method === 'GET' && route === '/session/oc-1/message') { + return [ + { + info: { + id: 'oc-message-1', + sessionID: 'oc-1', + role: 'assistant', + status: 'completed', + time: { created: 1, completed: 2 }, + }, + parts: [ + { + id: 'oc-part-1', + sessionID: 'oc-1', + messageID: 'oc-message-1', + type: 'text', + text: 'native response', + }, + ], + }, + ]; + } + if (method === 'POST' && route === '/session/oc-1/message') { + this.sentMessages.push(body); + setImmediate(() => { + this.emit({ + type: 'message.updated', + properties: { + info: { + id: 'oc-message-1', + sessionID: 'oc-1', + role: 'assistant', + status: 'running', + time: { created: 1 }, + }, + }, + }); + this.emit({ + type: 'message.part.delta', + properties: { + sessionID: 'oc-1', + messageID: 'oc-message-1', + partID: 'oc-part-1', + field: 'text', + delta: 'native response', + }, + }); + this.emit({ + type: 'session.idle', + properties: { info: { id: 'oc-1' } }, + }); + }); + return { ok: true }; + } + if (method === 'POST' && route === '/session/oc-1/abort') { + this.abortedSessionId = 'oc-1'; + return true; + } + if (method === 'DELETE' && route === '/session/oc-1') { + this.deletedSessionId = 'oc-1'; + return true; + } + throw new Error(`unexpected fake OpenCode request: ${method} ${route}`); + } + + openEventStream({ signal, onEvent }) { + this.eventHandlers.push(onEvent); + signal?.addEventListener('abort', () => { + this.eventHandlers = this.eventHandlers.filter((handler) => handler !== onEvent); + }); + return { + opened: Promise.resolve(), + done: new Promise(() => {}), + }; + } + + emit(event) { + for (const handler of this.eventHandlers) { + handler(event, 'message'); + } + } + + close() {} +} + function listen(server) { return new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); @@ -130,6 +400,14 @@ async function postJson(url, body) { return JSON.parse(text); } +async function deleteJson(url) { + const response = await fetch(url, { method: 'DELETE' }); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} + async function collectSseUntil(url, predicate) { const response = await fetch(url); assert.equal(response.ok, true); From 2a3ed57d55af6ff863a954fe4b5efc36f6bbc88c Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 20:55:20 +0800 Subject: [PATCH 05/10] ci: skip pods when building SPM iOS app --- .github/workflows/ios.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index cd2b855..c3380b6 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -49,7 +49,9 @@ jobs: run: | set -euo pipefail cd ios - pod install --repo-update + if [ -f Podfile ]; then + pod install --repo-update + fi xcodebuild \ -workspace Runner.xcworkspace \ -scheme Runner \ From a017ce2039d57d51062c7404a63fcaed1a01745b Mon Sep 17 00:00:00 2001 From: botbot Date: Tue, 19 May 2026 21:16:05 +0800 Subject: [PATCH 06/10] ci: pin iOS Flutter version --- .github/workflows/ios.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index c3380b6..4124e7b 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -23,6 +23,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.27.1 cache: true - run: flutter pub get @@ -31,7 +32,7 @@ jobs: # Wipe any stale derived data / pods that may be cached. Without this, # back-to-back runs occasionally fail with "Building a deployable iOS # app requires a selected Development Team" even though we pass - # --no-codesign — a known macos-runner cache-pollution issue. + # --no-codesign ??a known macos-runner cache-pollution issue. - name: Clean build state run: | flutter clean From 69741f62cda21396bbd8b7063a377714c39aff91 Mon Sep 17 00:00:00 2001 From: botbot Date: Tue, 19 May 2026 21:25:54 +0800 Subject: [PATCH 07/10] fix: build unsigned iOS IPA with Flutter 3.27 --- ios/Runner/AppDelegate.swift | 7 ++----- ios/Runner/Info.plist | 23 +---------------------- ios/Runner/SceneDelegate.swift | 4 +--- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..6266644 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,15 +2,12 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index edb1873..fad3567 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,4 +1,4 @@ - + @@ -31,27 +31,6 @@ NSAllowsArbitraryLoads -UIApplicationSceneManifest - -UIApplicationSupportsMultipleScenes - -UISceneConfigurations - -UIWindowSceneSessionRoleApplication - - -UISceneClassName -UIWindowScene -UISceneConfigurationName -flutter -UISceneDelegateClassName -$(PRODUCT_MODULE_NAME).SceneDelegate -UISceneStoryboardFile -Main - - - - UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index b9ce8ea..e6c70b8 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -1,6 +1,4 @@ import Flutter import UIKit -class SceneDelegate: FlutterSceneDelegate { - -} +class SceneDelegate: UIResponder, UIWindowSceneDelegate {} From 2b466829ae0587f7e0cea92f680b834c63db75c2 Mon Sep 17 00:00:00 2001 From: botbot Date: Tue, 19 May 2026 22:13:27 +0800 Subject: [PATCH 08/10] fix: connect settings to gateway protocol --- lib/ui/pages/gateway_ui_adapters.dart | 6 +++-- lib/ui/pages/settings_page.dart | 34 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/ui/pages/gateway_ui_adapters.dart b/lib/ui/pages/gateway_ui_adapters.dart index c7045e4..9da147a 100644 --- a/lib/ui/pages/gateway_ui_adapters.dart +++ b/lib/ui/pages/gateway_ui_adapters.dart @@ -195,8 +195,10 @@ GatewaySessionView readSession(dynamic value) { final map = _asMap(value); if (map.isEmpty) { - final nested = _property(value, 'session'); - if (nested != null) return readSession(nested); + try { + final nested = _property(value, 'session'); + if (nested != null) return readSession(nested); + } catch (_) {/* ignore */} } final model = _asMap(map['model']); final objectStatus = _property(value, 'status'); diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index fbc92f7..c6ef625 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../api/opencode_client.dart'; +import '../../api/gateway_client.dart'; import '../../state/settings_store.dart'; import '../widgets/model_picker.dart'; import 'home_page.dart'; @@ -48,11 +48,11 @@ class _SettingsPageState extends ConsumerState { _testOk = null; }); try { - final client = OpencodeClient( + final client = GatewayClient( baseUrl: Uri.parse(_baseUrlCtrl.text.trim()), bearerToken: _tokenCtrl.text.trim(), ); - final ok = await client.ping(); + final ok = await client.health(); if (!ok) { setState(() { _testOk = false; @@ -61,7 +61,21 @@ class _SettingsPageState extends ConsumerState { client.close(); return; } - final models = await client.listProviderModels(); + final agents = await client.listAgents(); + final models = []; + for (final agent in agents) { + if (!agent.supportsModels) continue; + final agentModels = await client.listAgentModels(agent.id); + models.addAll( + agentModels.map( + (model) => ( + providerId: agent.id, + modelId: model.id, + label: '${agent.displayName} ? ${model.displayName}', + ), + ), + ); + } client.close(); if (!mounted) return; setState(() { @@ -151,7 +165,7 @@ class _SettingsPageState extends ConsumerState { padding: const EdgeInsets.all(16), children: [ Text( - 'Connect to your OpenCode server', + 'Connect to your agent gateway', style: theme.textTheme.titleMedium, ), const SizedBox(height: 16), @@ -169,7 +183,7 @@ class _SettingsPageState extends ConsumerState { TextField( controller: _tokenCtrl, decoration: const InputDecoration( - labelText: 'Bearer token (OPENCODE_SERVER_PASSWORD)', + labelText: 'Bearer token (optional)', prefixIcon: Icon(Icons.lock_outline), ), autocorrect: false, @@ -187,7 +201,7 @@ class _SettingsPageState extends ConsumerState { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.bolt_outlined), - label: Text(_testing ? 'Testing…' : 'Test & load models'), + label: Text(_testing ? 'Testing?? : 'Test & load models'), ), const SizedBox(width: 12), if (_testOk == true) @@ -232,7 +246,7 @@ class _SettingsPageState extends ConsumerState { } } -/// Card-style tile that shows the chosen `provider · model` and opens the picker. +/// Card-style tile that shows the chosen `provider ? model` and opens the picker. class _ModelTile extends StatelessWidget { const _ModelTile({ required this.providerId, @@ -277,8 +291,8 @@ class _ModelTile extends StatelessWidget { const SizedBox(height: 2), Text( isEmpty - ? '$modelCount models available · tap to choose' - : 'Provider: $providerId · $modelCount available', + ? '$modelCount models available ? tap to choose' + : 'Provider: $providerId ? $modelCount available', style: theme.textTheme.labelSmall, ), ], From b3f8715d79360869aa8db7e3932d24a727b20e54 Mon Sep 17 00:00:00 2001 From: botbot Date: Tue, 19 May 2026 22:22:00 +0800 Subject: [PATCH 09/10] fix: avoid non-ascii settings labels --- lib/ui/pages/settings_page.dart | 570 ++++++++++++++++---------------- 1 file changed, 285 insertions(+), 285 deletions(-) diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index c6ef625..c356fa9 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -1,66 +1,66 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../api/gateway_client.dart'; -import '../../state/settings_store.dart'; -import '../widgets/model_picker.dart'; -import 'home_page.dart'; - -class SettingsPage extends ConsumerStatefulWidget { - const SettingsPage({super.key, this.firstRun = false}); - final bool firstRun; - - @override - ConsumerState createState() => _SettingsPageState(); -} - -class _SettingsPageState extends ConsumerState { - late final TextEditingController _baseUrlCtrl; - late final TextEditingController _tokenCtrl; - String _providerId = ''; - String _modelId = ''; - List _models = const []; - bool _testing = false; - String? _testError; - bool? _testOk; - - @override - void initState() { - super.initState(); - final s = ref.read(settingsControllerProvider); - _baseUrlCtrl = TextEditingController(text: s.baseUrl); - _tokenCtrl = TextEditingController(text: s.bearerToken); - _providerId = s.providerId; - _modelId = s.modelId; - } - - @override - void dispose() { - _baseUrlCtrl.dispose(); - _tokenCtrl.dispose(); - super.dispose(); - } - - Future _testAndLoadModels() async { - setState(() { - _testing = true; - _testError = null; - _testOk = null; - }); - try { +import '../../state/settings_store.dart'; +import '../widgets/model_picker.dart'; +import 'home_page.dart'; + +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key, this.firstRun = false}); + final bool firstRun; + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + late final TextEditingController _baseUrlCtrl; + late final TextEditingController _tokenCtrl; + String _providerId = ''; + String _modelId = ''; + List _models = const []; + bool _testing = false; + String? _testError; + bool? _testOk; + + @override + void initState() { + super.initState(); + final s = ref.read(settingsControllerProvider); + _baseUrlCtrl = TextEditingController(text: s.baseUrl); + _tokenCtrl = TextEditingController(text: s.bearerToken); + _providerId = s.providerId; + _modelId = s.modelId; + } + + @override + void dispose() { + _baseUrlCtrl.dispose(); + _tokenCtrl.dispose(); + super.dispose(); + } + + Future _testAndLoadModels() async { + setState(() { + _testing = true; + _testError = null; + _testOk = null; + }); + try { final client = GatewayClient( baseUrl: Uri.parse(_baseUrlCtrl.text.trim()), bearerToken: _tokenCtrl.text.trim(), ); final ok = await client.health(); - if (!ok) { - setState(() { - _testOk = false; - _testError = 'Server unreachable.'; - }); - client.close(); - return; - } + if (!ok) { + setState(() { + _testOk = false; + _testError = 'Server unreachable.'; + }); + client.close(); + return; + } final agents = await client.listAgents(); final models = []; for (final agent in agents) { @@ -71,238 +71,238 @@ class _SettingsPageState extends ConsumerState { (model) => ( providerId: agent.id, modelId: model.id, - label: '${agent.displayName} ? ${model.displayName}', + label: '${agent.displayName} / ${model.displayName}', ), ), ); } - client.close(); - if (!mounted) return; - setState(() { - _models = models; - _testOk = true; - // If the current selection is no longer offered, drop to the first. - final exists = models.any( - (m) => m.providerId == _providerId && m.modelId == _modelId, - ); - if (!exists && models.isNotEmpty) { - _providerId = models.first.providerId; - _modelId = models.first.modelId; - } - }); - } catch (err) { - setState(() { - _testOk = false; - _testError = '$err'; - }); - } finally { - if (mounted) setState(() => _testing = false); - } - } - - Future _save() async { - final controller = ref.read(settingsControllerProvider.notifier); - await controller.update( - AppSettings( - baseUrl: _baseUrlCtrl.text.trim(), - bearerToken: _tokenCtrl.text.trim(), - providerId: _providerId, - modelId: _modelId, - ), - ); - if (!mounted) return; - if (widget.firstRun) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomePage()), - ); - } else { - // When embedded in the Settings tab, just show a confirmation. - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Settings saved')), - ); - } - } - - Future _openModelPicker() async { - final selected = _models.isEmpty - ? null - : _models.firstWhere( - (m) => m.providerId == _providerId && m.modelId == _modelId, - orElse: () => _models.first, - ); - final picked = await showModelPicker( - context, - models: _models, - selected: selected, - ); - if (picked == null) return; - setState(() { - _providerId = picked.providerId; - _modelId = picked.modelId; - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final canSave = _baseUrlCtrl.text.trim().isNotEmpty && - _providerId.isNotEmpty && - _modelId.isNotEmpty; - - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - automaticallyImplyLeading: widget.firstRun, - actions: [ - if (!widget.firstRun) - TextButton( - onPressed: canSave ? _save : null, - child: const Text('Save'), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ + client.close(); + if (!mounted) return; + setState(() { + _models = models; + _testOk = true; + // If the current selection is no longer offered, drop to the first. + final exists = models.any( + (m) => m.providerId == _providerId && m.modelId == _modelId, + ); + if (!exists && models.isNotEmpty) { + _providerId = models.first.providerId; + _modelId = models.first.modelId; + } + }); + } catch (err) { + setState(() { + _testOk = false; + _testError = '$err'; + }); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + Future _save() async { + final controller = ref.read(settingsControllerProvider.notifier); + await controller.update( + AppSettings( + baseUrl: _baseUrlCtrl.text.trim(), + bearerToken: _tokenCtrl.text.trim(), + providerId: _providerId, + modelId: _modelId, + ), + ); + if (!mounted) return; + if (widget.firstRun) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomePage()), + ); + } else { + // When embedded in the Settings tab, just show a confirmation. + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings saved')), + ); + } + } + + Future _openModelPicker() async { + final selected = _models.isEmpty + ? null + : _models.firstWhere( + (m) => m.providerId == _providerId && m.modelId == _modelId, + orElse: () => _models.first, + ); + final picked = await showModelPicker( + context, + models: _models, + selected: selected, + ); + if (picked == null) return; + setState(() { + _providerId = picked.providerId; + _modelId = picked.modelId; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final canSave = _baseUrlCtrl.text.trim().isNotEmpty && + _providerId.isNotEmpty && + _modelId.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + automaticallyImplyLeading: widget.firstRun, + actions: [ + if (!widget.firstRun) + TextButton( + onPressed: canSave ? _save : null, + child: const Text('Save'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ Text( 'Connect to your agent gateway', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 16), - TextField( - controller: _baseUrlCtrl, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'http://100.x.x.x:4096', - prefixIcon: Icon(Icons.dns_outlined), - ), - keyboardType: TextInputType.url, - autocorrect: false, - ), - const SizedBox(height: 12), - TextField( - controller: _tokenCtrl, - decoration: const InputDecoration( + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextField( + controller: _baseUrlCtrl, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'http://100.x.x.x:4096', + prefixIcon: Icon(Icons.dns_outlined), + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + const SizedBox(height: 12), + TextField( + controller: _tokenCtrl, + decoration: const InputDecoration( labelText: 'Bearer token (optional)', - prefixIcon: Icon(Icons.lock_outline), - ), - autocorrect: false, - obscureText: true, - ), - const SizedBox(height: 16), - Row( - children: [ - FilledButton.icon( - onPressed: _testing ? null : _testAndLoadModels, - icon: _testing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bolt_outlined), - label: Text(_testing ? 'Testing?? : 'Test & load models'), - ), - const SizedBox(width: 12), - if (_testOk == true) - const Icon(Icons.check_circle, color: Colors.green), - if (_testOk == false) - Tooltip( - message: _testError ?? '', - child: const Icon(Icons.error, color: Colors.red), - ), - ], - ), - if (_testError != null) ...[ - const SizedBox(height: 8), - Text( - _testError!, - style: TextStyle(color: theme.colorScheme.error), - ), - ], - if (_models.isNotEmpty) ...[ - const SizedBox(height: 24), - Text('Default model', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - _ModelTile( - providerId: _providerId, - modelId: _modelId, - modelCount: _models.length, - onTap: _openModelPicker, - ), - ], - const SizedBox(height: 32), - if (widget.firstRun) - FilledButton.tonal( - onPressed: canSave ? _save : null, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Continue'), - ), - ), - ], - ), - ); - } -} - -/// Card-style tile that shows the chosen `provider ? model` and opens the picker. -class _ModelTile extends StatelessWidget { - const _ModelTile({ - required this.providerId, - required this.modelId, - required this.modelCount, - required this.onTap, - }); - - final String providerId; - final String modelId; - final int modelCount; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEmpty = providerId.isEmpty || modelId.isEmpty; - return Material( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.psychology_outlined), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isEmpty ? 'Select a model' : modelId, - style: theme.textTheme.titleSmall?.copyWith( - fontFamily: 'monospace', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - isEmpty - ? '$modelCount models available ? tap to choose' - : 'Provider: $providerId ? $modelCount available', - style: theme.textTheme.labelSmall, - ), - ], - ), - ), - const Icon(Icons.unfold_more), - ], - ), - ), - ), - ); - } -} + prefixIcon: Icon(Icons.lock_outline), + ), + autocorrect: false, + obscureText: true, + ), + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _testing ? null : _testAndLoadModels, + icon: _testing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bolt_outlined), + label: Text(_testing ? 'Testing...' : 'Test & load models'), + ), + const SizedBox(width: 12), + if (_testOk == true) + const Icon(Icons.check_circle, color: Colors.green), + if (_testOk == false) + Tooltip( + message: _testError ?? '', + child: const Icon(Icons.error, color: Colors.red), + ), + ], + ), + if (_testError != null) ...[ + const SizedBox(height: 8), + Text( + _testError!, + style: TextStyle(color: theme.colorScheme.error), + ), + ], + if (_models.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('Default model', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + _ModelTile( + providerId: _providerId, + modelId: _modelId, + modelCount: _models.length, + onTap: _openModelPicker, + ), + ], + const SizedBox(height: 32), + if (widget.firstRun) + FilledButton.tonal( + onPressed: canSave ? _save : null, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Continue'), + ), + ), + ], + ), + ); + } +} + +/// Card-style tile that shows the chosen `provider / model` and opens the picker. +class _ModelTile extends StatelessWidget { + const _ModelTile({ + required this.providerId, + required this.modelId, + required this.modelCount, + required this.onTap, + }); + + final String providerId; + final String modelId; + final int modelCount; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEmpty = providerId.isEmpty || modelId.isEmpty; + return Material( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.psychology_outlined), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEmpty ? 'Select a model' : modelId, + style: theme.textTheme.titleSmall?.copyWith( + fontFamily: 'monospace', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + isEmpty + ? '$modelCount models available / tap to choose' + : 'Provider: $providerId / $modelCount available', + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + const Icon(Icons.unfold_more), + ], + ), + ), + ), + ); + } +} From ce8b4ebc3d333f1a21a404eb7068c2da432c91a6 Mon Sep 17 00:00:00 2001 From: botlong Date: Tue, 19 May 2026 22:50:59 +0800 Subject: [PATCH 10/10] fix: add searchable model picker --- gateway/README.md | 1 - gateway/src/agents.js | 11 +++- gateway/src/server.js | 23 ++++++++ gateway/test/server.test.js | 47 ++++++++++++++++ lib/ui/pages/agent_group_page.dart | 90 ++++++++++++++++++++++++++---- lib/ui/widgets/model_picker.dart | 7 +++ pubspec.yaml | 2 +- 7 files changed, 166 insertions(+), 15 deletions(-) diff --git a/gateway/README.md b/gateway/README.md index 7e217d7..03f3a6a 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -47,7 +47,6 @@ The first gateway version has no authentication, matching | `GATEWAY_DIRECTORIES` | Extra roots returned by `GET /directories`, separated by OS path delimiter. | | `CODEX_BIN` | Override Codex executable path. | | `CODEX_SANDBOX` | Codex sandbox mode, default `workspace-write`. | -| `CODEX_APPROVAL_POLICY` | Codex approval policy, default `never`. | | `CLAUDE_CODE_BIN` | Override Claude Code executable path. | | `CLAUDE_CODE_MODELS` | Comma-separated Claude model aliases to show in the picker. | | `CLAUDE_CODE_PERMISSION_MODE` | Optional Claude permission mode, for example `acceptEdits` or `dontAsk`. | diff --git a/gateway/src/agents.js b/gateway/src/agents.js index 28c6a6e..411f8a1 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -198,8 +198,6 @@ class CodexAdapter { session.directory, '--sandbox', process.env.CODEX_SANDBOX || 'workspace-write', - '--ask-for-approval', - process.env.CODEX_APPROVAL_POLICY || 'never', '--skip-git-repo-check', ]; if (session.modelId) args.push('--model', session.modelId); @@ -670,6 +668,7 @@ function runJsonCli({ const state = { lastFullTextByKey: new Map(), sawText: false, + stderrLines: [], }; readLines(child.stdout, (line) => { const raw = parseJsonLine(line); @@ -692,6 +691,8 @@ function runJsonCli({ } }); readLines(child.stderr, (line) => { + state.stderrLines.push(line); + if (state.stderrLines.length > 80) state.stderrLines.shift(); onEvent({ type: 'command.updated', data: { stream: 'stderr', text: line }, @@ -716,7 +717,11 @@ function runJsonCli({ }); }); child.on('close', (exitCode) => { - finish({ exitCode }); + const stderr = state.stderrLines.join('\n').trim(); + finish({ + exitCode, + error: exitCode === 0 ? null : stderr || `agent exited with code ${exitCode}`, + }); }); return { pid: child.pid, diff --git a/gateway/src/server.js b/gateway/src/server.js index 803fab5..92c6bee 100644 --- a/gateway/src/server.js +++ b/gateway/src/server.js @@ -392,6 +392,23 @@ async function startTurn({ session, text, parts = [], store, registry, bus, acti await textWrite; if (assistantMessage) { const finalStatus = exitCode === 0 || aborted ? 'completed' : 'error'; + const errorText = error || `agent exited with code ${exitCode}`; + if (finalStatus === 'error' && !messageText(assistantMessage).trim()) { + assistantMessage = appendTextToMessage(assistantMessage, errorText); + await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); + emit( + bus, + 'message.delta', + running, + { + messageId: assistantMessage.id, + partId: assistantMessage.parts?.[0]?.id || partId, + field: 'text', + delta: errorText, + }, + { delta: errorText }, + ); + } assistantMessage = completeMessage(assistantMessage, finalStatus); await store.updateMessage(session.id, assistantMessage.id, () => assistantMessage); } @@ -427,6 +444,12 @@ async function startTurn({ session, text, parts = [], store, registry, bus, acti } } +function messageText(message) { + return (message.parts || []) + .map((part) => (typeof part.text === 'string' ? part.text : '')) + .join(''); +} + function emit(bus, type, session, data = {}, raw = {}) { bus.emit( makeEvent({ diff --git a/gateway/test/server.test.js b/gateway/test/server.test.js index 5e22679..bfeb6d9 100644 --- a/gateway/test/server.test.js +++ b/gateway/test/server.test.js @@ -175,6 +175,44 @@ test('aborting an active run returns the session to idle without error state', a assert.notEqual(updated.status, 'error'); }); +test('gateway stores CLI stderr when an adapter exits with an error', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-gateway-error-')); + const dataFile = path.join(root, 'store.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const adapters = new FakeRegistry(new FailingAdapter()); + const server = await createGatewayServer({ dataFile, adapters }); + await listen(server); + t.after(() => { + server.closeAllRuns?.(); + server.close(); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const project = await postJson(`${base}/projects`, { directory: projectDir }); + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'fake', + }); + + const events = collectSseUntil( + `${base}/sessions/${session.id}/events`, + (event) => event.type === 'session.error', + ); + await postJson(`${base}/sessions/${session.id}/messages`, { text: 'fail' }); + const received = await events; + assert(received.some((event) => event.type === 'message.delta')); + + const updated = await getJson(`${base}/sessions/${session.id}`); + assert.equal(updated.status, 'error'); + assert.equal(updated.raw.lastExitCode, 2); + assert.equal(updated.raw.lastError, 'cli usage error'); + + const messages = await getJson(`${base}/sessions/${session.id}/messages`); + assert.equal(messages[1].status, 'error'); + assert.equal(messages[1].parts[0].text, 'cli usage error'); +}); + class SingleAdapterRegistry { constructor(adapter) { this.adapter = adapter; @@ -255,6 +293,15 @@ class HangingAdapter extends FakeAdapter { } } +class FailingAdapter extends FakeAdapter { + run({ onExit }) { + setImmediate(() => onExit({ exitCode: 2, error: 'cli usage error' })); + return { + abort() {}, + }; + } +} + class FakeOpenCodeServer { constructor() { this.sentMessages = []; diff --git a/lib/ui/pages/agent_group_page.dart b/lib/ui/pages/agent_group_page.dart index aaba3ae..5e2e1f7 100644 --- a/lib/ui/pages/agent_group_page.dart +++ b/lib/ui/pages/agent_group_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../state/gateway_providers.dart'; import '../widgets/agent_badge.dart'; +import '../widgets/model_picker.dart'; import 'gateway_chat_page.dart'; import 'gateway_ui_adapters.dart'; @@ -104,6 +105,7 @@ class _AgentGroupPageState extends ConsumerState { }); } return _ModelPicker( + agentId: _selectedAgent!.id, models: models, selected: _selectedModel, onSelected: (model) => setState(() => _selectedModel = model), @@ -239,11 +241,13 @@ class _AgentOption extends StatelessWidget { class _ModelPicker extends StatelessWidget { const _ModelPicker({ + required this.agentId, required this.models, required this.selected, required this.onSelected, }); + final String agentId; final List models; final GatewayModelView? selected; final ValueChanged onSelected; @@ -256,18 +260,84 @@ class _ModelPicker extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, ); } - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final model in models) - ChoiceChip( - label: Text(model.displayName), - selected: selected?.id == model.id, - onSelected: (_) => onSelected(model), + final theme = Theme.of(context); + final selectedLabel = selected == null + ? 'Select a model' + : selected!.displayName.trim().isEmpty + ? selected!.id + : selected!.displayName; + return Material( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _open(context), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.manage_search_outlined), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 2), + Text( + '${models.length} models available / tap to search', + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + const Icon(Icons.unfold_more), + ], ), - ], + ), + ), + ); + } + + Future _open(BuildContext context) async { + final choices = [ + for (final model in models) + ( + providerId: agentId, + modelId: model.id, + label: + model.displayName.trim().isEmpty ? model.id : model.displayName, + ), + ]; + final picked = await showModelPicker( + context, + models: choices, + selected: selected == null + ? null + : ( + providerId: agentId, + modelId: selected!.id, + label: selected!.displayName.trim().isEmpty + ? selected!.id + : selected!.displayName, + ), + ); + if (picked == null) return; + final match = models.firstWhere( + (model) => model.id == picked.modelId, + orElse: () => GatewayModelView( + id: picked.modelId, + displayName: picked.label, + ), ); + onSelected(match); } } diff --git a/lib/ui/widgets/model_picker.dart b/lib/ui/widgets/model_picker.dart index 0b10230..fd72927 100644 --- a/lib/ui/widgets/model_picker.dart +++ b/lib/ui/widgets/model_picker.dart @@ -192,6 +192,13 @@ class _ModelRow extends StatelessWidget { : theme.colorScheme.onSurface, ), ), + subtitle: choice.label.trim().isEmpty || choice.label == choice.modelId + ? null + : Text( + choice.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), trailing: isSelected ? Icon(Icons.check, color: theme.colorScheme.primary, size: 18) : null, diff --git a/pubspec.yaml b/pubspec.yaml index 106c524..d1a9b02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: remote_multi_agent description: A mobile client for OpenCode — connect to a remote OpenCode server and tail its event stream. publish_to: "none" -version: 0.1.0+1 +version: 0.1.1+2 environment: sdk: ^3.5.0