Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"agentChats": "Chats",
"agentConversations": "Conversations",
"agentCouldNotAddModel": "Could not add model",
"agentCouldNotFetchModels": "Could not fetch models",
"agentCouldNotAddSkill": "Could not add skill",
"agentCouldNotSaveMcpServer": "Could not save MCP server",
"agentCouldNotSaveProvider": "Could not save provider",
Expand All @@ -77,12 +78,14 @@
"agentEditMcpServer": "Edit MCP server",
"agentEditProvider": "Edit provider",
"agentEditSkill": "Edit skill",
"agentDiscoveredModels": "Available models",
"agentEmptyHint": "Describe the task. The agent can inspect any saved server.",
"agentEnabled": "Enabled",
"agentEnvironmentInvalidJson": "Environment must be a JSON object.",
"agentError": "Error: {}",
"agentGhostConversation": "Don't save this conversation",
"agentGhostHint": "Ghost chat — messages are not saved to history.",
"agentFetchModels": "Fetch models",
"agentHoursAgo": "{}h ago",
"agentImportMcpAdded": "Added {} servers",
"agentImportMcpConfig": "Import MCP config",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"agentChats": "聊天",
"agentConversations": "对话",
"agentCouldNotAddModel": "无法添加模型",
"agentCouldNotFetchModels": "无法获取模型列表",
"agentCouldNotAddSkill": "无法添加技能",
"agentCouldNotSaveMcpServer": "无法保存 MCP 服务器",
"agentCouldNotSaveProvider": "无法保存提供方",
Expand All @@ -77,12 +78,14 @@
"agentEditMcpServer": "编辑 MCP 服务器",
"agentEditProvider": "编辑提供方",
"agentEditSkill": "编辑技能",
"agentDiscoveredModels": "可用模型",
"agentEmptyHint": "描述任务。助手可以检查任何已保存的服务器。",
"agentEnabled": "已启用",
"agentEnvironmentInvalidJson": "环境变量必须是 JSON 对象。",
"agentError": "错误:{}",
"agentGhostConversation": "不保存此对话",
"agentGhostHint": "幽灵聊天 — 消息不会保存到历史记录。",
"agentFetchModels": "获取模型列表",
"agentHoursAgo": "{} 小时前",
"agentImportMcpAdded": "已添加 {} 个服务器",
"agentImportMcpConfig": "导入 MCP 配置",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"agentChats": "聊天",
"agentConversations": "對話",
"agentCouldNotAddModel": "無法新增模型",
"agentCouldNotFetchModels": "無法取得模型清單",
"agentCouldNotAddSkill": "無法新增技能",
"agentCouldNotSaveMcpServer": "無法儲存 MCP 伺服器",
"agentCouldNotSaveProvider": "無法儲存提供方",
Expand All @@ -77,12 +78,14 @@
"agentEditMcpServer": "編輯 MCP 伺服器",
"agentEditProvider": "編輯提供方",
"agentEditSkill": "編輯技能",
"agentDiscoveredModels": "可用模型",
"agentEmptyHint": "描述任務。助手可以檢查任何已儲存的伺服器。",
"agentEnabled": "已啟用",
"agentEnvironmentInvalidJson": "環境變數必須是 JSON 物件。",
"agentError": "錯誤:{}",
"agentGhostConversation": "不儲存此對話",
"agentGhostHint": "幽靈聊天 — 訊息不會儲存到歷史記錄。",
"agentFetchModels": "取得模型清單",
"agentHoursAgo": "{} 小時前",
"agentImportMcpAdded": "已新增 {} 個伺服器",
"agentImportMcpConfig": "匯入 MCP 配置",
Expand Down
54 changes: 54 additions & 0 deletions lib/agent/agent_model_catalog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:dio/dio.dart';

class AgentModelCatalog {
AgentModelCatalog({Dio? dio}) : _dio = dio ?? Dio();

final Dio _dio;

Future<List<String>> fetchModels({
required String baseUrl,
required String apiKey,
}) async {
final endpoint = modelsEndpoint(baseUrl);
final response = await _dio.get<dynamic>(
endpoint.toString(),
options: Options(
headers: {
'Accept': 'application/json',
if (apiKey.trim().isNotEmpty)
'Authorization': 'Bearer ${apiKey.trim()}',
},
),
);
final body = response.data;
if (body is! Map) {
throw const FormatException('The models response is not an object.');
}
final data = body['data'];
if (data is! List) {
throw const FormatException('The models response has no data list.');
}
final models = <String>{};
for (final item in data) {
if (item is! Map) continue;
final id = item['id']?.toString().trim();
if (id != null && id.isNotEmpty) models.add(id);
}
if (models.isEmpty) {
throw const FormatException('The provider returned no model IDs.');
}
return models.toList()..sort();
}
}

Uri modelsEndpoint(String baseUrl) {
final value = baseUrl.trim().replaceFirst(RegExp(r'/+$'), '');
final base = Uri.tryParse(value);
if (base == null || !base.hasScheme || base.host.isEmpty) {
throw ArgumentError.value(baseUrl, 'baseUrl', 'must be an absolute URL');
}
final path = base.path.endsWith('/v1')
? '${base.path}/models'
: '${base.path}/v1/models';
return base.replace(path: path, query: null, fragment: null);
}
59 changes: 59 additions & 0 deletions lib/agent/agent_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,9 @@ class _AgentPageState extends ConsumerState<AgentPage> {
useRootNavigator: true,
builder: (sheetContext) => _AgentProviderEditorSheet(
existing: existing,
onFetchModels: (apiKey, baseUrl) => ref
.read(agentModelCatalogProvider)
.fetchModels(baseUrl: baseUrl, apiKey: apiKey),
onSave: (draft) async {
try {
await ref
Expand Down Expand Up @@ -1756,9 +1759,12 @@ class _ConversationTile extends StatelessWidget {
class _AgentProviderEditorSheet extends StatefulWidget {
const _AgentProviderEditorSheet({
required this.existing,
required this.onFetchModels,
required this.onSave,
});
final AgentProvider? existing;
final Future<List<String>> Function(String apiKey, String baseUrl)
onFetchModels;
final Future<void> Function(AgentProviderDraft draft) onSave;

@override
Expand Down Expand Up @@ -1850,6 +1856,8 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> {
late final _model = TextEditingController(
text: widget.existing?.model ?? 'gpt-4o-mini',
);
var _models = <String>[];
var _fetchingModels = false;
var _saving = false;

@override
Expand All @@ -1865,19 +1873,42 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> {
if (_saving) return;
setState(() => _saving = true);
try {
if (_key.text.trim().isNotEmpty && _models.isEmpty) {
await _fetchModels(showError: false);
}
await widget.onSave(
AgentProviderDraft(
name: _name.text,
apiKey: _key.text,
baseUrl: _endpoint.text,
model: _model.text,
models: _models,
),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}

Future<void> _fetchModels({bool showError = true}) async {
if (_fetchingModels) return;
setState(() => _fetchingModels = true);
try {
final models = await widget.onFetchModels(_key.text, _endpoint.text);
if (!mounted) return;
setState(() {
_models = models;
if (!models.contains(_model.text)) _model.text = models.first;
});
} catch (error) {
if (showError) {
showMaidKitErrorAlert(error, title: 'agentCouldNotFetchModels'.tr());
}
} finally {
if (mounted) setState(() => _fetchingModels = false);
}
}

@override
Widget build(BuildContext context) => SheetScaffold(
titleText: widget.existing == null
Expand All @@ -1901,6 +1932,7 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> {
_name.text = preset.name;
_endpoint.text = preset.baseUrl;
_model.text = preset.models.first;
_models = [];
});
},
),
Expand Down Expand Up @@ -1932,6 +1964,33 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> {
decoration: InputDecoration(labelText: 'agentBaseUrl'.tr()),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _fetchingModels ? null : _fetchModels,
icon: _fetchingModels
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Symbols.refresh),
label: Text('agentFetchModels'.tr()),
),
if (_models.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _models.contains(_model.text) ? _model.text : null,
decoration: InputDecoration(
labelText: 'agentDiscoveredModels'.tr(),
),
items: [
for (final model in _models)
DropdownMenuItem(value: model, child: Text(model)),
],
onChanged: (model) {
if (model != null) _model.text = model;
},
),
],
const SizedBox(height: 12),
TextField(
controller: _model,
onSubmitted: (_) => _save(),
Expand Down
17 changes: 8 additions & 9 deletions lib/agent/agent_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ class AgentProviderDraft {
required this.apiKey,
required this.baseUrl,
required this.model,
this.models = const [],
});
final String name;

/// Leave empty when editing to retain the stored key.
final String apiKey;
final String? baseUrl;
final String model;
final List<String> models;
}

class AgentRepository {
Expand Down Expand Up @@ -94,20 +96,17 @@ class AgentRepository {
updatedAt: DateTime.now().toUtc(),
),
);
await _database
.into(_database.agentProviderModels)
.insert(
AgentProviderModelsCompanion.insert(
providerId: providerId,
model: model,
createdAt: DateTime.now().toUtc(),
),
);
for (final discoveredModel in {model, ...draft.models}) {
await addModel(providerId, discoveredModel);
}
} else {
await (_database.update(
_database.agentProviders,
)..where((table) => table.id.equals(existing.id))).write(values);
await addModel(existing.id, model);
for (final discoveredModel in draft.models) {
await addModel(existing.id, discoveredModel);
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions lib/servers/server_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:maid_kit/agent/mcp_repository.dart';
import 'package:maid_kit/agent/skill_repository.dart';
import 'package:maid_kit/agent/skill_registry.dart';
import 'package:maid_kit/agent/agent_repository.dart';
import 'package:maid_kit/agent/agent_model_catalog.dart';
import 'package:maid_kit/agent/conversation_store.dart';
import 'package:maid_kit/agent/agent_personality.dart';
import 'package:maid_kit/agent/agent_run_policy.dart';
Expand Down Expand Up @@ -426,6 +427,10 @@ final agentRepositoryProvider = Provider<AgentRepository>((ref) {
);
});

final agentModelCatalogProvider = Provider<AgentModelCatalog>(
(ref) => AgentModelCatalog(),
);

final agentConfiguredProvider = StreamProvider<bool>((ref) {
return ref
.watch(agentRepositoryProvider)
Expand Down
69 changes: 69 additions & 0 deletions test/agent_model_catalog_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:maid_kit/agent/agent_model_catalog.dart';

class _FakeAdapter implements HttpClientAdapter {
_FakeAdapter(this.body);

final String body;
RequestOptions? request;

@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
request = options;
return ResponseBody.fromString(
body,
200,
headers: const {
'content-type': ['application/json'],
},
);
}

@override
void close({bool force = false}) {}
}

void main() {
test('builds model endpoints with or without an existing v1 path', () {
expect(
modelsEndpoint('https://api.openai.com').toString(),
'https://api.openai.com/v1/models',
);
expect(
modelsEndpoint('https://openrouter.ai/api/v1/').toString(),
'https://openrouter.ai/api/v1/models',
);
});

test('fetches, deduplicates, and sorts OpenAI-compatible models', () async {
final adapter = _FakeAdapter(
jsonEncode({
'data': [
{'id': 'z-model'},
{'id': 'a-model'},
{'id': 'z-model'},
],
}),
);
final dio = Dio()..httpClientAdapter = adapter;

final models = await AgentModelCatalog(
dio: dio,
).fetchModels(baseUrl: 'https://api.example.com', apiKey: 'secret');

expect(models, ['a-model', 'z-model']);
expect(
adapter.request!.uri.toString(),
'https://api.example.com/v1/models',
);
expect(adapter.request!.headers['Authorization'], 'Bearer secret');
});
}
Loading