* refactor: replace super_clipboard with pasteboard, drop cargokit/Rust
super_clipboard pulled in super_native_extensions (a Rust native layer
built via cargokit), whose precompiled-binary download from GitHub
release assets has been intermittently failing the build ("Connection
closed while receiving data"). It was used for exactly one feature -
reading a clipboard image into the composer - in a single file; the
other 12 imports were dead.
- Swap super_clipboard -> pasteboard (platform-channel, no Rust).
- Rewrite readClipboardImageAsXFileInternal() on Pasteboard.image
(PNG bytes), collapsing three helpers into one.
- Remove 12 unused super_clipboard imports.
- Regenerated plugin registrants / lockfiles drop super_native_extensions.
Removes the Rust toolchain requirement and the flaky download entirely.
Text copy/paste already used Flutter's built-in Clipboard and is
unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 6e31064cd2)
* ci: keep TestFlight package release-only
(cherry picked from commit bd500d66c7)
* ci: refresh release validation for #55 backport
---------
Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
982 lines
31 KiB
Dart
982 lines
31 KiB
Dart
// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member
|
|
|
|
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'package:file_selector/file_selector.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:markdown/markdown.dart' as md;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import '../../app/app_controller.dart';
|
|
import '../../app/app_controller_desktop_thread_binding.dart';
|
|
import '../../app/app_metadata.dart';
|
|
import '../../app/ui_feature_manifest.dart';
|
|
import '../../i18n/app_language.dart';
|
|
import '../../models/app_models.dart';
|
|
import '../../runtime/runtime_models.dart';
|
|
import '../../theme/app_palette.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../widgets/assistant_focus_panel.dart';
|
|
import '../../widgets/assistant_artifact_sidebar.dart';
|
|
import '../../widgets/desktop_workspace_scaffold.dart';
|
|
import '../../widgets/pane_resize_handle.dart';
|
|
import '../../widgets/surface_card.dart';
|
|
import 'assistant_page_main.dart';
|
|
import 'assistant_page_components.dart';
|
|
import 'assistant_page_composer_bar.dart';
|
|
import 'assistant_page_composer_state_helpers.dart';
|
|
import 'assistant_page_composer_support.dart';
|
|
import 'assistant_page_tooltip_labels.dart';
|
|
import 'assistant_page_message_widgets.dart';
|
|
import 'assistant_page_task_models.dart';
|
|
import 'assistant_page_composer_skill_picker.dart';
|
|
import 'assistant_page_composer_clipboard.dart';
|
|
import 'assistant_attachment_payloads.dart';
|
|
import 'assistant_page_components_core.dart';
|
|
|
|
extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
|
Future<void> pickAttachmentsInternal() async {
|
|
final uiFeatures = widget.controller.featuresFor(
|
|
resolveUiFeaturePlatformFromContext(context),
|
|
);
|
|
if (!uiFeatures.supportsFileAttachments) {
|
|
return;
|
|
}
|
|
final files = await openFiles(
|
|
acceptedTypeGroups: const [
|
|
XTypeGroup(
|
|
label: 'Images',
|
|
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
|
|
),
|
|
XTypeGroup(label: 'Logs', extensions: ['log', 'txt', 'json', 'csv']),
|
|
XTypeGroup(
|
|
label: 'Files',
|
|
extensions: ['md', 'pdf', 'yaml', 'yml', 'zip'],
|
|
),
|
|
],
|
|
);
|
|
if (!mounted || files.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
attachmentsInternal = [
|
|
...attachmentsInternal,
|
|
...files.map(ComposerAttachmentInternal.fromXFile),
|
|
];
|
|
saveComposerAttachmentsForSessionInternal(
|
|
widget.controller.currentSessionKey,
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> submitPromptInternal() async {
|
|
final controller = widget.controller;
|
|
final uiFeatures = controller.featuresFor(
|
|
resolveUiFeaturePlatformFromContext(context),
|
|
);
|
|
final settings = controller.settings;
|
|
final executionTarget = resolvedVisibleExecutionTargetInternal(
|
|
controller,
|
|
supportedTargets: uiFeatures.availableExecutionTargets,
|
|
);
|
|
final rawPrompt = inputControllerInternal.text.trim();
|
|
if (rawPrompt.isEmpty) {
|
|
return;
|
|
}
|
|
final submittedSessionKey = controller.currentSessionKey;
|
|
|
|
final autoAgent = pickAutoAgentInternal(controller, rawPrompt);
|
|
if (autoAgent != null) {
|
|
await controller.selectAgent(autoAgent.id);
|
|
}
|
|
|
|
final submittedAttachments = List<ComposerAttachmentInternal>.from(
|
|
attachmentsInternal,
|
|
growable: false,
|
|
);
|
|
final List<GatewayChatAttachmentPayload> attachmentPayloads;
|
|
try {
|
|
attachmentPayloads = await buildAttachmentPayloadsInternal(
|
|
submittedAttachments,
|
|
);
|
|
} on AssistantAttachmentLimitException catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(attachmentLimitMessageInternal(error))),
|
|
);
|
|
return;
|
|
}
|
|
final attachmentNames = submittedAttachments
|
|
.map((item) => item.name)
|
|
.toList(growable: false);
|
|
final selectedSkillLabels = resolveSelectedSkillLabelsInternal(controller);
|
|
final connectionState = controller.currentAssistantConnectionState;
|
|
final prompt = composePromptInternal(
|
|
mode: modeInternal,
|
|
prompt: rawPrompt,
|
|
attachmentNames: attachmentNames,
|
|
selectedSkillLabels: selectedSkillLabels,
|
|
executionTarget: executionTarget,
|
|
permissionLevel: settings.assistantPermissionLevel,
|
|
);
|
|
|
|
setState(() {
|
|
lastAutoAgentLabelInternal =
|
|
autoAgent?.name ?? conversationOwnerLabelInternal(controller);
|
|
attachmentsInternal = const <ComposerAttachmentInternal>[];
|
|
clearComposerAttachmentsForSessionInternal(submittedSessionKey);
|
|
touchTaskSeedInternal(
|
|
sessionKey: submittedSessionKey,
|
|
title:
|
|
taskSeedsInternal[submittedSessionKey]?.title ??
|
|
fallbackSessionTitleInternal(submittedSessionKey),
|
|
preview: rawPrompt,
|
|
status: controller.hasAssistantPendingRun || connectionState.connected
|
|
? 'running'
|
|
: 'queued',
|
|
owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller),
|
|
surface: 'Assistant',
|
|
executionTarget: executionTarget,
|
|
draft: submittedSessionKey.trim().startsWith('draft:'),
|
|
);
|
|
});
|
|
inputControllerInternal.clear();
|
|
|
|
try {
|
|
await controller.sendChatMessage(
|
|
prompt,
|
|
sessionKey: submittedSessionKey,
|
|
thinking: thinkingLabelInternal,
|
|
attachments: attachmentPayloads,
|
|
selectedSkillLabels: selectedSkillLabels,
|
|
);
|
|
clearComposerDraftForSessionInternal(submittedSessionKey);
|
|
} catch (error) {
|
|
debugPrint('Assistant task submission failed: $error');
|
|
if (!mounted) {
|
|
rethrow;
|
|
}
|
|
final currentSessionMatchesSubmitted = sessionKeysMatchInternal(
|
|
widget.controller.currentSessionKey,
|
|
submittedSessionKey,
|
|
);
|
|
if (!currentSessionMatchesSubmitted) {
|
|
composerDraftBySessionKeyInternal[submittedSessionKey] = rawPrompt;
|
|
composerAttachmentsBySessionKeyInternal[submittedSessionKey] =
|
|
submittedAttachments;
|
|
} else if (inputControllerInternal.text.trim().isEmpty) {
|
|
inputControllerInternal.value = TextEditingValue(
|
|
text: rawPrompt,
|
|
selection: TextSelection.collapsed(offset: rawPrompt.length),
|
|
);
|
|
}
|
|
if (currentSessionMatchesSubmitted &&
|
|
attachmentsInternal.isEmpty &&
|
|
submittedAttachments.isNotEmpty) {
|
|
setState(() {
|
|
attachmentsInternal = submittedAttachments;
|
|
saveComposerAttachmentsForSessionInternal(submittedSessionKey);
|
|
});
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<List<GatewayChatAttachmentPayload>> buildAttachmentPayloadsInternal(
|
|
List<ComposerAttachmentInternal> attachments,
|
|
) => buildAssistantAttachmentPayloadsInternal(attachments);
|
|
|
|
String attachmentLimitMessageInternal(
|
|
AssistantAttachmentLimitException error,
|
|
) {
|
|
final size = formatAssistantAttachmentBytesInternal(error.sizeBytes);
|
|
final limit = formatAssistantAttachmentBytesInternal(error.limitBytes);
|
|
if (error.code == 'total') {
|
|
return appText(
|
|
'附件总大小 $size 超过单次提交上限 $limit,请移除部分文件后再提交。',
|
|
'Attachments total $size exceeds the per-message limit of $limit. Remove some files and try again.',
|
|
);
|
|
}
|
|
return appText(
|
|
'附件 ${error.fileName} 为 $size,超过单文件上限 $limit。',
|
|
'Attachment ${error.fileName} is $size, above the per-file limit of $limit.',
|
|
);
|
|
}
|
|
|
|
GatewayAgentSummary? pickAutoAgentInternal(
|
|
AppController controller,
|
|
String prompt,
|
|
) {
|
|
final text = prompt.toLowerCase();
|
|
final agents = controller.agents;
|
|
if (agents.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
GatewayAgentSummary? byName(String name) {
|
|
for (final agent in agents) {
|
|
if (agent.name.toLowerCase().contains(name)) {
|
|
return agent;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (text.contains('browser') ||
|
|
text.contains('search') ||
|
|
text.contains('website') ||
|
|
text.contains('网页') ||
|
|
text.contains('爬') ||
|
|
text.contains('抓取')) {
|
|
return byName('browser');
|
|
}
|
|
|
|
if (text.contains('research') ||
|
|
text.contains('analyze') ||
|
|
text.contains('compare') ||
|
|
text.contains('summary') ||
|
|
text.contains('研究') ||
|
|
text.contains('分析') ||
|
|
text.contains('调研')) {
|
|
return byName('research');
|
|
}
|
|
|
|
if (text.contains('code') ||
|
|
text.contains('deploy') ||
|
|
text.contains('build') ||
|
|
text.contains('test') ||
|
|
text.contains('log') ||
|
|
text.contains('bug') ||
|
|
text.contains('代码') ||
|
|
text.contains('部署') ||
|
|
text.contains('日志')) {
|
|
return byName('coding');
|
|
}
|
|
|
|
return byName('coding') ?? byName('browser') ?? byName('research');
|
|
}
|
|
|
|
List<ComposerSkillOptionInternal> availableSkillOptionsInternal(
|
|
AppController controller,
|
|
) {
|
|
final options = <ComposerSkillOptionInternal>[];
|
|
final seenKeys = <String>{};
|
|
|
|
void addOption(ComposerSkillOptionInternal option) {
|
|
if (seenKeys.add(option.key)) {
|
|
options.add(option);
|
|
}
|
|
}
|
|
|
|
for (final skill in controller.skills) {
|
|
final option = skillOptionFromGatewayInternal(skill);
|
|
addOption(option);
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
List<String> selectedSkillKeysForInternal(AppController controller) {
|
|
final selected =
|
|
controller
|
|
.taskThreadForSessionInternal(controller.currentSessionKey)
|
|
?.selectedSkillKeys ??
|
|
const <String>[];
|
|
final availableKeys = availableSkillOptionsInternal(
|
|
controller,
|
|
).map((option) => option.key).toSet();
|
|
return selected.where(availableKeys.contains).toList(growable: false);
|
|
}
|
|
|
|
List<String> resolveSelectedSkillLabelsInternal(AppController controller) {
|
|
final optionsByKey = <String, ComposerSkillOptionInternal>{
|
|
for (final option in availableSkillOptionsInternal(controller))
|
|
option.key: option,
|
|
};
|
|
return selectedSkillKeysForInternal(controller)
|
|
.map((key) {
|
|
final option = optionsByKey[key];
|
|
if (option == null) {
|
|
return null;
|
|
}
|
|
return option.label == key ? key : '${option.label} ($key)';
|
|
})
|
|
.whereType<String>()
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String composePromptInternal({
|
|
required String mode,
|
|
required String prompt,
|
|
required List<String> attachmentNames,
|
|
required List<String> selectedSkillLabels,
|
|
required AssistantExecutionTarget executionTarget,
|
|
required AssistantPermissionLevel permissionLevel,
|
|
}) {
|
|
final attachmentBlock = attachmentNames.isEmpty
|
|
? ''
|
|
: 'Attached files:\n${attachmentNames.map((name) => '- $name').join('\n')}\n\n';
|
|
final skillBlock = selectedSkillLabels.isEmpty
|
|
? ''
|
|
: 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n';
|
|
final executionContext =
|
|
'Execution context:\n'
|
|
'- target: ${executionTarget.promptValue}\n'
|
|
'- permission: ${permissionLevel.promptValue}\n\n';
|
|
|
|
return switch (mode) {
|
|
'craft' =>
|
|
'$attachmentBlock$skillBlock$executionContext'
|
|
'Craft a polished result for this request:\n$prompt',
|
|
'plan' =>
|
|
'$attachmentBlock$skillBlock$executionContext'
|
|
'Create a clear execution plan for this task:\n$prompt',
|
|
_ => '$attachmentBlock$skillBlock$executionContext$prompt',
|
|
};
|
|
}
|
|
|
|
void openGatewaySettingsInternal() {
|
|
widget.controller.openSettings(tab: SettingsTab.gateway);
|
|
}
|
|
|
|
Future<void> connectFromSavedSettingsOrShowDialogInternal() async {
|
|
if (!widget.controller.canQuickConnectGateway) {
|
|
openGatewaySettingsInternal();
|
|
return;
|
|
}
|
|
await widget.controller.connectSavedGateway();
|
|
}
|
|
|
|
void openAiGatewaySettingsInternal() {
|
|
widget.controller.openSettings(tab: SettingsTab.gateway);
|
|
}
|
|
|
|
Future<void> continueCurrentTaskInternal(String sessionKey) async {
|
|
try {
|
|
await widget.controller.continueAssistantTaskInternal(sessionKey);
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Error: $e\n$stackTrace');
|
|
focusComposerInternal();
|
|
}
|
|
}
|
|
|
|
void focusComposerInternal() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
void requestFocus() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
FocusScope.of(context).requestFocus(composerFocusNodeInternal);
|
|
composerFocusNodeInternal.requestFocus();
|
|
}
|
|
|
|
requestFocus();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => requestFocus());
|
|
}
|
|
|
|
Future<bool> runTaskSessionActionWithRetryInternal(
|
|
String label,
|
|
Future<void> Function() action,
|
|
) async {
|
|
Object? lastError;
|
|
for (
|
|
var attempt = 1;
|
|
attempt <= assistantTaskActionMaxRetryCountInternal;
|
|
attempt++
|
|
) {
|
|
try {
|
|
await action();
|
|
return true;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt >= assistantTaskActionMaxRetryCountInternal) {
|
|
break;
|
|
}
|
|
await Future<void>.delayed(Duration(milliseconds: 240 * attempt));
|
|
}
|
|
}
|
|
if (!mounted) {
|
|
return false;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
appText(
|
|
'$label 失败,弱网环境下已重试 $assistantTaskActionMaxRetryCountInternal 次。',
|
|
'$label failed after $assistantTaskActionMaxRetryCountInternal retries on a weak network.',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
debugPrint('$label failed after retries: $lastError');
|
|
return false;
|
|
}
|
|
|
|
Future<void> refreshTasksWithRetryInternal() async {
|
|
await runTaskSessionActionWithRetryInternal(
|
|
appText('刷新任务列表', 'Refresh task list'),
|
|
() => widget.controller.refreshSessions(preserveCurrentSelection: true),
|
|
);
|
|
}
|
|
|
|
Future<void> recallUserMessageInternal(TimelineItemInternal item) async {
|
|
final removed = await widget.controller.removeAssistantUserMessage(
|
|
widget.controller.currentSessionKey,
|
|
item.key,
|
|
);
|
|
if (!mounted || !removed) {
|
|
return;
|
|
}
|
|
final text = item.text?.trim() ?? '';
|
|
if (text.isNotEmpty) {
|
|
inputControllerInternal.value = TextEditingValue(
|
|
text: text,
|
|
selection: TextSelection.collapsed(offset: text.length),
|
|
);
|
|
focusComposerInternal();
|
|
}
|
|
}
|
|
|
|
Future<void> editUserMessageInternal(TimelineItemInternal item) async {
|
|
final original = item.text?.trim() ?? '';
|
|
if (original.isEmpty) {
|
|
return;
|
|
}
|
|
final input = TextEditingController(text: original);
|
|
final edited = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: Text(appText('修改消息', 'Edit message')),
|
|
content: TextField(
|
|
key: const Key('assistant-message-edit-input'),
|
|
controller: input,
|
|
autofocus: true,
|
|
minLines: 3,
|
|
maxLines: 8,
|
|
decoration: InputDecoration(
|
|
hintText: appText('输入修改后的内容', 'Enter the revised message'),
|
|
),
|
|
onSubmitted: (value) => Navigator.of(context).pop(value),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(appText('取消', 'Cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(input.text),
|
|
child: Text(appText('保存', 'Save')),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (!mounted || edited == null) {
|
|
return;
|
|
}
|
|
await widget.controller.updateAssistantUserMessage(
|
|
widget.controller.currentSessionKey,
|
|
item.key,
|
|
edited,
|
|
);
|
|
}
|
|
|
|
Future<void> switchSessionWithRetryInternal(String sessionKey) async {
|
|
saveComposerDraftForSessionInternal(widget.controller.currentSessionKey);
|
|
saveComposerAttachmentsForSessionInternal(
|
|
widget.controller.currentSessionKey,
|
|
);
|
|
final switched = await runTaskSessionActionWithRetryInternal(
|
|
appText('切换会话', 'Switch session'),
|
|
() => widget.controller.switchSession(sessionKey),
|
|
);
|
|
if (switched) {
|
|
composerDraftSessionKeyInternal = widget.controller.currentSessionKey;
|
|
restoreComposerDraftForSessionInternal(
|
|
widget.controller.currentSessionKey,
|
|
);
|
|
restoreComposerAttachmentsForSessionInternal(
|
|
widget.controller.currentSessionKey,
|
|
);
|
|
focusComposerInternal();
|
|
}
|
|
}
|
|
|
|
Future<void> createNewThreadInternal() async {
|
|
final sessionKey = widget.controller
|
|
.createAssistantDraftSessionKeyInternal();
|
|
final inheritedTarget = pickDraftThreadExecutionTargetInternal(
|
|
currentTarget: widget.controller.currentAssistantExecutionTarget,
|
|
visibleTargets: widget.controller.visibleAssistantExecutionTargets(
|
|
AssistantExecutionTarget.values,
|
|
),
|
|
localWorkspaceAvailable: widget.controller.settings.workspacePath
|
|
.trim()
|
|
.isNotEmpty,
|
|
);
|
|
final inheritedViewMode = widget.controller.currentAssistantMessageViewMode;
|
|
setState(() {
|
|
archivedTaskKeysInternal.removeWhere(
|
|
(value) => sessionKeysMatchInternal(value, sessionKey),
|
|
);
|
|
taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal(
|
|
sessionKey: sessionKey,
|
|
title: appText('新对话', 'New conversation'),
|
|
preview: appText(
|
|
'等待描述这个任务的第一条消息',
|
|
'Waiting for the first message of this task',
|
|
),
|
|
status: 'queued',
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: conversationOwnerLabelInternal(widget.controller),
|
|
surface: 'Assistant',
|
|
executionTarget: inheritedTarget,
|
|
draft: true,
|
|
);
|
|
});
|
|
widget.controller.initializeAssistantThreadContext(
|
|
sessionKey,
|
|
title: appText('新对话', 'New conversation'),
|
|
executionTarget: inheritedTarget,
|
|
messageViewMode: inheritedViewMode,
|
|
);
|
|
await switchSessionWithRetryInternal(sessionKey);
|
|
}
|
|
|
|
List<AssistantTaskEntryInternal> buildTaskEntriesInternal(
|
|
AppController controller,
|
|
) {
|
|
archivedTaskKeysInternal
|
|
..clear()
|
|
..addAll(
|
|
controller.assistantThreadRecordsInternal.values
|
|
.where((item) => item.archived)
|
|
.map((item) => item.sessionKey),
|
|
);
|
|
synchronizeTaskSeedsInternal(controller);
|
|
final entries = taskSeedsInternal.values
|
|
.where((item) => !isArchivedTaskInternal(item.sessionKey))
|
|
.map((item) {
|
|
final isCurrent = sessionKeysMatchInternal(
|
|
item.sessionKey,
|
|
controller.currentSessionKey,
|
|
);
|
|
final entry = item.toEntry(isCurrent: isCurrent);
|
|
if (!isCurrent) {
|
|
return entry;
|
|
}
|
|
return entry.copyWith(
|
|
owner: conversationOwnerLabelInternal(controller),
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
return entries;
|
|
}
|
|
|
|
List<AssistantTaskEntryInternal> filterTasksInternal(
|
|
List<AssistantTaskEntryInternal> items,
|
|
) {
|
|
final query = threadQueryInternal.trim().toLowerCase();
|
|
if (query.isEmpty) {
|
|
return items;
|
|
}
|
|
return items
|
|
.where((item) {
|
|
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
|
|
.toLowerCase();
|
|
return haystack.contains(query);
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
AssistantTaskEntryInternal resolveCurrentTaskInternal(
|
|
List<AssistantTaskEntryInternal> items,
|
|
String sessionKey,
|
|
) {
|
|
for (final item in items) {
|
|
if (sessionKeysMatchInternal(item.sessionKey, sessionKey)) {
|
|
return item;
|
|
}
|
|
}
|
|
return AssistantTaskEntryInternal(
|
|
sessionKey: sessionKey,
|
|
title: resolvedTaskTitleInternal(widget.controller, sessionKey),
|
|
preview: '',
|
|
status: 'queued',
|
|
updatedAtMs:
|
|
taskSeedsInternal[sessionKey]?.updatedAtMs ??
|
|
widget.controller
|
|
.taskThreadForSessionInternal(sessionKey)
|
|
?.updatedAtMs ??
|
|
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: conversationOwnerLabelInternal(widget.controller),
|
|
surface: 'Assistant',
|
|
executionTarget: resolvedVisibleExecutionTargetInternal(
|
|
widget.controller,
|
|
supportedTargets: AssistantExecutionTarget.values,
|
|
),
|
|
isCurrent: true,
|
|
draft: true,
|
|
);
|
|
}
|
|
|
|
void synchronizeTaskSeedsInternal(AppController controller) {
|
|
for (final session in controller.assistantSessions) {
|
|
if (isArchivedTaskInternal(session.key)) {
|
|
continue;
|
|
}
|
|
taskSeedsInternal[session.key] = AssistantTaskSeedInternal(
|
|
sessionKey: session.key,
|
|
title: resolvedTaskTitleInternal(
|
|
controller,
|
|
session.key,
|
|
session: session,
|
|
),
|
|
preview:
|
|
sessionPreviewInternal(session) ??
|
|
appText('等待继续执行这个任务', 'Waiting to continue this task'),
|
|
status: sessionStatusInternal(
|
|
session,
|
|
sessionPending: controller.assistantSessionHasPendingRun(session.key),
|
|
lifecycleStatus:
|
|
controller
|
|
.taskThreadForSessionInternal(session.key)
|
|
?.lifecycleState
|
|
.status ??
|
|
'',
|
|
),
|
|
updatedAtMs:
|
|
session.updatedAtMs ??
|
|
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: conversationOwnerLabelInternal(controller),
|
|
surface: session.surface ?? session.kind ?? 'Assistant',
|
|
executionTarget: controller.assistantExecutionTargetForSession(
|
|
session.key,
|
|
),
|
|
draft: session.key.trim().startsWith('draft:'),
|
|
);
|
|
}
|
|
|
|
final currentSeed = taskSeedsInternal[controller.currentSessionKey];
|
|
final currentSession = sessionByKeyInternal(
|
|
controller,
|
|
controller.currentSessionKey,
|
|
);
|
|
final currentPreview = currentTaskPreviewInternal(controller.chatMessages);
|
|
final currentStatus = currentTaskStatusInternal(
|
|
controller.chatMessages,
|
|
controller,
|
|
);
|
|
|
|
if (isArchivedTaskInternal(controller.currentSessionKey)) {
|
|
return;
|
|
}
|
|
taskSeedsInternal[controller.currentSessionKey] = AssistantTaskSeedInternal(
|
|
sessionKey: controller.currentSessionKey,
|
|
title: resolvedTaskTitleInternal(
|
|
controller,
|
|
controller.currentSessionKey,
|
|
fallbackTitle: currentSeed?.title,
|
|
),
|
|
preview:
|
|
currentPreview ??
|
|
currentSeed?.preview ??
|
|
appText(
|
|
'等待描述这个任务的第一条消息',
|
|
'Waiting for the first message of this task',
|
|
),
|
|
status: currentStatus ?? currentSeed?.status ?? 'queued',
|
|
updatedAtMs:
|
|
currentSession?.updatedAtMs ??
|
|
currentSeed?.updatedAtMs ??
|
|
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: conversationOwnerLabelInternal(controller),
|
|
surface: currentSeed?.surface ?? 'Assistant',
|
|
executionTarget: controller.assistantExecutionTargetForSession(
|
|
controller.currentSessionKey,
|
|
),
|
|
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
|
);
|
|
}
|
|
|
|
GatewaySessionSummary? sessionByKeyInternal(
|
|
AppController controller,
|
|
String sessionKey,
|
|
) {
|
|
for (final session in controller.assistantSessions) {
|
|
if (sessionKeysMatchInternal(session.key, sessionKey)) {
|
|
return session;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String resolvedTaskTitleInternal(
|
|
AppController controller,
|
|
String sessionKey, {
|
|
GatewaySessionSummary? session,
|
|
String? fallbackTitle,
|
|
}) {
|
|
final customTitle = controller.assistantCustomTaskTitle(sessionKey);
|
|
if (customTitle.isNotEmpty) {
|
|
return customTitle;
|
|
}
|
|
final resolvedSession =
|
|
session ?? sessionByKeyInternal(controller, sessionKey);
|
|
if (resolvedSession != null) {
|
|
return sessionDisplayTitleInternal(resolvedSession);
|
|
}
|
|
final fallback = fallbackTitle?.trim() ?? '';
|
|
if (fallback.isNotEmpty) {
|
|
return fallback;
|
|
}
|
|
return fallbackSessionTitleInternal(sessionKey);
|
|
}
|
|
|
|
String defaultTaskTitleInternal(
|
|
AppController controller,
|
|
String sessionKey, {
|
|
GatewaySessionSummary? session,
|
|
}) {
|
|
final resolvedSession =
|
|
session ?? sessionByKeyInternal(controller, sessionKey);
|
|
if (resolvedSession != null) {
|
|
return sessionDisplayTitleInternal(resolvedSession);
|
|
}
|
|
return fallbackSessionTitleInternal(sessionKey);
|
|
}
|
|
|
|
AssistantExecutionTarget resolvedVisibleExecutionTargetInternal(
|
|
AppController controller, {
|
|
required Iterable<AssistantExecutionTarget> supportedTargets,
|
|
}) {
|
|
final visibleTargets = controller.visibleAssistantExecutionTargets(
|
|
supportedTargets,
|
|
);
|
|
final currentTarget = controller.currentAssistantExecutionTarget;
|
|
if (visibleTargets.contains(currentTarget)) {
|
|
return currentTarget;
|
|
}
|
|
if (visibleTargets.isNotEmpty) {
|
|
return visibleTargets.first;
|
|
}
|
|
return currentTarget;
|
|
}
|
|
|
|
void touchTaskSeedInternal({
|
|
required String sessionKey,
|
|
required String title,
|
|
required String preview,
|
|
required String status,
|
|
required String owner,
|
|
required String surface,
|
|
required AssistantExecutionTarget executionTarget,
|
|
required bool draft,
|
|
}) {
|
|
taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal(
|
|
sessionKey: sessionKey,
|
|
title: title,
|
|
preview: preview,
|
|
status: status,
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: owner,
|
|
surface: surface,
|
|
executionTarget: executionTarget,
|
|
draft: draft,
|
|
);
|
|
}
|
|
|
|
bool isArchivedTaskInternal(String sessionKey) {
|
|
for (final archivedKey in archivedTaskKeysInternal) {
|
|
if (sessionKeysMatchInternal(archivedKey, sessionKey)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> archiveTaskInternal(String sessionKey) async {
|
|
final isCurrent = sessionKeysMatchInternal(
|
|
sessionKey,
|
|
widget.controller.currentSessionKey,
|
|
);
|
|
if (widget.controller.assistantSessionHasPendingRun(sessionKey)) {
|
|
return;
|
|
}
|
|
final archived = await runTaskSessionActionWithRetryInternal(
|
|
appText('归档任务', 'Archive task'),
|
|
() => widget.controller.saveAssistantTaskArchived(sessionKey, true),
|
|
);
|
|
if (!archived) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
archivedTaskKeysInternal.add(sessionKey);
|
|
taskSeedsInternal.removeWhere(
|
|
(key, _) => sessionKeysMatchInternal(key, sessionKey),
|
|
);
|
|
});
|
|
|
|
if (!isCurrent) {
|
|
return;
|
|
}
|
|
|
|
for (final candidate in taskSeedsInternal.keys) {
|
|
if (isArchivedTaskInternal(candidate) ||
|
|
sessionKeysMatchInternal(candidate, sessionKey)) {
|
|
continue;
|
|
}
|
|
await switchSessionWithRetryInternal(candidate);
|
|
return;
|
|
}
|
|
|
|
await createNewThreadInternal();
|
|
}
|
|
|
|
Future<void> renameTaskInternal(AssistantTaskEntryInternal entry) async {
|
|
final controller = widget.controller;
|
|
final input = TextEditingController(text: entry.title);
|
|
final renamed = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: Text(appText('重命名任务', 'Rename task')),
|
|
content: TextField(
|
|
key: const Key('assistant-task-rename-input'),
|
|
controller: input,
|
|
autofocus: true,
|
|
maxLines: 1,
|
|
decoration: InputDecoration(
|
|
labelText: appText('任务名称', 'Task name'),
|
|
hintText: appText(
|
|
'留空后恢复默认名称',
|
|
'Leave empty to restore the default title',
|
|
),
|
|
),
|
|
onSubmitted: (value) => Navigator.of(context).pop(value),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(appText('取消', 'Cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(input.text),
|
|
child: Text(appText('保存', 'Save')),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (!mounted || renamed == null) {
|
|
return;
|
|
}
|
|
final normalized = renamed.trim();
|
|
final nextTitle = normalized.isNotEmpty
|
|
? normalized
|
|
: defaultTaskTitleInternal(controller, entry.sessionKey);
|
|
final saved = await runTaskSessionActionWithRetryInternal(
|
|
appText('重命名任务', 'Rename task'),
|
|
() => controller.saveAssistantTaskTitle(entry.sessionKey, normalized),
|
|
);
|
|
if (!saved) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
final existing = taskSeedsInternal[entry.sessionKey];
|
|
if (existing != null) {
|
|
taskSeedsInternal[entry.sessionKey] = AssistantTaskSeedInternal(
|
|
sessionKey: existing.sessionKey,
|
|
title: nextTitle,
|
|
preview: existing.preview,
|
|
status: existing.status,
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
|
owner: existing.owner,
|
|
surface: existing.surface,
|
|
executionTarget: existing.executionTarget,
|
|
draft: existing.draft,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
AssistantFocusEntry? resolveFocusedDestinationInternal(
|
|
List<AssistantFocusEntry> favorites,
|
|
) {
|
|
if (favorites.isEmpty) {
|
|
return null;
|
|
}
|
|
if (activeFocusedDestinationInternal != null &&
|
|
favorites.contains(activeFocusedDestinationInternal)) {
|
|
return activeFocusedDestinationInternal;
|
|
}
|
|
return favorites.first;
|
|
}
|
|
|
|
double resolveMaxSidePaneWidthInternal(double viewportWidth) {
|
|
final maxWidthByViewport =
|
|
viewportWidth -
|
|
AssistantPageStateInternal.mainWorkspaceMinWidthInternal -
|
|
AssistantPageStateInternal.sidePaneViewportPaddingInternal -
|
|
assistantHorizontalResizeHandleWidthInternal -
|
|
assistantHorizontalPaneGapInternal;
|
|
return maxWidthByViewport
|
|
.clamp(
|
|
AssistantPageStateInternal.sidePaneMinWidthInternal,
|
|
viewportWidth -
|
|
AssistantPageStateInternal.sidePaneViewportPaddingInternal,
|
|
)
|
|
.toDouble();
|
|
}
|
|
|
|
String conversationOwnerLabelInternal(AppController controller) {
|
|
return controller.assistantConversationOwnerLabel;
|
|
}
|
|
|
|
String? currentTaskPreviewInternal(List<GatewayChatMessage> messages) {
|
|
for (final message in messages.reversed) {
|
|
final text = message.text.trim();
|
|
if (text.isNotEmpty) {
|
|
return text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? currentTaskStatusInternal(
|
|
List<GatewayChatMessage> messages,
|
|
AppController controller,
|
|
) {
|
|
final thread = controller.taskThreadForSessionInternal(
|
|
controller.currentSessionKey,
|
|
);
|
|
final lifecycleStatus = normalizedTaskStatusInternal(
|
|
thread?.lifecycleState.status ?? '',
|
|
);
|
|
if (controller.hasAssistantPendingRun) {
|
|
return 'running';
|
|
}
|
|
if (lifecycleStatus == 'interrupted') {
|
|
return 'interrupted';
|
|
}
|
|
if (messages.isEmpty) {
|
|
return null;
|
|
}
|
|
final last = messages.last;
|
|
if (last.error) {
|
|
return 'failed';
|
|
}
|
|
if (last.role.trim().toLowerCase() == 'user') {
|
|
return 'queued';
|
|
}
|
|
return 'open';
|
|
}
|
|
}
|