diff --git a/lib/main.dart b/lib/main.dart index c9dabcc..30426d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,13 @@ import 'dart:async'; -import 'package:desktop_webview_window/desktop_webview_window.dart' - show runWebViewTitleBarWidget; +import 'package:desktop_webview_window/desktop_webview_window.dart' show runWebViewTitleBarWidget; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:techpie/utils/platform.dart'; import 'models/third_party_account.dart'; +import 'services/ai_service.dart'; import 'services/assignment_service.dart'; import 'services/auth_service.dart'; import 'services/debug_logger.dart'; @@ -21,6 +22,7 @@ import 'services/third_party_auth_service.dart'; import 'services/uni_auth_service.dart'; import 'widgets/adaptive_feedback.dart'; import 'widgets/app_shell/app_shell.dart'; + void main(List args) async { // If this Flutter engine is a desktop_webview_window title bar (secondary // engine inside the webview popup), render the navigation controls and @@ -79,6 +81,12 @@ Future _realMain(SharedPreferences prefs) async { scheduleService, ); final syncService = SyncService(authService, thirdPartyAuthService, storageService); + final aiService = AiService( + storageService, + scheduleService, + assignmentService, + thirdPartyAuthService, + ); authService.onLogout = () async { // Third-party bindings persist across logouts — they will be used by the @@ -116,6 +124,7 @@ Future _realMain(SharedPreferences prefs) async { await syncService.loadCachedKey(); assignmentService.loadCached(); await scheduleService.loadCachedData(); + await aiService.initialize(); runApp( TechPieApp( @@ -129,6 +138,7 @@ Future _realMain(SharedPreferences prefs) async { oaGymService: oaGymService, uniAuthService: uniAuthService, syncService: syncService, + aiService: aiService, ), ); @@ -141,9 +151,7 @@ Future _realMain(SharedPreferences prefs) async { // refresh token (legacy session) this is a no-op and returns false — // that is NOT a "login expired" condition, only an actual renewal // failure is. - final renewMain = authService.isLoggedIn - ? authService.tryRenewSession() - : Future.value(true); + final renewMain = authService.isLoggedIn ? authService.tryRenewSession() : Future.value(true); final renewThirdParty = thirdPartyAuthService.autoRenewIfNeeded(); final results = await Future.wait([renewMain, renewThirdParty]); @@ -152,9 +160,7 @@ Future _realMain(SharedPreferences prefs) async { // Only surface a renewal failure when we actually had a refresh token // to try (a no-op returning false is not an expiry). - if (!mainOk && - authService.session?.geekpieRefreshToken != null && - !isIos()) { + if (!mainOk && authService.session?.geekpieRefreshToken != null && !isIos()) { showAdaptiveFeedback( message: '登录已过期,请重新登录', style: AdaptiveFeedbackStyle.error, @@ -234,6 +240,7 @@ class TechPieApp extends StatefulWidget { final OaGymService oaGymService; final UniAuthService uniAuthService; final SyncService syncService; + final AiService aiService; const TechPieApp({ super.key, @@ -247,6 +254,7 @@ class TechPieApp extends StatefulWidget { required this.oaGymService, required this.uniAuthService, required this.syncService, + required this.aiService, }); @override @@ -280,11 +288,17 @@ class _TechPieAppState extends State { oaGymService: widget.oaGymService, uniAuthService: widget.uniAuthService, syncService: widget.syncService, + aiService: widget.aiService, child: MaterialApp( scaffoldMessengerKey: rootMessengerKey, + navigatorObservers: [FeedbackRouteObserver()], + builder: (context, child) => AdaptiveFeedbackHost( + key: adaptiveFeedbackHostKey, + child: child ?? const SizedBox.shrink(), + ), title: 'TechPie', - theme: widget.themeService.lightTheme, - darkTheme: widget.themeService.darkTheme, + theme: _withAiExtension(widget.themeService.lightTheme), + darkTheme: _withAiExtension(widget.themeService.darkTheme), themeMode: widget.themeService.themeMode, home: const AppShell(), ), @@ -292,3 +306,28 @@ class _TechPieAppState extends State { ); } } + +/// Registers an [AiThemeExtension] on the TechPie theme so the flutter_ai +/// widgets (AiResponse markdown, AiCodeBlock, etc.) pick up the active color +/// scheme instead of their built-in defaults. Without this they fall back to +/// `AiThemeExtension.fallback()`, whose dark code-block background clashes +/// with TechPie's surfaces. +ThemeData _withAiExtension(ThemeData base) { + final cs = base.colorScheme; + final isDark = base.brightness == Brightness.dark; + final ai = (isDark ? AiThemeExtension.dark() : AiThemeExtension.fallback()) + .copyWith( + // Prose follows the surface text color; code block sits on a surface + // container tint with a contrasting foreground. + assistantTextColor: cs.onSurface, + userTextColor: cs.onSurface, + codeBackgroundColor: + isDark ? cs.surfaceContainerHighest : const Color(0xFF1E1E1E), + codeForegroundColor: + isDark ? cs.onSurface : const Color(0xFFE6E6E6), + linkColor: cs.primary, + accentColor: cs.primary, + onAccentColor: cs.onPrimary, + ); + return base.copyWith(extensions: [...base.extensions.values, ai]); +} diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart new file mode 100644 index 0000000..ad3e683 --- /dev/null +++ b/lib/models/ai_chat.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +// Re-export the library's chat-domain types so the rest of TechPie keeps a +// single import (`ai_chat.dart`) for everything AI — the parts-based +// AiMessage/AiRole/... come from flutter_ai_core, while TechPie-specific +// AiConfig / AiPromptTemplate / AiThread / gallery stay below. +export 'package:flutter_ai_core/flutter_ai_core.dart' + show + AiMessage, + AiConversation, + AiRole, + AiMessageStatus, + AiPart, + TextPart; + +/// A persisted conversation thread. +/// +/// The library's [AiConversation] only carries `{id, messages}` — it has no +/// title or timestamp. TechPie's history page needs both, so this wrapper adds +/// them and bundles the library conversation as the message payload. It is the +/// unit persisted by [StorageService] and exchanged with [AiService]. +class AiThread { + final String id; + final String title; + final List messages; + final DateTime updatedAt; + + const AiThread({ + required this.id, + required this.title, + required this.messages, + required this.updatedAt, + }); + + /// The underlying library conversation (messages only, no metadata). + AiConversation get conversation => + AiConversation(id: id, messages: messages); + + AiThread copyWith({ + String? title, + List? messages, + DateTime? updatedAt, + }) { + return AiThread( + id: id, + title: title ?? this.title, + messages: messages ?? this.messages, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + 'messages': [for (final m in messages) m.toJson()], + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory AiThread.fromJson(Map json) { + final rawMessages = (json['messages'] as List? ?? const []) + .map((e) => AiMessage.fromJson((e as Map).cast())) + // Drop only messages with no content at all (e.g. an aborted + // streaming placeholder with a single empty text part). Filtering on + // `text` alone would drop AiRole.tool messages — tool results have no + // text part — leaving every historical tool_use unanswered, which + // providers reject on the next request. + .where( + (m) => + m.role == AiRole.system || + m.parts.any((p) => p is! TextPart || p.text.isNotEmpty), + ) + .toList(); + return AiThread( + id: json['id'] as String, + title: json['title'] as String? ?? '新对话', + messages: rawMessages, + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + } +} + + +/// API configuration for the Anthropic-format endpoint. +/// +/// Defaults are seeded for DeepSeek's Anthropic-compatible endpoint so the +/// assistant works out of the box. The user can override base URL / model / +/// key in the config page. +/// +/// Note: [baseUrl] should point at the API *root* **including** the `/v1` +/// segment — the underlying `AnthropicProvider` appends `/messages`. See +/// [normalizeAiBaseUrl], which massages whatever the user typed into that form. +class AiConfig { + /// `ANTHROPIC_BASE_URL` — the endpoint root, e.g. + /// `https://api.deepseek.com/anthropic/v1`. `AnthropicProvider` appends + /// `/messages`, so this MUST include the version segment. + final String baseUrl; + + /// `ANTHROPIC_AUTH_TOKEN` — sent as `x-api-key` by `AnthropicProvider`. + final String authToken; + + /// `ANTHROPIC_MODEL` — e.g. `deepseek-v4-flash`. + final String model; + + /// Optional system prompt prepended to every request (injected as a leading + /// `AiRole.system` message, which the provider folds into the top-level + /// `system` field). + final String systemPrompt; + + /// Sampling temperature, 0.0–2.0. Null = server default. + final double? temperature; + + /// Max output tokens per turn. + final int maxTokens; + + const AiConfig({ + required this.baseUrl, + required this.authToken, + required this.model, + this.systemPrompt = _defaultSystemPrompt, + this.temperature, + this.maxTokens = 2048, + }); + + /// Default endpoint: DeepSeek's Anthropic-compatible API. The `/v1` segment + /// is included because `AnthropicProvider` appends `/messages` itself. + static const defaultBaseUrl = 'https://api.deepseek.com/anthropic/v1'; + static const defaultModel = 'deepseek-v4-flash'; + static const _defaultSystemPrompt = + '你是 TechPie AI 助手,为上海科技大学师生提供帮助。请用中文简洁、准确地回答问题。\n\n' + '你可以调用以下工具查询校园数据(数据来自上海科技大学校园系统):\n' + '- get_current_time:当前时间、星期几、第几教学周\n' + '- get_semesters:可用学期列表及当前选中学期\n' + '- get_week_schedule:指定学期指定周的课程表(可按学期/周过滤,默认当前)\n' + '- get_assignments:作业与考试截止时间列表(可按平台/类型过滤)\n\n' + '当用户询问时间、课程表、作业、考试、截止日期等信息时,请主动调用相应工具获取真实数据,' + '不要凭空编造。工具返回的 error 字段说明查询失败(如未绑定 eGate),应如实告知用户并引导其在设置中绑定校园账号。'; + + factory AiConfig.defaults() => const AiConfig( + baseUrl: defaultBaseUrl, + authToken: '', + model: defaultModel, + ); + + bool get hasAuthToken => authToken.isNotEmpty; + + AiConfig copyWith({ + String? baseUrl, + String? authToken, + String? model, + String? systemPrompt, + double? temperature, + int? maxTokens, + }) { + return AiConfig( + baseUrl: baseUrl ?? this.baseUrl, + authToken: authToken ?? this.authToken, + model: model ?? this.model, + systemPrompt: systemPrompt ?? this.systemPrompt, + temperature: temperature ?? this.temperature, + maxTokens: maxTokens ?? this.maxTokens, + ); + } + + Map toJson() => { + 'baseUrl': baseUrl, + 'authToken': authToken, + 'model': model, + 'systemPrompt': systemPrompt, + 'temperature': temperature, + 'maxTokens': maxTokens, + }; + + factory AiConfig.fromJson(Map json) { + return AiConfig( + baseUrl: normalizeAiBaseUrl(json['baseUrl'] as String? ?? defaultBaseUrl), + authToken: json['authToken'] as String? ?? '', + model: json['model'] as String? ?? defaultModel, + systemPrompt: json['systemPrompt'] as String? ?? _defaultSystemPrompt, + temperature: (json['temperature'] as num?)?.toDouble(), + maxTokens: json['maxTokens'] as int? ?? 2048, + ); + } +} + +/// Normalizes a user-entered base URL into the form `AnthropicProvider` +/// expects: a root ending in `/v1` (it appends `/messages` itself). +/// +/// Tolerates the legacy default (no `/v1`), a fully-qualified +/// `/v1/messages` URL, and trailing slashes — so existing saved configs and +/// hand-typed endpoints keep working after the switch to the library provider. +String normalizeAiBaseUrl(String raw) { + var url = raw.trim(); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + if (url.isEmpty) return AiConfig.defaultBaseUrl; + if (url.endsWith('/v1/messages')) { + return url.substring(0, url.length - '/messages'.length); + } + if (url.endsWith('/messages')) { + return url.substring(0, url.length - '/messages'.length); + } + if (url.endsWith('/v1')) { + return url; + } + return '$url/v1'; +} + +/// A reusable prompt template shown in the gallery. Tapping one starts a new +/// conversation with the template text pre-filled (and optionally a custom +/// system prompt scoped to that category). +class AiPromptTemplate { + final String id; + final String title; + final String subtitle; + final String prompt; + final IconData icon; + + const AiPromptTemplate({ + required this.id, + required this.title, + required this.subtitle, + required this.prompt, + required this.icon, + }); +} + +/// Built-in gallery of prompt templates. Hard-coded (not user-editable) to +/// keep the feature simple. +const List aiPromptGallery = [ + AiPromptTemplate( + id: 'translate-en-zh', + title: '翻译 英文 → 中文', + subtitle: '把一段英文翻译成通顺的中文', + prompt: '请把下面这段英文翻译成自然流畅的中文:\n\n', + icon: Icons.translate, + ), + AiPromptTemplate( + id: 'translate-zh-en', + title: '翻译 中文 → 英文', + subtitle: '把一段中文翻译成地道的英文', + prompt: 'Please translate the following Chinese text into natural English:\n\n', + icon: Icons.translate_outlined, + ), + AiPromptTemplate( + id: 'summarize', + title: '总结摘要', + subtitle: '提取一段文字的要点', + prompt: '请用中文为以下内容生成一份要点摘要,使用无序列表:\n\n', + icon: Icons.summarize, + ), + AiPromptTemplate( + id: 'polish', + title: '润色改写', + subtitle: '让文字更通顺、更专业', + prompt: '请润色下面这段文字,使其更通顺、专业,并保留原意:\n\n', + icon: Icons.auto_fix_high, + ), + AiPromptTemplate( + id: 'explain-code', + title: '解释代码', + subtitle: '逐行讲解一段代码', + prompt: '请用中文逐行解释下面这段代码的作用,并指出潜在问题:\n\n```\n\n```', + icon: Icons.code, + ), + AiPromptTemplate( + id: 'study-plan', + title: '制定学习计划', + subtitle: '为某个主题安排学习路径', + prompt: '我想学习', + icon: Icons.school, + ), + AiPromptTemplate( + id: 'email-draft', + title: '撰写邮件', + subtitle: '起草一封正式邮件', + prompt: '请帮我起草一封邮件。背景:\n收件人:\n主要目的:\n语气:正式\n\n', + icon: Icons.mail, + ), + AiPromptTemplate( + id: 'brainstorm', + title: '头脑风暴', + subtitle: '为一个问题发散想法', + prompt: '请围绕以下主题,给我 10 个有创意的点子,每个一句话:\n\n', + icon: Icons.lightbulb, + ), +]; diff --git a/lib/models/feature.dart b/lib/models/feature.dart index 4df06a5..b86ae80 100644 --- a/lib/models/feature.dart +++ b/lib/models/feature.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../pages/ai_assistant_page.dart'; +import '../pages/ai_demo/ai_demo_page.dart'; import '../pages/oa_gym_page.dart'; enum FeatureMode { @@ -22,6 +24,9 @@ class Feature { final Icon icon; final void Function(BuildContext context)? nativeEntry; + /// Only shown when the debug-mode setting is on. + final bool debugOnly; + Feature({ required this.id, required this.description, @@ -30,6 +35,7 @@ class Feature { this.cookieType, required this.icon, this.nativeEntry, + this.debugOnly = false, }); } @@ -59,6 +65,25 @@ final featureEntries = [ ), icon: const Icon(Icons.sports_tennis), ), + Feature( + id: 'ai_assistant', + description: 'AI 助手', + mode: FeatureMode.native, + nativeEntry: (context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiAssistantPage()), + ), + icon: const Icon(Icons.smart_toy_outlined), + ), + Feature( + id: 'ai_demo', + description: 'AI 演示', + mode: FeatureMode.native, + debugOnly: true, + nativeEntry: (context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiDemoPage()), + ), + icon: const Icon(Icons.auto_awesome), + ), ]; final moreFeature = Feature( diff --git a/lib/pages/ai_assistant_page.dart b/lib/pages/ai_assistant_page.dart new file mode 100644 index 0000000..b03caf6 --- /dev/null +++ b/lib/pages/ai_assistant_page.dart @@ -0,0 +1,604 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../services/service_provider.dart'; +import '../utils/platform.dart'; +import '../widgets/ai/ai_code_highlighter.dart'; +import '../widgets/ai/ai_text_renderer.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import 'ai_config_page.dart'; +import 'ai_gallery_page.dart'; +import 'ai_history_page.dart'; + +/// The AI Assistant chat page — entered from the Home "应用" card. +/// +/// Layout mirrors other TechPie feature pages: a platform-adaptive app bar +/// (Liquid Glass on iOS 26+, blurred bar elsewhere) over a body composed of +/// the flutter_ai library's chat widgets — [AiChat] for the transcript, +/// [AiPromptInput] for the composer, [AiErrorBanner] for failures. Only the +/// top bar, the not-configured banner, and the empty state are TechPie's own; +/// the conversation UI itself is the library's so that markdown, code blocks, +/// and future workflow/cite parts render through the library's part pipeline. +class AiAssistantPage extends StatefulWidget { + const AiAssistantPage({super.key, this.seedPrompt}); + + /// Optional prompt pre-filled from the gallery. Written into the composer's + /// text controller on first build. + final String? seedPrompt; + + @override + State createState() => _AiAssistantPageState(); +} + +class _AiAssistantPageState extends State { + /// Owns the composer's text so the gallery can pre-fill it. Passed to + /// AiPromptInput.textController. + final TextEditingController _textController = TextEditingController(); + + @override + void initState() { + super.initState(); + if (widget.seedPrompt != null && widget.seedPrompt!.isNotEmpty) { + _textController.text = widget.seedPrompt!; + } + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _openGallery() async { + final AiPromptTemplate? picked = await Navigator.of(context).push< + AiPromptTemplate + >( + MaterialPageRoute( + builder: (_) => const AiGalleryPage(), + ), + ); + if (picked != null && mounted) { + _textController.text = picked.prompt; + _textController.selection = TextSelection.collapsed( + offset: picked.prompt.length, + ); + } + } + + Future _openHistory(AiService aiService) async { + final String? selectedId = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AiHistoryPage(aiService: aiService), + ), + ); + if (selectedId != null && mounted) { + aiService.selectConversation(selectedId); + } + } + + Future _openConfig() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiConfigPage()), + ); + } + + @override + Widget build(BuildContext context) { + final sp = ServiceProvider.of(context); + final aiService = sp.aiService; + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final topInset = + useIosChrome || useLegacyIosChrome + ? 0.0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: 'AI 助手', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Home', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Home', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'gallery', + title: 'Gallery', + sfSymbol: 'square.grid.2x2', + accessibilityLabel: '提示词画廊', + placementGroup: 'trailing-main', + ), + IosNativeNavigationBarItem( + id: 'history', + sfSymbol: 'clock.arrow.circlepath', + accessibilityLabel: '历史会话', + placementGroup: 'trailing-main', + ), + IosNativeNavigationBarItem( + id: 'config', + sfSymbol: 'gearshape', + accessibilityLabel: 'API 设置', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'gallery': + unawaited(_openGallery()); + case 'history': + unawaited(_openHistory(aiService)); + case 'config': + unawaited(_openConfig()); + } + }, + ) + : BlurredAppBar( + title: const Text('AI 助手'), + actions: [ + IconButton( + tooltip: '提示词画廊', + icon: const Icon(Icons.auto_awesome_outlined), + onPressed: _openGallery, + ), + IconButton( + tooltip: '历史会话', + icon: const Icon(Icons.history), + onPressed: () => unawaited(_openHistory(aiService)), + ), + IconButton( + tooltip: 'API 设置', + icon: const Icon(Icons.settings_outlined), + onPressed: _openConfig, + ), + ], + ), + // The transcript (AiChat) and composer (AiPromptInput) bind the + // controller DIRECTLY and are NOT wrapped in a ListenableBuilder — + // matching the flutter_ai demo. Wrapping them in ListenableBuilder(aiService) + // rebuilds AiChat on every status change, which races AiChat's top-anchor + // scroll logic (its anchor RenderBox goes missing mid-rebuild → trailingSpace + // oscillates → the bottom padding flickers). Only the mutable banners listen + // to aiService; AiChat listens to the controller itself. + body: Column( + children: [ + SizedBox(height: topInset), + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final notConfigured = !aiService.isConfigured; + if (!notConfigured) return const SizedBox.shrink(); + return _configBanner(context, aiService); + }, + ), + Expanded( + child: _AutoScrollChat( + controller: aiService.controller, + emptyState: Builder( + builder: (context) => + _emptyState(context, !aiService.isConfigured), + ), + ), + ), + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final error = aiService.streamingError; + if (error == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: AiErrorBanner( + message: error, + onRetry: aiService.isStreaming + ? null + : () => unawaited(_retry(aiService)), + onDismiss: () => _dismissError(aiService), + ), + ); + }, + ), + // Composer appears only once configured; before that a button guides + // the user to set a token. This toggle is the only composer-level + // piece that needs aiService. + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + if (!aiService.isConfigured) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.tonalIcon( + onPressed: _openConfig, + icon: const Icon(Icons.key), + label: const Text('前往配置 API 令牌'), + ), + ), + ); + } + return AiPromptInput( + controller: aiService.controller, + hintText: '输入消息…', + textController: _textController, + ); + }, + ), + ], + ), + ); + } + + Future _retry(AiService aiService) async { + // Re-send an empty turn to regenerate the last assistant reply. + await aiService.send(''); + } + + void _dismissError(AiService aiService) { + // Clearing the transcript's error requires a fresh controller state; the + // simplest portable way is to reload the current conversation, which + // resets status to idle and drops the error. + final conv = aiService.currentConversation; + if (conv != null) { + aiService.controller.stop(); + } + } + + Widget _configBanner(BuildContext context, AiService aiService) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.tertiaryContainer, + child: InkWell( + onTap: _openConfig, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + Icons.key, + size: 18, + color: theme.colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '尚未配置 API 令牌,点此填写以开始对话', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onTertiaryContainer, + ), + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onTertiaryContainer, + ), + ], + ), + ), + ), + ); + } + + Widget _emptyState(BuildContext context, bool notConfigured) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.smart_toy_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + notConfigured ? '欢迎使用 AI 助手' : '开始一段新对话', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + notConfigured + ? '先配置 API 令牌,然后即可开始对话。也可以从画廊挑选一个提示词模板。' + : '在下方输入你的问题,或从画廊挑一个提示词模板快速开始。', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + FilledButton.tonalIcon( + onPressed: _openGallery, + icon: const Icon(Icons.auto_awesome_outlined), + label: const Text('浏览提示词画廊'), + ), + ], + ), + ), + ); + } +} + +/// A transcript bound to a [UseChatController] that scrolls to the bottom when a +/// message is sent — WITHOUT the library [AiChat]'s ChatGPT-style top-anchor. +/// +/// Why not just use `AiChat(autoScroll: false)`: AiChat's ScrollController is +/// private, so we can't drive scroll-to-end from outside. This wrapper uses the +/// presentational [AiConversationView] with our own ScrollController, so we +/// control scrolling directly. Crucially it never sets `trailingSpace`, so +/// there is no dynamic bottom-padding (the source of the earlier flicker). +/// +/// Scroll behavior: +/// - On a new message (count grows), animate to the bottom. +/// - While streaming, keep pinned to the bottom ONLY if the user is already +/// near the bottom — so scrolling up to read isn't yanked back down. +class _AutoScrollChat extends StatefulWidget { + const _AutoScrollChat({required this.controller, this.emptyState}); + + final UseChatController controller; + final Widget? emptyState; + + @override + State<_AutoScrollChat> createState() => _AutoScrollChatState(); +} + +class _AutoScrollChatState extends State<_AutoScrollChat> { + final ScrollController _scrollController = ScrollController(); + int _lastCount = 0; + /// First message id of the currently-shown thread. When it changes, the user + /// switched conversations (the controller is a singleton that `load()`s a new + /// transcript) — we jump to the bottom of the new thread. + String? _firstMessageId; + bool _nearBottom = true; + + @override + void initState() { + super.initState(); + _lastCount = widget.controller.messages.length; + _firstMessageId = widget.controller.messages.firstOrNull?.id; + widget.controller.addListener(_onChanged); + _scrollController.addListener(_onScroll); + // On first open (or when re-entering a conversation), jump to the latest + // message so the user lands at the bottom of the transcript. + _jumpToEnd(); + } + + @override + void didUpdateWidget(covariant _AutoScrollChat oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onChanged); + widget.controller.addListener(_onChanged); + _lastCount = widget.controller.messages.length; + _firstMessageId = widget.controller.messages.firstOrNull?.id; + // Switched controllers — land at the bottom of the new thread. + _jumpToEnd(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChanged); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final pos = _scrollController.position; + final near = (pos.maxScrollExtent - pos.pixels) <= 80; + if (near != _nearBottom) { + setState(() => _nearBottom = near); + } + } + + void _onChanged() { + final messages = widget.controller.messages; + final count = messages.length; + final firstId = messages.firstOrNull?.id; + // The controller is a singleton; a conversation switch swaps the transcript + // in place via load(). Detect it by the first message id changing. + final switched = firstId != _firstMessageId; + _firstMessageId = firstId; + final grew = count > _lastCount; + _lastCount = count; + if (!mounted) return; + if (switched) { + // New conversation — jump (no animation) to its latest message. + _jumpToEnd(); + return; + } + // Scroll to end when a new message lands (user sent / assistant turn + // started), or while streaming if the user is still pinned near the bottom. + final streaming = widget.controller.status == ChatStatus.streaming; + if (grew || (streaming && _nearBottom)) { + _scrollToEnd(); + } + } + + /// Animated scroll to the bottom — used while streaming / on send. + void _scrollToEnd() { + if (!_scrollController.hasClients) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final pos = _scrollController.position; + unawaited( + _scrollController.animateTo( + pos.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ), + ); + }); + } + + /// Instant jump to the bottom — used on init / conversation switch so the + /// user lands at the latest message without a scroll animation. + void _jumpToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + /// Builds a message bubble, pairing tool calls with their results across the + /// whole transcript (the agent loop appends results as a separate + /// AiRole.tool message, so per-message pairing would miss them). + Widget _buildMessage(BuildContext context, AiMessage message) { + // Tool-result messages are folded into the assistant turn's tool cards. + if (message.role == AiRole.tool) return const SizedBox.shrink(); + // User messages render with the default bubble. + if (message.role == AiRole.user) { + return AiMessageBubble(message: message); + } + + // Assistant: gather tool results across the whole transcript so each + // ToolCallPart pairs with its result regardless of which message it's in. + final results = { + for (final m in widget.controller.messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId: p, + }; + final toolCalls = message.parts.whereType().toList(); + + final children = []; + void add(Widget w) { + if (children.isNotEmpty) children.add(const SizedBox(height: 10)); + children.add(w); + } + + var toolsRendered = false; + for (final part in message.parts) { + switch (part) { + case TextPart(:final text): + if (text.isNotEmpty) { + add(AiResponse(text: text, codeHighlighter: techpieCodeHighlighter)); + } + case ToolCallPart(): + // Render all tool calls once (a group when parallel, else one card), + // each paired with its transcript-wide result. + if (!toolsRendered) { + toolsRendered = true; + add( + toolCalls.length > 1 + ? AiToolGroup(calls: toolCalls, results: results) + : AiToolInvocation( + call: part, + result: results[part.toolCallId], + ), + ); + } + case ToolResultPart(): + break; // rendered within its AiToolInvocation card + case ReasoningPart(:final text): + if (text.isNotEmpty) add(AiReasoning(text: text)); + default: + break; // FilePart/SourcePart/DataPart not used by TechPie tools yet + } + } + + if (children.isEmpty) return const SizedBox.shrink(); + if (children.length == 1) return children.first; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final messages = widget.controller.messages; + if (messages.isEmpty && + !widget.controller.status.isBusy && + widget.emptyState != null) { + return widget.emptyState!; + } + final view = AiConversationView( + messages: messages, + scrollController: _scrollController, + textRenderer: const StreamingMarkdownRenderer(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + // Show the thinking loader while awaiting the first streamed token, + // matching AiChat's behavior. + showLoader: widget.controller.status == ChatStatus.submitted, + // Custom builder pairs tool calls with their results across the whole + // transcript (the agent loop lands ToolResultParts in a separate + // AiRole.tool message; the default bubble only pairs within one + // message). Tool-result messages are collapsed into the call card. + messageBuilder: _buildMessage, + ); + // Floating "scroll to end" button, shown only when not already at the + // bottom (and there's content to scroll). Hidden once at the end. + final showJump = !_nearBottom && messages.isNotEmpty; + return Stack( + children: [ + view, + if (showJump) + PositionedDirectional( + bottom: 12, + start: 0, + end: 0, + child: Center(child: _ScrollToEndButton(onTap: _scrollToEnd)), + ), + ], + ); + }, + ); + } +} + +/// A small circular "scroll to end" affordance. Styled to match the library's +/// jump button (AiChat._JumpButton) so it feels native to the chat surface. +class _ScrollToEndButton extends StatelessWidget { + const _ScrollToEndButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Semantics( + button: true, + label: '滚动到最新', + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + shape: const CircleBorder(), + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.arrow_downward_rounded, + size: 20, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + } +} + + diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart new file mode 100644 index 0000000..f7fa0b2 --- /dev/null +++ b/lib/pages/ai_config_page.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../services/service_provider.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_feedback.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// Edit the AI assistant's API configuration. +/// +/// Fields: base URL, auth token (obscured), model, system prompt, temperature, +/// max tokens. A "测试连接" button sends a trivial non-streaming probe so the +/// user can confirm their key/endpoint before chatting. +class AiConfigPage extends StatefulWidget { + const AiConfigPage({super.key}); + + @override + State createState() => _AiConfigPageState(); +} + +class _AiConfigPageState extends State { + late final TextEditingController _baseUrlCtrl; + late final TextEditingController _tokenCtrl; + late final TextEditingController _modelCtrl; + late final TextEditingController _systemCtrl; + late final TextEditingController _maxTokensCtrl; + late final TextEditingController _tempCtrl; + + bool _obscureToken = true; + bool _testing = false; + bool _seeded = false; + + @override + void initState() { + super.initState(); + // Controllers created empty; populated in didChangeDependencies where + // ServiceProvider (an InheritedWidget) is safe to access. + _baseUrlCtrl = TextEditingController(); + _tokenCtrl = TextEditingController(); + _modelCtrl = TextEditingController(); + _systemCtrl = TextEditingController(); + _maxTokensCtrl = TextEditingController(); + _tempCtrl = TextEditingController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_seeded) return; + _seeded = true; + final config = ServiceProvider.of(context).aiService.config; + _baseUrlCtrl.text = config.baseUrl; + _tokenCtrl.text = config.authToken; + _modelCtrl.text = config.model; + _systemCtrl.text = config.systemPrompt; + _maxTokensCtrl.text = config.maxTokens.toString(); + _tempCtrl.text = config.temperature?.toString() ?? ''; + } + + @override + void dispose() { + _baseUrlCtrl.dispose(); + _tokenCtrl.dispose(); + _modelCtrl.dispose(); + _systemCtrl.dispose(); + _maxTokensCtrl.dispose(); + _tempCtrl.dispose(); + super.dispose(); + } + + AiConfig _buildConfig() { + final maxTokens = int.tryParse(_maxTokensCtrl.text.trim()) ?? 2048; + final temp = double.tryParse(_tempCtrl.text.trim()); + return AiConfig( + baseUrl: _baseUrlCtrl.text.trim().isEmpty + ? AiConfig.defaultBaseUrl + : _baseUrlCtrl.text.trim(), + authToken: _tokenCtrl.text.trim(), + model: _modelCtrl.text.trim().isEmpty + ? AiConfig.defaultModel + : _modelCtrl.text.trim(), + systemPrompt: _systemCtrl.text, + temperature: temp, + maxTokens: maxTokens, + ); + } + + Future _save() async { + final aiService = ServiceProvider.of(context).aiService; + await aiService.saveConfig(_buildConfig()); + if (mounted) { + showAdaptiveFeedback(message: '已保存', style: AdaptiveFeedbackStyle.success); + } + } + + Future _testConnection() async { + if (_testing) return; + setState(() => _testing = true); + final config = _buildConfig(); + AnthropicProvider? provider; + StreamSubscription? sub; + try { + if (!config.hasAuthToken) { + showAdaptiveFeedback( + message: '请先填写 API 令牌', + style: AdaptiveFeedbackStyle.error, + ); + return; + } + // Build a one-off provider + a trivial "ping" turn and listen for the + // first text delta — that confirms auth + endpoint end-to-end. + provider = AnthropicProvider( + apiKey: config.authToken, + baseUrl: Uri.parse(normalizeAiBaseUrl(config.baseUrl)), + defaultModel: config.model, + defaultMaxTokens: config.maxTokens, + timeout: const Duration(seconds: 30), + ); + final systemMsg = config.systemPrompt.trim().isEmpty + ? null + : AiMessage( + id: 'probe-system', + role: AiRole.system, + parts: [TextPart(config.systemPrompt)], + ); + final conv = AiConversation( + id: 'probe', + messages: [ + if (systemMsg != null) systemMsg, + AiMessage.text( + id: 'probe-user', + role: AiRole.user, + text: 'ping', + ), + ], + ); + final got = Completer(); + final buffer = StringBuffer(); + sub = provider.send(conv, options: _probeOptions(config)).listen( + (event) { + if (event is TextDelta) { + buffer.write(event.delta); + if (buffer.length > 8 && !got.isCompleted) got.complete(); + } else if (event is StreamErrorEvent) { + if (!got.isCompleted) got.completeError(event.error); + } + }, + onError: (Object e) { + if (!got.isCompleted) got.completeError(e); + }, + ); + await got.future.timeout(const Duration(seconds: 30)); + await sub.cancel(); + if (mounted) { + showAdaptiveFeedback( + message: '连接成功,模型已回复', + style: AdaptiveFeedbackStyle.success, + ); + } + } on LlmException catch (e) { + if (mounted) { + showAdaptiveFeedback( + message: '请求失败(${e.statusCode}):${e.body.isEmpty ? e.toString() : e.body}', + style: AdaptiveFeedbackStyle.error, + duration: const Duration(seconds: 5), + ); + } + } on TimeoutException { + if (mounted) { + showAdaptiveFeedback( + message: '请求超时,请稍后重试', + style: AdaptiveFeedbackStyle.error, + ); + } + } catch (e) { + if (mounted) { + showAdaptiveFeedback( + message: '连接失败:$e', + style: AdaptiveFeedbackStyle.error, + duration: const Duration(seconds: 5), + ); + } + } finally { + await sub?.cancel(); + provider?.close(); + if (mounted) setState(() => _testing = false); + } + } + + AiRequestOptions _probeOptions(AiConfig config) => AiRequestOptions( + model: config.model, + temperature: config.temperature, + maxOutputTokens: config.maxTokens, + ); + + Future _clearAll(AiService aiService) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('清空所有会话?'), + content: const Text('此操作不可撤销,将删除全部本地对话记录。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('清空'), + ), + ], + ), + ); + if (confirmed == true) { + await aiService.clearAllConversations(); + if (mounted) { + showAdaptiveFeedback(message: '已清空', style: AdaptiveFeedbackStyle.success); + } + } + } + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: 'AI 设置', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'save', + title: '保存', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'save': + unawaited(_save()); + } + }, + ) + : BlurredAppBar( + title: const Text('AI 设置'), + actions: [ + TextButton( + onPressed: _save, + child: const Text('保存'), + ), + ], + ), + body: ListView( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 8 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top + 8, + bottom: 32, + ), + children: [ + _section(theme, '接口', [ + _field( + theme: theme, + controller: _baseUrlCtrl, + label: 'Base URL', + hint: 'https://api.deepseek.com/anthropic', + keyboardType: TextInputType.url, + ), + _tokenField(theme, colorScheme), + _field( + theme: theme, + controller: _modelCtrl, + label: '模型', + hint: 'deepseek-v4-flash', + ), + ]), + _section(theme, '生成', [ + _field( + theme: theme, + controller: _tempCtrl, + label: '温度 (temperature, 留空使用默认)', + hint: '0.7', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + _field( + theme: theme, + controller: _maxTokensCtrl, + label: '最大输出 tokens', + hint: '2048', + keyboardType: TextInputType.number, + ), + ]), + _section(theme, '系统提示词', [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _systemCtrl, + minLines: 3, + maxLines: 8, + decoration: const InputDecoration( + alignLabelWithHint: true, + border: OutlineInputBorder(), + ), + ), + ), + ]), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _testing ? null : _testConnection, + icon: _testing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: const Text('测试连接'), + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.delete_sweep_outlined, color: Colors.red), + title: const Text('清空所有会话'), + subtitle: const Text('删除本地全部对话记录'), + onTap: () => + unawaited(_clearAll(ServiceProvider.of(context).aiService)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '支持任何 Anthropic 兼容端点。令牌仅保存在设备安全存储中。', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + Widget _section(ThemeData theme, String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ...children, + ], + ); + } + + Widget _field({ + required ThemeData theme, + required TextEditingController controller, + required String label, + required String hint, + TextInputType keyboardType = TextInputType.text, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ), + ), + ); + } + + Widget _tokenField(ThemeData theme, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _tokenCtrl, + obscureText: _obscureToken, + keyboardType: TextInputType.visiblePassword, + decoration: InputDecoration( + labelText: 'API 令牌 (auth token)', + hintText: 'sk-…', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureToken ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => _obscureToken = !_obscureToken), + ), + ), + ), + ); + } +} diff --git a/lib/pages/ai_demo/ai_demo_page.dart b/lib/pages/ai_demo/ai_demo_page.dart new file mode 100644 index 0000000..d18fdb3 --- /dev/null +++ b/lib/pages/ai_demo/ai_demo_page.dart @@ -0,0 +1,723 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../widgets/adaptive_feedback.dart'; +import 'code_highlighter.dart'; +import 'demo_data.dart'; +import 'demo_provider.dart'; +import 'demo_tools.dart'; +import 'feature_sections.dart'; + +/// A self-contained showcase of the `flutter_ai_elements` widget family, ported +/// from the flutter_ai demo. +/// +/// Everything here is scripted ([DemoChatProvider]) and offline — no live +/// model, no network, no voice. It is surfaced from the home "应用" card as a +/// native feature entry (see `lib/models/feature.dart`). +class AiDemoPage extends StatefulWidget { + /// Creates the AI demo page. + const AiDemoPage({super.key}); + + @override + State createState() => _AiDemoPageState(); +} + +class _AiDemoPageState extends State { + String _modelId = demoModels.first.id; + final UseChatController _controller = UseChatController( + provider: const DemoChatProvider(), + ); + late final ToolRunner _toolRunner = ToolRunner(_controller); + + @override + void initState() { + super.initState(); + _controller.setTools(demoTools); + _toolRunner.addListener(_onToolChange); + } + + void _onToolChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _toolRunner.removeListener(_onToolChange); + _toolRunner.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _selectModel(String id) { + setState(() => _modelId = id); + _controller.setOptions(AiRequestOptions(model: id)); + } + + void _openGallery() => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar( + title: const Text('Every element'), + scrolledUnderElevation: 0, + ), + body: const SafeArea(child: GalleryScreen()), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final isWide = media.size.width >= 900; + final heroHeight = (media.size.height * 0.66).clamp(420.0, 620.0); + + // Inject the flutter_ai theme extension into techpie's inherited ThemeData + // for this subtree only, picking light/dark to match the current brightness. + final inherited = Theme.of(context); + final aiExtension = inherited.brightness == Brightness.dark + ? AiThemeExtension.dark() + : AiThemeExtension.fallback(); + + return Theme( + data: inherited.copyWith( + extensions: [...inherited.extensions.values, aiExtension], + ), + child: Scaffold( + appBar: AppBar(title: const Text('AI 演示'), scrolledUnderElevation: 0), + body: SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _Centered( + child: _HeroHeader( + modelId: _modelId, + onSelectModel: _selectModel, + onNewChat: _controller.clear, + onOpenGallery: _openGallery, + ), + ), + ), + // Hero: the live, scripted chat. + SliverToBoxAdapter( + child: SizedBox( + height: heroHeight, + child: ChatScreen( + controller: _controller, + toolRunner: _toolRunner, + ), + ), + ), + SliverToBoxAdapter( + child: _Centered( + child: FeatureSections( + isWide: isWide, + onOpenGallery: _openGallery, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Centers its child at the package's reading width on wide screens. +class _Centered extends StatelessWidget { + const _Centered({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final maxWidth = AiThemeExtension.of(context).maxContentWidth; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: child, + ), + ); + } +} + +/// The tight hero header: brand wordmark + value prop + badges. +class _HeroHeader extends StatelessWidget { + const _HeroHeader({ + required this.modelId, + required this.onSelectModel, + required this.onNewChat, + required this.onOpenGallery, + }); + + final String modelId; + final ValueChanged onSelectModel; + final VoidCallback onNewChat; + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final subdued = + DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.62); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const _BrandGlyph(size: 30), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'flutter_ai', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + ), + IconButton( + icon: const Icon(Icons.edit_square), + tooltip: 'New chat', + onPressed: onNewChat, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'A scripted AI chat toolkit showcase — streaming, tools, ' + 'generative UI, citations.', + style: TextStyle(fontSize: 16, height: 1.4, color: subdued), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AiModelSelector( + models: demoModels, + selectedId: modelId, + onSelected: onSelectModel, + ), + const _Badge(label: '9 packages'), + const _Badge(label: 'pub.dev'), + const _Badge(label: 'zero lock-in'), + _GalleryButton(theme: theme, onTap: onOpenGallery), + ], + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +/// A small outlined badge chip used in the hero header. +class _Badge extends StatelessWidget { + const _Badge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: theme.borderColor), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: color?.withValues(alpha: 0.7), + ), + ), + ); + } +} + +/// A pill button that opens the full element gallery. +class _GalleryButton extends StatelessWidget { + const _GalleryButton({required this.theme, required this.onTap}); + + final AiThemeExtension theme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: theme.accentColor, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.grid_view_rounded, + size: 14, color: theme.onAccentColor,), + const SizedBox(width: 6), + Text( + 'Every element', + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: theme.onAccentColor, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// A live chat backed by a [UseChatController]. +class ChatScreen extends StatefulWidget { + const ChatScreen({ + super.key, + required this.controller, + required this.toolRunner, + }); + + final UseChatController controller; + final ToolRunner toolRunner; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + UseChatController get controller => widget.controller; + ToolRunner get toolRunner => widget.toolRunner; + + Object? _dismissedError; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListenableBuilder( + listenable: controller, + builder: (context, _) { + final messages = controller.messages.length; + if (controller.status != ChatStatus.error) { + _dismissedError = null; + } + final showError = controller.status == ChatStatus.error && + controller.error != _dismissedError; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + children: [ + if (messages > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), + child: AiContextMeter( + usedTokens: 1200 + messages * 850, + totalTokens: 128000, + ), + ), + if (showError) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: AiErrorBanner( + message: '${controller.error}', + onRetry: () => unawaited(controller.regenerate()), + onDismiss: () => setState( + () => _dismissedError = controller.error, + ), + ), + ), + ], + ), + ), + ); + }, + ), + Expanded( + child: AiChat( + controller: controller, + messageBuilder: _buildMessage, + emptyState: _emptyState(), + loadingBuilder: (_) => + const SizedBox(width: 220, child: AiShimmer()), + maxContentWidth: 760, + ), + ), + SafeArea( + top: false, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: AiPromptInput( + controller: controller, + onPickAttachment: _pickAttachment, + ), + ), + ), + ), + ], + ); + } + + Future> _pickAttachment() async => [ + FilePart( + mediaType: 'image/png', + bytes: sampleImageBytes, + name: 'photo.png', + ), + ]; + + void _snack(BuildContext context, String text) => showAdaptiveFeedback( + context: context, + message: text, + duration: const Duration(seconds: 1), + ); + + Future _editPrecedingUserMessage( + BuildContext context, + AiMessage assistant, + ) async { + final msgs = controller.messages; + final i = msgs.indexWhere((m) => m.id == assistant.id); + final userIndex = i == -1 + ? -1 + : msgs.sublist(0, i).lastIndexWhere((m) => m.role == AiRole.user); + if (userIndex == -1) return; + final user = msgs[userIndex]; + final edited = await showDialog( + context: context, + builder: (context) => _EditMessageDialog(initialText: user.text), + ); + if (edited != null && edited.trim().isNotEmpty) { + await controller.editMessage(user.id, edited.trim()); + } + } + + AiWidgetRegistry get _genUi => AiWidgetRegistry() + ..register( + 'chain_of_thought', + (context, data) => + AiChainOfThought(initiallyExpanded: true, steps: _steps(data)), + ) + ..register( + 'task', + (context, data) => AiTask( + title: data['title'] as String? ?? 'Task', + items: _taskItems(data), + ), + ) + ..register( + 'confirmation', + (context, data) => AiConfirmation( + title: data['title'] as String? ?? 'Confirm?', + description: data['description'] as String?, + onConfirm: () => _snack(context, 'Done.'), + onDeny: () => _snack(context, 'Cancelled.'), + ), + ); + + Widget _buildMessage(BuildContext context, AiMessage message) { + if (message.role == AiRole.user) return AiMessageBubble(message: message); + if (message.role == AiRole.tool) return const SizedBox.shrink(); + + final results = { + for (final m in controller.messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId: p, + }; + final sources = message.parts.whereType().toList(); + final toolCalls = message.parts.whereType().toList(); + final subdued = + DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6); + var toolsRendered = false; + + final children = []; + void add(Widget w) { + if (children.isNotEmpty) children.add(const SizedBox(height: 12)); + children.add(w); + } + + add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AiAvatar(role: AiRole.assistant, size: 24), + const SizedBox(width: 8), + Text('flutter_ai', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600, color: subdued,),), + ], + ),); + + for (final part in message.parts) { + switch (part) { + case ReasoningPart(:final text): + add(AiReasoning(text: text)); + case TextPart(:final text): + add(AiResponse(text: text, codeHighlighter: demoCodeHighlighter)); + case ToolCallPart(): + if (!toolsRendered) { + toolsRendered = true; + add( + toolCalls.length > 1 + ? AiToolGroup(calls: toolCalls, results: results) + : AiToolInvocation( + call: part, result: results[part.toolCallId],), + ); + } + case ToolResultPart(): + break; + case FilePart(): + if (part.mediaType.startsWith('image/')) { + add(SizedBox( + width: 260, + child: AiImage( + url: part.url, bytes: part.bytes, aspectRatio: 16 / 9,), + ),); + } else { + add(AiAttachment(file: part)); + } + case SourcePart(): + break; + case DataPart(): + add(AiDataView(part: part, registry: _genUi)); + } + } + + for (final call in toolCalls) { + final pending = toolRunner.pending[call.toolCallId]; + if (pending == null) continue; + final info = toolRunner.confirmationFor(pending); + add(AiConfirmation( + title: info.title, + description: info.description, + onConfirm: () => + toolRunner.resolveConfirmation(call.toolCallId, approved: true), + onDeny: () => + toolRunner.resolveConfirmation(call.toolCallId, approved: false), + ),); + } + + if (sources.isNotEmpty) { + add(AiSources( + sources: sources, + onTap: (source) => unawaited( + launchUrl(source.url, mode: LaunchMode.externalApplication),), + ),); + } + + if (message.status == AiMessageStatus.complete) { + add(Row( + children: [ + AiMessageActions( + message: message, + onGood: () => _snack(context, 'Thanks for the feedback!'), + onBad: () => _snack(context, 'Thanks — we\'ll do better.'), + onShare: () => _snack(context, 'Share sheet would open here.'), + onRegenerate: () => unawaited(controller.regenerate()), + onEdit: () => + unawaited(_editPrecedingUserMessage(context, message)), + ), + const Spacer(), + if (message == controller.messages.last && controller.branchCount > 1) + AiBranch( + index: controller.branchIndex, + total: controller.branchCount, + onPrevious: () => + controller.selectBranch(controller.branchIndex - 1), + onNext: () => controller.selectBranch(controller.branchIndex + 1), + ), + ], + ),); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } + + List _steps(Map data) { + final raw = (data['steps'] as List?) ?? const []; + return raw.map((s) { + final m = (s! as Map).cast(); + return AiThoughtStep( + label: m['label'] as String? ?? '', + detail: m['detail'] as String?, + isActive: m['active'] as bool? ?? false, + ); + }).toList(); + } + + List _taskItems(Map data) { + final raw = (data['items'] as List?) ?? const []; + return raw.map((item) { + final m = (item! as Map).cast(); + return AiTaskItem( + label: m['label'] as String? ?? '', + status: switch (m['status']) { + 'complete' => AiTaskStatus.complete, + 'active' => AiTaskStatus.active, + 'error' => AiTaskStatus.error, + _ => AiTaskStatus.pending, + }, + ); + }).toList(); + } + + void _onSuggestion(String text) { + if (text.startsWith('Summarize')) { + unawaited( + controller.sendText('Summarize this article', attachments: const [ + FilePart(mediaType: 'application/pdf', name: 'article.pdf'), + ],), + ); + } else { + unawaited(controller.sendText(text)); + } + } + + Widget _emptyState() => AiEmptyState( + glyph: const _BrandGlyph(size: 56), + title: 'Ask me anything', + subtitle: 'A live, scripted demo — no API key required.', + suggestions: const [ + 'Plan a weekend in Lisbon', + 'Suggest a dinner recipe', + 'How do I center a widget?', + 'Summarize this article', + ], + onSuggestionTap: _onSuggestion, + ); +} + +/// Edit-message dialog that owns its [TextEditingController]. +class _EditMessageDialog extends StatefulWidget { + const _EditMessageDialog({required this.initialText}); + + final String initialText; + + @override + State<_EditMessageDialog> createState() => _EditMessageDialogState(); +} + +class _EditMessageDialogState extends State<_EditMessageDialog> { + late final TextEditingController _field = + TextEditingController(text: widget.initialText); + + @override + void dispose() { + _field.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit message'), + content: TextField(controller: _field, autofocus: true, maxLines: null), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'),), + FilledButton( + onPressed: () => Navigator.pop(context, _field.text), + child: const Text('Save'),), + ], + ); + } +} + +/// The `flutter_ai` brand glyph. +class _BrandGlyph extends StatelessWidget { + const _BrandGlyph({this.size = 40}); + + final double size; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size * 0.28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.orbColor, + Color.lerp(theme.orbColor, theme.accentColor, 0.5)!, + ], + ), + boxShadow: [ + BoxShadow( + color: theme.orbColor.withValues(alpha: 0.35), + blurRadius: size * 0.35, + spreadRadius: size * 0.02, + ), + ], + ), + child: Icon(Icons.auto_awesome, + size: size * 0.5, color: theme.onAccentColor,), + ); + } +} + +/// A scrolling gallery of every element with sample data. +class GalleryScreen extends StatelessWidget { + const GalleryScreen({super.key}); + + @override + Widget build(BuildContext context) { + final items = galleryItems(); + final divider = AiThemeExtension.of(context).borderColor; + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: items.length, + separatorBuilder: (_, __) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1, color: divider), + ), + itemBuilder: (context, index) { + final item = items[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF9893A8), + letterSpacing: 0.2,),), + const SizedBox(height: 10), + item.child, + ], + ); + }, + ); + } +} diff --git a/lib/pages/ai_demo/code_highlighter.dart b/lib/pages/ai_demo/code_highlighter.dart new file mode 100644 index 0000000..42edf68 --- /dev/null +++ b/lib/pages/ai_demo/code_highlighter.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +/// A demo [CodeHighlighter] backed by the `highlight` package, mapping token +/// classes to a VS Code–dark-ish palette. Returns `null` (plain monospace) when +/// the language is unknown or parsing fails, so rendering never breaks. +List? demoCodeHighlighter( + String code, + String? language, + TextStyle base, +) { + try { + final result = (language != null && language.isNotEmpty) + ? highlight.parse(code, language: language) + : highlight.parse(code, autoDetection: true); + final nodes = result.nodes; + if (nodes == null) return null; + return [for (final node in nodes) _span(node, base)]; + } catch (_) { + return null; // unknown grammar → fall back to plain monospace + } +} + +const _tokenColors = { + 'keyword': Color(0xFFC586C0), + 'built_in': Color(0xFF4EC9B0), + 'type': Color(0xFF4EC9B0), + 'class': Color(0xFF4EC9B0), + 'title': Color(0xFFDCDCAA), + 'function': Color(0xFFDCDCAA), + 'string': Color(0xFFCE9178), + 'number': Color(0xFFB5CEA8), + 'symbol': Color(0xFFB5CEA8), + 'literal': Color(0xFF569CD6), + 'comment': Color(0xFF6A9955), + 'meta': Color(0xFF9CDCFE), + 'attr': Color(0xFF9CDCFE), +}; + +TextSpan _span(Node node, TextStyle base) { + final color = node.className == null ? null : _tokenColors[node.className!]; + final style = color == null ? base : base.copyWith(color: color); + final value = node.value; + if (value != null) return TextSpan(text: value, style: style); + final children = node.children ?? const []; + return TextSpan( + style: style, + children: [for (final child in children) _span(child, base)], + ); +} diff --git a/lib/pages/ai_demo/demo_data.dart b/lib/pages/ai_demo/demo_data.dart new file mode 100644 index 0000000..81d8b4c --- /dev/null +++ b/lib/pages/ai_demo/demo_data.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +/// The demo uses the package's modern default skin as-is. +final AiThemeExtension demoTheme = AiThemeExtension.fallback(); + +/// A tiny 1×1 PNG, used to fake a "picked" image and show the AiImage frame +/// without a network round-trip. +final Uint8List sampleImageBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen6' + '3NgAAAAASUVORK5CYII=', +); + +/// Models offered by the demo's model selector. These are real Gemini model +/// ids, so selecting one drives the live provider when a key is supplied. +const List demoModels = [ + AiModelOption( + id: 'gemini-2.5-flash', + label: 'Gemini 2.5 Flash', + description: 'Fast and capable', + ), + AiModelOption( + id: 'gemini-2.0-flash', + label: 'Gemini 2.0 Flash', + description: 'Fast and economical', + ), + AiModelOption( + id: 'gemini-2.5-pro', + label: 'Gemini 2.5 Pro', + description: 'Best reasoning', + ), +]; + +/// One entry in the element gallery. +typedef GalleryItem = ({String name, String title, Widget child}); + +final Uri _weatherUri = Uri.parse('https://weather.example.com/london'); + +/// A rich assistant message exercising several part types at once. +final AiMessage richAssistantMessage = AiMessage( + id: 'a-rich', + role: AiRole.assistant, + parts: [ + const ReasoningPart( + 'The user wants current weather, so I should call a tool.', + ), + const TextPart('Let me check the weather for you.'), + const ToolCallPart( + toolCallId: 'call_1', + toolName: 'get_weather', + args: {'city': 'London'}, + state: ToolCallState.outputAvailable, + ), + const ToolResultPart( + toolCallId: 'call_1', + result: {'tempC': 18, 'condition': 'Rainy'}, + ), + const TextPart("It's 18°C and rainy in London — bring an umbrella!"), + SourcePart(url: _weatherUri, title: 'weather.example.com'), + ], +); + +/// The elements gallery, each wrapped for display and screenshotting. +List galleryItems() => [ + ( + name: 'message_user', + title: 'AiMessageBubble — user', + child: const AiMessageBubble( + message: AiMessage( + id: 'u1', + role: AiRole.user, + parts: [TextPart('What is the weather in London today?')], + ), + ), + ), + ( + name: 'message_assistant', + title: 'AiMessageBubble — assistant (rich)', + child: AiMessageBubble(message: richAssistantMessage), + ), + (name: 'loader', title: 'AiLoader', child: const AiLoader()), + ( + name: 'reasoning', + title: 'AiReasoning', + child: const AiReasoning( + text: 'First, identify the city. Then call the weather tool and ' + 'summarize the result for the user.', + initiallyExpanded: true, + ), + ), + ( + name: 'tool_invocation', + title: 'AiToolInvocation', + child: const AiToolInvocation( + call: ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'London', 'units': 'metric'}, + state: ToolCallState.outputAvailable, + ), + result: ToolResultPart( + toolCallId: 'c1', + result: {'tempC': 18, 'condition': 'Rainy'}, + ), + initiallyExpanded: true, + ), + ), + ( + name: 'tool_group', + title: 'AiToolGroup — parallel calls', + child: const AiToolGroup( + calls: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + state: ToolCallState.outputAvailable, + ), + ToolCallPart( + toolCallId: 'c2', + toolName: 'web_search', + state: ToolCallState.executing, + ), + ], + results: { + 'c1': ToolResultPart(toolCallId: 'c1', result: {'tempC': 18}), + }, + ), + ), + ( + name: 'tool_error', + title: 'AiToolInvocation — error', + child: const AiToolInvocation( + call: ToolCallPart( + toolCallId: 'e1', + toolName: 'charge_card', + args: {'amountEur': 420}, + state: ToolCallState.error, + ), + result: ToolResultPart( + toolCallId: 'e1', + result: {'message': 'Card declined: insufficient funds'}, + isError: true, + ), + initiallyExpanded: true, + ), + ), + ( + name: 'attachment', + title: 'AiAttachment', + child: const AiAttachment( + file: FilePart(mediaType: 'application/pdf', name: 'itinerary.pdf'), + ), + ), + ( + name: 'sources', + title: 'AiSources', + child: AiSources( + sources: [ + SourcePart(url: _weatherUri, title: 'weather.example.com'), + SourcePart(url: Uri.parse('https://flutter.dev'), title: 'Flutter'), + ], + ), + ), + ( + name: 'code_block', + title: 'AiCodeBlock', + child: const AiCodeBlock( + language: 'dart', + code: "void main() {\n print('Hello, flutter_ai!');\n}", + ), + ), + ( + name: 'response', + title: 'AiResponse — Markdown', + child: const AiResponse( + text: '## Streaming\n\nFold the **event stream** with a reducer:\n\n' + '- rebuild only changed messages\n' + '- stays at `60fps`\n\n' + 'See the [docs](https://docs.flutter.dev/ai).', + ), + ), + ( + name: 'chain_of_thought', + title: 'AiChainOfThought', + child: const AiChainOfThought( + initiallyExpanded: true, + steps: [ + AiThoughtStep( + label: 'Search the web', detail: 'flutter stream tokens',), + AiThoughtStep(label: 'Read top results'), + AiThoughtStep(label: 'Synthesize an answer', isActive: true), + ], + ), + ), + ( + name: 'task', + title: 'AiTask', + child: const AiTask( + title: 'Refactor the controller', + items: [ + AiTaskItem( + label: 'Read use_chat_controller.dart', + status: AiTaskStatus.complete, + ), + AiTaskItem( + label: 'Extract _startStream()', + status: AiTaskStatus.active, + ), + AiTaskItem(label: 'Update tests'), + ], + ), + ), + ( + name: 'inline_citation', + title: 'AiInlineCitation', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Backed by sources'), + SizedBox(width: 6), + AiInlineCitation(number: 1), + SizedBox(width: 4), + AiInlineCitation(number: 2), + ], + ), + ), + ( + name: 'branch', + title: 'AiBranch — versions (tap ‹ ›)', + child: const _BranchDemo(), + ), + ( + name: 'image', + title: 'AiImage', + child: SizedBox( + width: 200, + child: AiImage(bytes: sampleImageBytes, aspectRatio: 16 / 9), + ), + ), + ( + name: 'model_selector', + title: 'AiModelSelector', + child: AiModelSelector( + models: demoModels, + selectedId: 'gemini-2.5-flash', + onSelected: (_) {}, + ), + ), + ( + name: 'confirmation', + title: 'AiConfirmation', + child: AiConfirmation( + title: 'Send this email to the team?', + description: 'Subject: "Weekend plan in Lisbon"', + onConfirm: () {}, + onDeny: () {}, + ), + ), + ( + name: 'context_meter', + title: 'AiContextMeter', + child: const AiContextMeter(usedTokens: 8200, totalTokens: 128000), + ), + (name: 'shimmer', title: 'AiShimmer', child: const AiShimmer()), + ( + name: 'live_session', + title: 'AiLiveSession — voice mode', + child: SizedBox( + height: 360, + child: AiLiveSession( + status: AiLiveStatus.speaking, + amplitude: 0.6, + transcript: 'Lisbon is sunny, about 24°C this weekend.', + onMute: () {}, + onKeyboard: () {}, + onEnd: () {}, + ), + ), + ), + ( + name: 'suggestions', + title: 'AiSuggestions', + child: AiSuggestions( + suggestions: const [ + 'Summarize this', + 'Translate to French', + 'Explain', + ], + onSelected: (_) {}, + ), + ), + ( + name: 'avatars', + title: 'AiAvatar', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + AiAvatar(role: AiRole.user), + SizedBox(width: 12), + AiAvatar(role: AiRole.assistant), + ], + ), + ), + ( + name: 'empty_state', + title: 'AiEmptyState', + child: const SizedBox( + height: 220, + child: AiEmptyState( + title: 'Ask me anything', + subtitle: 'Your AI assistant is ready to help.', + ), + ), + ), + ( + name: 'error_banner', + title: 'AiErrorBanner', + child: AiErrorBanner( + message: 'The request timed out.', + onRetry: () {}, + onDismiss: () {}, + ), + ), + ( + name: 'message_actions', + title: 'AiMessageActions', + child: AiMessageActions( + message: const AiMessage( + id: 'm', + role: AiRole.assistant, + parts: [TextPart('hi')], + ), + onRegenerate: () {}, + onEdit: () {}, + ), + ), + ( + name: 'composer_idle', + title: 'AiComposer — attach · mic · live', + child: AiComposer( + onSend: (_) {}, + onAttach: () {}, + onVoice: () {}, + onLive: () {}, + ), + ), + ( + name: 'composer_busy', + title: 'AiComposer — streaming (Stop)', + child: AiComposer(onSend: (_) {}, onStop: () {}, isBusy: true), + ), + ]; + +/// A live [AiBranch] whose arrows actually move between versions. +class _BranchDemo extends StatefulWidget { + const _BranchDemo(); + + @override + State<_BranchDemo> createState() => _BranchDemoState(); +} + +class _BranchDemoState extends State<_BranchDemo> { + int _index = 0; + static const _total = 3; + + @override + Widget build(BuildContext context) => AiBranch( + index: _index, + total: _total, + onPrevious: () => setState(() => _index--), + onNext: () => setState(() => _index++), + ); +} diff --git a/lib/pages/ai_demo/demo_provider.dart b/lib/pages/ai_demo/demo_provider.dart new file mode 100644 index 0000000..0004dbd --- /dev/null +++ b/lib/pages/ai_demo/demo_provider.dart @@ -0,0 +1,302 @@ +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import 'demo_data.dart'; + +/// A scripted [LlmProvider] with everyday mobile-chat scenarios (trip planning, +/// a recipe, summarizing an article) so the elements appear in realistic use. +/// +/// Structured widgets ride along as `DataPart`s; the demo's `messageBuilder` +/// maps them to elements (a tiny generative-UI catalog). A prompt containing +/// "error" streams a failure to demo the error path. +/// +/// Fully offline: no HTTP, no live model. Images use bundled bytes +/// ([sampleImageBytes]) so nothing leaves the device. +class DemoChatProvider implements LlmProvider { + /// Creates a demo provider with a per-event [delay] for a streaming feel. + const DemoChatProvider({this.delay = const Duration(milliseconds: 95)}); + + /// Delay between emitted events. + final Duration delay; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) { + final prompt = conversation.lastMessage?.text.toLowerCase() ?? ''; + final id = 'assistant-${conversation.messages.length}'; + if (prompt.contains('error')) return _error(id); + if (prompt.contains('recipe') || + prompt.contains('dinner') || + prompt.contains('cook')) { + return _recipe(id); + } + if (prompt.contains('summar')) return _summary(id); + if (prompt.contains('center') || + prompt.contains('widget') || + prompt.contains('code')) { + return _code(id); + } + return _trip(id); + } + + Future _step(AiStreamEvent event) async { + await Future.delayed(delay); + return event; + } + + Stream _textChunks(String id, String text, + {Duration? chunkDelay,}) async* { + final words = text.split(' '); + final d = chunkDelay ?? const Duration(milliseconds: 40); + for (var i = 0; i < words.length; i++) { + final chunk = i == 0 ? words[i] : ' ${words[i]}'; + await Future.delayed(d); + yield TextDelta(messageId: id, delta: chunk); + } + } + + Stream _error(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta(messageId: id, delta: 'Attempting the request…'), + ); + yield await _step( + StreamErrorEvent( + error: 'The upstream service timed out. Please try again.', + messageId: id, + ), + ); + } + + Stream _trip(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'chain_of_thought', + data: { + 'steps': [ + {'label': 'Check the weekend weather'}, + {'label': 'Find the top sights'}, + {'label': 'Build a 2-day plan', 'active': true}, + ], + }, + ), + ), + ); + yield* _textChunks( + id, + "Lisbon is a great pick — here's a quick weekend plan.", + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'task', + data: { + 'title': 'Trip checklist', + 'items': [ + {'label': 'Book flights', 'status': 'complete'}, + {'label': 'Reserve a hotel', 'status': 'active'}, + {'label': 'Pack essentials', 'status': 'pending'}, + ], + }, + ), + ), + ); + yield* _tool(id, 'get_weather', '{"city":"Lisbon"}', { + 'tempC': 24, + 'condition': 'Sunny', + }); + yield* _tool( + id, + 'find_hotels', + '{"city":"Lisbon","nights":2}', + { + 'count': 12, + 'topRate': 210, + }, + tag: 't2',); + yield* _textChunks( + id, + '## Day 1\n' + '- Morning: Belém Tower and pastéis de nata\n' + '- Afternoon: wander Alfama and São Jorge Castle\n\n' + '## Day 2\n' + '- Morning: Time Out Market\n' + '- Afternoon: day trip to Sintra\n\n' + 'The forecast is **sunny, ~24°C** — pack light!', + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'confirmation', + data: { + 'title': 'Reserve Hotel Lisboa for €420?', + 'description': '2 nights · breakfast included · free cancellation', + }, + ), + ), + ); + yield* _image(id, 'lisbon'); + yield* _sources(id, const [ + ('https://www.timeout.com/lisbon', 'timeout.com'), + ('https://www.lonelyplanet.com/portugal/lisbon', 'lonelyplanet.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _recipe(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'chain_of_thought', + data: { + 'steps': [ + {'label': 'Look for something quick'}, + {'label': 'Pick a crowd-pleaser', 'active': true}, + ], + }, + ), + ), + ); + yield* _textChunks( + id, + 'How about one-pan lemon chicken? Ready in about 30 minutes.', + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'task', + data: { + 'title': 'Ingredients', + 'items': [ + {'label': 'Chicken thighs', 'status': 'complete'}, + {'label': 'Lemon & garlic', 'status': 'complete'}, + {'label': 'Baby spinach', 'status': 'pending'}, + ], + }, + ), + ), + ); + yield* _tool(id, 'search_recipes', '{"q":"30 minute dinner"}', { + 'results': 5, + }); + yield* _textChunks( + id, + '## Steps\n' + '1. Sear the chicken 5 minutes per side\n' + '2. Add garlic, lemon, and a splash of stock\n' + '3. Simmer 10 minutes, then stir in the spinach\n\n' + '**Tip:** serve over rice or with crusty bread.', + ); + yield* _image(id, 'dinner'); + yield* _sources(id, const [ + ('https://www.bbcgoodfood.com', 'bbcgoodfood.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _summary(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta( + messageId: id, + delta: 'Skimming the article for the key points.', + ), + ); + yield* _textChunks( + id, + '**Summary**\n\nThe article makes three points:\n\n' + '- Streaming UIs must batch updates to stay smooth\n' + '- Tool calls should be inspectable, not hidden\n' + '- Citations build user trust\n\n' + 'Overall, a strong case for *structured* AI interfaces.', + ); + yield* _sources(id, const [ + ('https://www.smashingmagazine.com', 'smashingmagazine.com'), + ('https://www.nngroup.com', 'nngroup.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _tool( + String id, + String name, + String args, + Map result, { + String tag = 't1', + }) async* { + final callId = '$id-$tag'; + yield await _step( + ToolCallStarted(messageId: id, toolCallId: callId, toolName: name), + ); + yield await _step(ToolCallDelta(toolCallId: callId, argumentsDelta: args)); + yield await _step(ToolCallReady(toolCallId: callId)); + yield await _step( + ToolResultReceived(messageId: id, toolCallId: callId, result: result), + ); + } + + Stream _code(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta(messageId: id, delta: 'Recalling the idiomatic way.'), + ); + yield* _textChunks( + id, + 'Wrap the child in a `Center`:\n\n' + '```dart\n' + 'Center(\n' + " child: Text('Hi'),\n" + ')\n' + '```\n\n' + 'For finer control, use `Align` with an `alignment`.', + ); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + // Offline image: a bundled 1×1 PNG (demo_data.sampleImageBytes), so AiImage + // renders without any network round-trip or INTERNET permission. + Stream _image(String id, String seed) async* { + yield await _step( + PartReceived( + messageId: id, + part: FilePart( + mediaType: 'image/png', + bytes: sampleImageBytes, + name: '$seed.png', + ), + ), + ); + } + + Stream _sources( + String id, + List<(String, String)> sources, + ) async* { + for (final (url, title) in sources) { + yield await _step( + PartReceived( + messageId: id, + part: SourcePart(url: Uri.parse(url), title: title), + ), + ); + } + } +} diff --git a/lib/pages/ai_demo/demo_tools.dart b/lib/pages/ai_demo/demo_tools.dart new file mode 100644 index 0000000..a72dc8c --- /dev/null +++ b/lib/pages/ai_demo/demo_tools.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +/// Tools advertised to the model. The model decides *if* and *when* to call +/// them; [ToolRunner] executes the calls and feeds results back. +/// +/// Ask the live model things like "What's the weather in Lisbon?" or +/// "Book a hotel in Lisbon for 2 nights" to trigger them. +const List demoTools = [ + ToolDefinition( + name: 'get_weather', + description: 'Get the current weather for a city. Call this whenever the ' + 'user asks about weather.', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string', 'description': 'City name, e.g. "Lisbon"'}, + }, + 'required': ['city'], + }, + ), + ToolDefinition( + name: 'book_hotel', + description: 'Book a hotel room in a city for a number of nights. This ' + 'charges the user, so it must be confirmed first.', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + 'nights': {'type': 'integer', 'description': 'Number of nights'}, + }, + 'required': ['city', 'nights'], + }, + ), +]; + +/// Drives the tool-call loop for the demo: when the model emits a tool call, +/// safe tools run immediately while sensitive ones wait for the user to confirm +/// — then results are sent back so the model can finish its answer. Supports +/// multiple rounds (a real agentic loop). +/// +/// Only acts on calls the model leaves *open* (no result yet), so the scripted +/// [DemoChatProvider], whose tool calls already carry inline results, is +/// untouched. This makes the elements genuinely exercised by a live provider. +class ToolRunner extends ChangeNotifier { + /// Watches [controller] and runs its tool calls. + ToolRunner(this._controller) { + _controller.addListener(_pump); + } + + final UseChatController _controller; + + // Tools that require explicit user approval before they run. + static const Set _needsConfirmation = {'book_hotel'}; + + final Map _pending = {}; // awaiting confirmation + final Map _ready = {}; // executed, not yet sent + final Set _dispatched = {}; // results already sent to the model + bool _busy = false; + + /// Tool calls awaiting user confirmation, keyed by tool-call id. + Map get pending => Map.unmodifiable(_pending); + + @override + void dispose() { + _controller.removeListener(_pump); + super.dispose(); + } + + /// Confirms or rejects a sensitive tool call, then continues the loop. + void resolveConfirmation(String toolCallId, {required bool approved}) { + final call = _pending.remove(toolCallId); + if (call == null) return; + _ready[toolCallId] = _execute(call, approved: approved); + notifyListeners(); + _pump(); + } + + /// Human-readable confirmation copy for a pending sensitive call. + ({String title, String description}) confirmationFor(ToolCallPart call) { + final city = call.args['city'] as String? ?? 'the city'; + final nights = (call.args['nights'] as num?)?.toInt() ?? 1; + return ( + title: + 'Book Hotel Lisboa in $city for $nights night${nights == 1 ? '' : 's'}?', + description: 'Estimated total €${nights * 210} · free cancellation', + ); + } + + void _pump() { + if (_busy || _controller.status != ChatStatus.idle) return; + final messages = _controller.messages; + + // Calls already answered anywhere in the transcript (incl. the scripted + // provider's inline results) are left alone. + final resolved = { + for (final m in messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId, + }; + + final open = []; + for (final m in messages.reversed) { + if (m.role != AiRole.assistant) continue; + final calls = m.parts.whereType(); + if (calls.isEmpty) continue; + open.addAll( + calls.where( + (c) => + !resolved.contains(c.toolCallId) && + !_dispatched.contains(c.toolCallId), + ), + ); + break; // only the most recent assistant turn can have open calls + } + if (open.isEmpty) return; + + // Resolve each: auto-run safe tools, queue sensitive ones for confirmation. + for (final call in open) { + final id = call.toolCallId; + if (_ready.containsKey(id) || _pending.containsKey(id)) continue; + if (_needsConfirmation.contains(call.toolName)) { + _pending[id] = call; + } else { + _ready[id] = _execute(call, approved: true); + } + } + notifyListeners(); + + // Providers need a result for *every* call before continuing, so only send + // once all open calls are ready (none awaiting confirmation). + if (open.every((c) => _ready.containsKey(c.toolCallId))) { + final results = [for (final c in open) _ready.remove(c.toolCallId)!]; + for (final c in open) { + _dispatched.add(c.toolCallId); + } + _busy = true; + unawaited( + _controller.addToolResults(results).whenComplete(() { + _busy = false; + _pump(); // a further tool round may follow + }), + ); + } + } + + // Mock implementations — a real app would call its backend here. + ToolResultPart _execute(ToolCallPart call, {required bool approved}) { + final id = call.toolCallId; + if (!approved) { + return ToolResultPart( + toolCallId: id, + result: {'status': 'declined', 'note': 'User did not approve.'}, + isError: true, + ); + } + switch (call.toolName) { + case 'get_weather': + final city = call.args['city'] as String? ?? 'your city'; + return ToolResultPart( + toolCallId: id, + result: {'city': city, 'tempC': 22, 'condition': 'Sunny'}, + ); + case 'book_hotel': + final city = call.args['city'] as String? ?? 'the city'; + final nights = (call.args['nights'] as num?)?.toInt() ?? 1; + return ToolResultPart( + toolCallId: id, + result: { + 'status': 'booked', + 'hotel': 'Hotel Lisboa', + 'city': city, + 'nights': nights, + 'totalEur': nights * 210, + 'confirmation': 'LSB-${1000 + nights}', + }, + ); + default: + return ToolResultPart( + toolCallId: id, + result: {'status': 'unknown tool: ${call.toolName}'}, + isError: true, + ); + } + } +} diff --git a/lib/pages/ai_demo/feature_sections.dart b/lib/pages/ai_demo/feature_sections.dart new file mode 100644 index 0000000..cea625d --- /dev/null +++ b/lib/pages/ai_demo/feature_sections.dart @@ -0,0 +1,576 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'code_highlighter.dart'; + +/// The scrollable marketing feature sections shown beneath the hero chat. +/// +/// Each section is a short title + one-line blurb + a *live* mini-demo built +/// from the real `flutter_ai_elements` widgets and scripted data — so the page +/// doubles as a guided tour of the package's surface. +class FeatureSections extends StatelessWidget { + /// Creates the feature sections. + const FeatureSections({ + super.key, + required this.isWide, + required this.onOpenGallery, + }); + + /// Whether the viewport is wide enough to lay demos out side-by-side. + final bool isWide; + + /// Opens the full element gallery. + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionDivider(), + const _Section( + title: 'Streaming & Markdown', + blurb: + 'Tokens stream in with a blur fade-in, then settle into rich ' + 'Markdown — headings, code, lists, quotes and links.', + child: _StreamingDemo(), + ), + const _Section( + title: 'Generative UI', + blurb: + 'The model emits typed data parts; an allowlist registry maps ' + 'each to a real widget — thoughts, tasks, confirmations.', + child: _GenerativeUiDemo(), + ), + const _Section( + title: 'Tool calling', + blurb: 'Inspect what the agent did. Parallel calls group together ' + 'with arguments and results — never hidden.', + child: _ToolCallingDemo(), + ), + const _Section( + title: 'Citations & grounding', + blurb: 'Show where answers came from. Source chips carry favicons, ' + 'index badges and hover — tap to open.', + child: _CitationsDemo(), + ), + _Section( + title: 'Theming', + blurb: 'One AiThemeExtension restyles everything. Here is the same ' + 'answer in light and dark, side by side.', + child: _ThemingDemo(isWide: isWide), + ), + _Section( + title: 'Every element', + blurb: + 'Thirty-plus composable widgets, each themeable and testable. ' + 'Browse the full gallery with sample data.', + child: _GalleryCta(onOpenGallery: onOpenGallery), + ), + ], + ), + ); + } +} + +/// A single feature section: title, blurb, and a live demo card. +class _Section extends StatelessWidget { + const _Section({ + required this.title, + required this.blurb, + required this.child, + }); + + final String title; + final String blurb; + final Widget child; + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: color, + ), + ), + const SizedBox(height: 6), + Text( + blurb, + style: TextStyle( + fontSize: 15, + height: 1.45, + color: color?.withValues(alpha: 0.62), + ), + ), + const SizedBox(height: 16), + _DemoCard(child: child), + const _SectionDivider(), + ], + ), + ); + } +} + +/// A bordered surface that frames a live mini-demo. +class _DemoCard extends StatelessWidget { + const _DemoCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: theme.borderColor), + ), + child: child, + ); + } +} + +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: + Divider(height: 1, color: AiThemeExtension.of(context).borderColor), + ); +} + +// ---- Streaming & Markdown -------------------------------------------------- + +/// Renders Markdown progressively — text grows chunk by chunk with a short +/// delay per chunk, and [AiResponse] re-parses Markdown on each update so +/// headings, bold, code blocks, etc. appear in real time. +class _StreamingDemo extends StatefulWidget { + const _StreamingDemo(); + + @override + State<_StreamingDemo> createState() => _StreamingDemoState(); +} + +class _StreamingDemoState extends State<_StreamingDemo> { + static const String _markdown = '## Streaming, done right\n\n' + 'Fold the **event stream** with a reducer so only the *changed* ' + 'message rebuilds:\n\n' + '```dart\n' + 'stream.listen((event) {\n' + ' state = reduce(state, event);\n' + '});\n' + '```\n\n' + '- Stays at `60fps` while tokens arrive\n\n' + 'What ships:\n\n' + '- [x] Streaming Markdown\n' + '- [x] Tool calls\n' + '- [ ] Your idea here\n\n' + '> Trust comes from showing the work.\n\n' + 'See the [docs](https://docs.flutter.dev).'; + + /// Chunk size (characters) revealed per tick. + static const int _chunkSize = 3; + + /// Delay between chunks — a simulated token arrival rate. + static const Duration _chunkDelay = Duration(milliseconds: 30); + + String _shownText = ''; + bool _streaming = false; + Timer? _timer; + int _cursor = 0; + + void _replay() { + _timer?.cancel(); + setState(() { + _streaming = true; + _shownText = ''; + _cursor = 0; + }); + _timer = Timer.periodic(_chunkDelay, (_) { + if (_cursor >= _markdown.length) { + _timer?.cancel(); + setState(() => _streaming = false); + return; + } + final end = (_cursor + _chunkSize).clamp(0, _markdown.length); + setState(() { + _shownText = _markdown.substring(0, end); + _cursor = end; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AiResponse( + text: _streaming ? _shownText : _markdown, + codeHighlighter: demoCodeHighlighter, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: _GhostButton( + icon: Icons.play_arrow_rounded, + label: _streaming ? 'Streaming…' : 'Replay streaming', + onTap: _streaming ? null : _replay, + ), + ), + ], + ); + } +} + +// ---- Generative UI --------------------------------------------------------- + +/// Drives a tiny generative-UI catalog: each [DataPart] dataType resolves to a +/// real widget via [AiWidgetRegistry] — including a danger-tone +/// [AiConfirmation]. +class _GenerativeUiDemo extends StatelessWidget { + const _GenerativeUiDemo(); + + static final AiWidgetRegistry _registry = AiWidgetRegistry() + ..register( + 'chain_of_thought', + (context, data) => const AiChainOfThought( + initiallyExpanded: true, + steps: [ + AiThoughtStep(label: 'Read the request'), + AiThoughtStep(label: 'Draft the email'), + AiThoughtStep(label: 'Await approval', isActive: true), + ], + ), + ) + ..register( + 'task', + (context, data) => const AiTask( + title: 'Send weekly digest', + items: [ + AiTaskItem(label: 'Gather metrics', status: AiTaskStatus.complete), + AiTaskItem(label: 'Compose email', status: AiTaskStatus.active), + AiTaskItem(label: 'Send to list', status: AiTaskStatus.pending), + ], + ), + ) + ..register( + 'confirmation', + (context, data) => AiConfirmation( + tone: AiConfirmationTone.danger, + icon: Icons.delete_outline, + title: 'Delete all 1,284 archived records?', + description: 'This cannot be undone.', + confirmLabel: 'Delete', + onConfirm: () {}, + onDeny: () {}, + ), + ); + + static const List _parts = [ + DataPart(dataType: 'chain_of_thought', data: {}), + DataPart(dataType: 'task', data: {}), + DataPart(dataType: 'confirmation', data: {}), + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final part in _parts) ...[ + AiDataView(part: part, registry: _registry), + if (part != _parts.last) const SizedBox(height: 12), + ], + ], + ); + } +} + +// ---- Tool calling ---------------------------------------------------------- + +/// Shows a parallel tool call rendered with [AiToolGroup]: two calls, one +/// resolved with a result, one still executing. +class _ToolCallingDemo extends StatelessWidget { + const _ToolCallingDemo(); + + @override + Widget build(BuildContext context) { + return const AiToolGroup( + calls: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'Lisbon'}, + state: ToolCallState.outputAvailable, + ), + ToolCallPart( + toolCallId: 'c2', + toolName: 'find_hotels', + args: {'city': 'Lisbon', 'nights': 2}, + state: ToolCallState.executing, + ), + ], + results: { + 'c1': ToolResultPart( + toolCallId: 'c1', + result: {'tempC': 24, 'condition': 'Sunny'}, + ), + }, + ); + } +} + +// ---- Citations & grounding ------------------------------------------------- + +/// A few real-looking sources rendered with favicons enabled, plus a sentence +/// carrying inline [AiInlineCitation] badges. +class _CitationsDemo extends StatelessWidget { + const _CitationsDemo(); + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + style: TextStyle(fontSize: 15.5, height: 1.5, color: color), + children: const [ + TextSpan(text: 'Lisbon is sunny, about 24°C this weekend '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: AiInlineCitation(number: 1), + ), + TextSpan(text: ' with a Sintra day trip recommended '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: AiInlineCitation(number: 2), + ), + TextSpan(text: '.'), + ], + ), + ), + const SizedBox(height: 14), + AiSources( + showFavicons: true, + sources: [ + SourcePart( + url: Uri.parse('https://www.timeout.com/lisbon'), + title: 'timeout.com', + ), + SourcePart( + url: Uri.parse('https://www.lonelyplanet.com/portugal/lisbon'), + title: 'lonelyplanet.com', + ), + SourcePart( + url: Uri.parse('https://flutter.dev'), + title: 'flutter.dev', + ), + ], + onTap: (source) => unawaited( + launchUrl(source.url, mode: LaunchMode.externalApplication), + ), + ), + ], + ); + } +} + +// ---- Theming --------------------------------------------------------------- + +/// The same answer rendered through both [AiThemeExtension.fallback] (light) +/// and [AiThemeExtension.dark], to show that one token set restyles everything. +class _ThemingDemo extends StatelessWidget { + const _ThemingDemo({required this.isWide}); + + final bool isWide; + + static const String _md = + '## Day 1\n- Belém Tower & pastéis de nata\n- Alfama and the castle\n\n' + 'Sunny, **~24°C** — pack light.'; + + @override + Widget build(BuildContext context) { + final light = _Themed( + label: 'Light', + brightness: Brightness.light, + extension: AiThemeExtension.fallback(), + background: Colors.white, + ); + final dark = _Themed( + label: 'Dark', + brightness: Brightness.dark, + extension: AiThemeExtension.dark(), + background: const Color(0xFF131316), + ); + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: light), + const SizedBox(width: 12), + Expanded(child: dark), + ], + ); + } + return Column(children: [light, const SizedBox(height: 12), dark]); + } +} + +/// A small message preview wrapped in its own [Theme] so the [extension] and +/// brightness apply only to this subtree. +class _Themed extends StatelessWidget { + const _Themed({ + required this.label, + required this.brightness, + required this.extension, + required this.background, + }); + + final String label; + final Brightness brightness; + final AiThemeExtension extension; + final Color background; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData( + useMaterial3: true, + brightness: brightness, + extensions: [extension], + ), + child: Builder( + builder: (context) { + final fg = brightness == Brightness.dark + ? const Color(0xFFECECEC) + : const Color(0xFF0D0D0D); + return DefaultTextStyle.merge( + style: TextStyle(color: fg), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: extension.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: fg.withValues(alpha: 0.5), + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 10), + const AiMessageBubble( + message: AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart('Plan a weekend in Lisbon')], + ), + ), + const SizedBox(height: 8), + const AiResponse(text: _ThemingDemo._md), + ], + ), + ), + ); + }, + ), + ); + } +} + +// ---- Gallery CTA ----------------------------------------------------------- + +class _GalleryCta extends StatelessWidget { + const _GalleryCta({required this.onOpenGallery}); + + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: _GhostButton( + icon: Icons.grid_view_rounded, + label: 'Browse the full gallery', + onTap: onOpenGallery, + ), + ); + } +} + +// ---- Shared ---------------------------------------------------------------- + +/// A bordered, low-emphasis action button used across the sections. +class _GhostButton extends StatelessWidget { + const _GhostButton({required this.icon, required this.label, this.onTap}); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + return Material( + color: theme.assistantBubbleColor, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/ai_gallery_page.dart b/lib/pages/ai_gallery_page.dart new file mode 100644 index 0000000..be256a2 --- /dev/null +++ b/lib/pages/ai_gallery_page.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/ai_chat.dart'; +import '../utils/platform.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// A gallery of built-in prompt templates. Tapping one pops the page and +/// returns the chosen [AiPromptTemplate] to the chat page, which pre-fills it +/// into the composer. +class AiGalleryPage extends StatelessWidget { + const AiGalleryPage({super.key}); + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '提示词画廊', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + onItemPressed: (id) { + if (id == 'back') unawaited(Navigator.maybePop(context)); + }, + ) + : const BlurredAppBar(title: Text('提示词画廊')), + body: ListView.separated( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top + 8, + bottom: 16, + ), + itemCount: aiPromptGallery.length, + separatorBuilder: (context, _) => const Divider(height: 1, indent: 72), + itemBuilder: (context, i) { + final t = aiPromptGallery[i]; + return ListTile( + leading: CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + child: Icon(t.icon), + ), + title: Text(t.title, style: theme.textTheme.titleSmall), + subtitle: Text( + t.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).pop(t), + ); + }, + ), + ); + } +} diff --git a/lib/pages/ai_history_page.dart b/lib/pages/ai_history_page.dart new file mode 100644 index 0000000..f252988 --- /dev/null +++ b/lib/pages/ai_history_page.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_feedback.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// Lists past conversations. Tap to open (returns the id), swipe/trailing to +/// delete, rename via the overflow menu. A "新对话" action in the app bar +/// creates a fresh thread. +class AiHistoryPage extends StatelessWidget { + const AiHistoryPage({super.key, required this.aiService}); + + final AiService aiService; + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '历史会话', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'new', + title: '新对话', + sfSymbol: 'square.and.pencil', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'new': + final conv = aiService.newConversation(); + Navigator.of(context).pop(conv.id); + } + }, + ) + : BlurredAppBar( + title: const Text('历史会话'), + actions: [ + IconButton( + tooltip: '新对话', + icon: const Icon(Icons.add_comment_outlined), + onPressed: () { + final conv = aiService.newConversation(); + Navigator.of(context).pop(conv.id); + }, + ), + ], + ), + body: ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final conversations = aiService.conversations; + if (conversations.isEmpty) { + return _empty(context, theme, colorScheme); + } + final currentId = aiService.currentConversation?.id; + return ListView.separated( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 8 + : adaptiveTopBarHeight() + + MediaQuery.viewPaddingOf(context).top + + 8, + bottom: 16, + ), + itemCount: conversations.length, + separatorBuilder: (context, _) => const Divider(height: 1, indent: 72), + itemBuilder: (context, i) { + final c = conversations[i]; + final isCurrent = c.id == currentId; + return Dismissible( + key: ValueKey(c.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: colorScheme.error, + child: Icon(Icons.delete, color: colorScheme.onError), + ), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除该会话?'), + content: Text(c.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + }, + onDismissed: (_) async { + await aiService.deleteConversation(c.id); + if (context.mounted) { + showAdaptiveFeedback( + message: '已删除', + style: AdaptiveFeedbackStyle.success, + ); + } + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: isCurrent + ? colorScheme.primary + : colorScheme.surfaceContainerHigh, + foregroundColor: isCurrent + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + child: const Icon(Icons.chat_bubble_outline), + ), + title: Text( + c.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: isCurrent ? FontWeight.bold : null, + ), + ), + subtitle: Text( + _subtitle(c), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: PopupMenuButton( + itemBuilder: (context) => const [ + PopupMenuItem(value: 'rename', child: Text('重命名')), + PopupMenuItem(value: 'delete', child: Text('删除')), + ], + onSelected: (value) { + switch (value) { + case 'rename': + unawaited(_rename(context, c)); + case 'delete': + unawaited(_deleteWithConfirm(context, c)); + } + }, + ), + onTap: () => Navigator.of(context).pop(c.id), + ), + ); + }, + ); + }, + ), + ); + } + + String _subtitle(AiThread c) { + final count = c.messages.length; + final date = '${c.updatedAt.month}/${c.updatedAt.day}'; + return '$date · $count 条消息'; + } + + Widget _empty( + BuildContext context, + ThemeData theme, + ColorScheme colorScheme, + ) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text('暂无历史会话', style: theme.textTheme.bodyLarge), + ], + ), + ); + } + + Future _rename(BuildContext context, AiThread c) async { + final controller = TextEditingController(text: c.title); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('重命名会话'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('确定'), + ), + ], + ), + ); + controller.dispose(); + if (result != null && result.isNotEmpty) { + await aiService.renameConversation(c.id, result); + } + } + + Future _deleteWithConfirm(BuildContext context, AiThread c) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除该会话?'), + content: Text(c.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + if (ok == true) { + await aiService.deleteConversation(c.id); + if (context.mounted) { + showAdaptiveFeedback( + message: '已删除', + style: AdaptiveFeedbackStyle.success, + ); + } + } + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9d2408d..b8fe11a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -309,7 +309,9 @@ class _HomePageState extends State with TickerProviderStateMixin { ), ), const SizedBox(height: 16), - _buildAppCard(featureEntries), + _buildAppCard( + featureEntries.where((f) => !f.debugOnly || isDebug).toList(), + ), const SizedBox(height: 16), _buildTodayClasses(theme, auth.isLoggedIn), const SizedBox(height: 8), diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart new file mode 100644 index 0000000..453702d --- /dev/null +++ b/lib/services/ai_service.dart @@ -0,0 +1,451 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; + +import '../models/ai_chat.dart'; +import 'ai_tools.dart'; +import 'assignment_service.dart'; +import 'schedule_service.dart'; +import 'storage_service.dart'; +import 'third_party_auth_service.dart'; + +/// Central state for the AI Assistant feature. +/// +/// Wraps the library's [UseChatController] (the streaming state machine) and an +/// [AnthropicProvider], exposing TechPie's own chat API so the rest of the app +/// — UI, config page, history, gallery — binds to a single `ChangeNotifier` +/// exactly as before. The controller's notifications are forwarded to this +/// service's listeners, so `ListenableBuilder(listenable: aiService, ...)` +/// keeps working. +/// +/// Responsibilities split as: the controller owns the live transcript + stream +/// state (in library [AiConversation] form); this service owns the [AiConfig] +/// (persisted), the list of persisted [AiThread]s (TechPie's metadata + +/// messages), the current-thread pointer, and serializing the controller's +/// transcript back to [StorageService] when a turn settles. +class AiService extends ChangeNotifier { + final StorageService _storage; + final ScheduleService _schedule; + final AssignmentService _assignments; + final ThirdPartyAuthService _tpAuth; + + /// The campus-service tools the model can call. Built once at construction. + late final ToolRegistry _tools = buildAiTools( + scheduleService: _schedule, + assignmentService: _assignments, + thirdPartyAuthService: _tpAuth, + ); + + AiConfig _config = AiConfig.defaults(); + List _conversations = const []; + String? _currentConversationId; + + /// The library controller. Lazily created once config is available so an + /// unconfigured app doesn't spin up a provider with an empty key. + UseChatController? _controller; + AnthropicProvider? _provider; + + /// True while the controller's forward-listener is active, to avoid + /// re-entrant notifyListeners churn. + bool _forwarding = false; + + /// A service-level error surfaced ahead of any network call (e.g. the app + /// isn't configured yet). Cleared when a real turn starts or config is saved. + String? _userError; + + /// Last-seen controller status / error, to detect transitions worth notifying + /// the UI about (streaming start/stop, error appear/clear) without firing on + /// every token. + ChatStatus? _lastStatus; + Object? _lastError; + + /// Debounced persistence timer. + Timer? _persistTimer; + + AiService( + this._storage, + this._schedule, + this._assignments, + this._tpAuth, + ); + + // ---- Accessors ---- + + AiConfig get config => _config; + + bool get isConfigured => _config.hasAuthToken; + + bool get isStreaming { + final c = _controller; + // isBusy covers submitted/streaming/executingTools — the tool-execution + // phase must count as busy too, or the UI re-enables input mid agent-loop + // and a new send() lands on a transcript with unanswered tool calls. + return c != null && c.status.isBusy; + } + + /// The error from the last failed turn, surfaced as a string for the UI + /// banner. Returns null when idle/healthy. Prefers a service-level user + /// error (e.g. "not configured") over the controller's last network error. + String? get streamingError { + if (_userError != null) return _userError; + final c = _controller; + if (c == null) return null; + final err = c.error; + if (err == null) return null; + if (err is LlmException) { + // LlmException carries a status code + body, not a plain message. Map + // auth failures to a friendlier hint; otherwise surface the body. + if (err is LlmAuthException) { + return '认证失败(${err.statusCode}):请检查 API 令牌与端点。'; + } + final body = err.body; + final detail = + body.isEmpty ? err.toString() : (body.length > 200 ? '${body.substring(0, 200)}…' : body); + return '请求失败(${err.statusCode}):$detail'; + } + return '发生错误:$err'; + } + + List get conversations { + final list = [..._conversations]; + list.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } + + AiThread? get currentConversation { + final id = _currentConversationId; + if (id == null) return null; + for (final c in _conversations) { + if (c.id == id) return c; + } + return null; + } + + /// The library chat controller the UI binds to (AiChat / AiPromptInput). + /// + /// Lazily built on first access. The controller owns the live transcript + + /// streaming state; this service mirrors its transcript back into the + /// persisted [AiThread] list (see [_onControllerChanged]) and owns config / + /// conversation metadata. Thread switches happen via [selectConversation] / + /// [newConversation] (which call `controller.load`), NOT here — re-loading + /// on every build would tear down an in-flight stream. + UseChatController get controller => _ensureController(forCurrent: false); + + // ---- Lifecycle ---- + + Future initialize() async { + _config = await _storage.loadAiConfig(); + _conversations = _storage.loadAiConversations(); + if (_conversations.isEmpty) { + // Start the user with one fresh conversation so the chat is never empty. + final fresh = _newConversation(); + _conversations = [fresh]; + _currentConversationId = fresh.id; + } else { + _currentConversationId = conversations.first.id; + } + _ensureController(forCurrent: true); + notifyListeners(); + } + + // ---- Config ---- + + Future saveConfig(AiConfig config) async { + _config = config; + // Token goes to secure storage; the rest to prefs (token stripped inside). + await _storage.saveAiAuthToken(config.authToken); + await _storage.saveAiConfig(config); + // Re-point the controller's provider/options to the new config. + _ensureController(forCurrent: true); + _controller?.setProvider(_buildProvider()); + _controller?.setOptions(_buildOptions()); + // A fresh config clears any "not configured" prompt. + _userError = null; + notifyListeners(); + } + + // ---- Conversations ---- + + /// Create and switch to a new conversation, optionally seeded with a prompt + /// (used by the gallery). + AiThread newConversation({String? seedPrompt}) { + final conv = _newConversation(); + _conversations = [..._conversations, conv]; + _currentConversationId = conv.id; + _ensureController(forCurrent: true); + _schedulePersist(); + notifyListeners(); + return conv; + } + + void selectConversation(String id) { + if (_currentConversationId == id) return; + final conv = _conversationById(id); + if (conv == null) return; + _controller?.stop(); + _currentConversationId = id; + _ensureController(forCurrent: true); + notifyListeners(); + } + + Future deleteConversation(String id) async { + if (_currentConversationId == id) { + _controller?.stop(); + } + _conversations = _conversations.where((c) => c.id != id).toList(); + if (_currentConversationId == id) { + _currentConversationId = + _conversations.isEmpty ? null : conversations.first.id; + if (_conversations.isEmpty) { + final fresh = _newConversation(); + _conversations = [fresh]; + _currentConversationId = fresh.id; + } + _ensureController(forCurrent: true); + } + _schedulePersist(); + notifyListeners(); + } + + Future clearAllConversations() async { + _controller?.stop(); + await _storage.clearAiConversations(); + _conversations = [_newConversation()]; + _currentConversationId = _conversations.first.id; + _ensureController(forCurrent: true); + notifyListeners(); + } + + Future renameConversation(String id, String title) async { + _conversations = [ + for (final c in _conversations) + if (c.id == id) c.copyWith(title: title) else c, + ]; + _schedulePersist(); + notifyListeners(); + } + + // ---- Sending / streaming ---- + + /// Send [text] as a user turn and stream the assistant reply. If [text] is + /// empty, re-streams the last user turn (regenerate). Errors are captured + /// into [streamingError] (via the controller's `error`), not thrown. + Future send(String text, {String? conversationId}) async { + final c = _ensureController(forCurrent: true); + if (!_config.hasAuthToken) { + _userError = '请先在设置中填写 API 令牌'; + notifyListeners(); + return; + } + if (c.status.isBusy) return; + // Starting a real turn — clear any prior service-level error. + if (_userError != null) { + _userError = null; + notifyListeners(); + } + + // Auto-title from the first user message if untitled. + final convId = conversationId ?? _currentConversationId; + if (convId != null && _conversationById(convId)?.title == '新对话') { + final firstUserText = text.trim().isNotEmpty ? text : _lastUserText(c); + if (firstUserText != null) { + unawaited(renameConversation(convId, _deriveTitle(firstUserText))); + } + } + + if (text.trim().isNotEmpty) { + await c.sendText(text); + } else { + // Regenerate: re-run from the last user message. + await c.regenerate(); + } + } + + /// Stop an in-flight stream. The partial assistant text is kept. + void stop() => _controller?.stop(); + + // ---- Controller plumbing ---- + + /// Lazily build (or re-seat) the controller for the current conversation. + /// When [forCurrent] is true and the controller already exists, the current + /// conversation is loaded into it (thread switch). Returns the controller. + UseChatController _ensureController({required bool forCurrent}) { + if (_controller == null) { + _controller = UseChatController( + provider: _buildProvider(), + options: _buildOptions(), + // Campus-service tools (schedule / assignments / time). Providing + // onToolCalls turns the controller into an automatic agent loop: + // after a stream, pending ToolCallParts are executed, results are + // appended as an AiRole.tool message, and the model is re-prompted — + // repeating until no tool calls remain (bounded by maxSteps=8). + tools: _tools.definitions, + onToolCalls: (calls, signal) async { + // Run each call through the registry; it catches per-tool failures + // as isError results so one bad tool doesn't kill the batch. + final results = []; + for (final call in calls) { + if (signal.isCancelled) break; + results.add(await _tools.run(call)); + } + return results; + }, + ); + _controller!.addListener(_onControllerChanged); + } + if (forCurrent) { + final conv = currentConversation; + if (conv != null) { + _controller!.load(_withSystemMessage(conv.conversation)); + } + } + return _controller!; + } + + void _onControllerChanged() { + if (_forwarding) return; + _forwarding = true; + try { + // Mirror the controller's live transcript into the persisted thread so + // currentConversation reflects streamed tokens. Done silently — token + // arrivals must NOT call notifyListeners, or the page rebuilds every + // token and AiChat's scroll/anchor logic fights the rebuild (visible as + // flicker + no smooth auto-scroll). AiChat listens to the controller + // directly for live transcript updates. + _syncCurrentFromController(); + final status = _controller?.status; + final error = _controller?.error; + // Only surface a notification when something the UI actually cares about + // changes: streaming started/stopped, or an error appeared/cleared. + // Token-only changes (status stays streaming, same error) are silent. + final statusChanged = status != _lastStatus; + final errorChanged = error != _lastError; + _lastStatus = status; + _lastError = error; + if (statusChanged || errorChanged) { + notifyListeners(); + } + // Persist once the turn settles (status leaves streaming). + if (status != ChatStatus.streaming) { + _schedulePersist(); + } + } finally { + _forwarding = false; + } + } + + /// Copy the controller's transcript into the persisted thread record. + void _syncCurrentFromController() { + final id = _currentConversationId; + final c = _controller; + if (id == null || c == null) return; + // Strip the leading system message before persisting — it's re-injected + // from config on load, so we don't want it duplicated across saves. + final messages = + c.conversation.messages.where((m) => m.role != AiRole.system).toList(); + _conversations = [ + for (final conv in _conversations) + if (conv.id == id) + conv.copyWith(messages: messages, updatedAt: DateTime.now()) + else + conv, + ]; + } + + /// The conversation with the configured system prompt prepended (the + /// AnthropicProvider folds `AiRole.system` messages into the request's + /// top-level `system` field). + AiConversation _withSystemMessage(AiConversation conv) { + final prompt = _config.systemPrompt; + if (prompt.trim().isEmpty) return conv; + if (conv.messages.any((m) => m.role == AiRole.system)) return conv; + final systemMsg = AiMessage( + id: 'system-${conv.id}', + role: AiRole.system, + parts: [TextPart(prompt)], + ); + return conv.copyWith(messages: [systemMsg, ...conv.messages]); + } + + AnthropicProvider _buildProvider() { + // Reuse an existing provider when auth + endpoint are unchanged; otherwise + // build fresh and close the old one. (baseUrl is private on the provider, + // so we track the resolved URL ourselves for the reuse check.) + final previous = _provider; + final resolved = normalizeAiBaseUrl(_config.baseUrl); + if (previous != null && + previous.apiKey == _config.authToken && + _resolvedBaseUrl == resolved) { + return previous; + } + previous?.close(); + final provider = AnthropicProvider( + apiKey: _config.authToken.isEmpty ? 'unset' : _config.authToken, + baseUrl: Uri.parse(resolved), + defaultModel: _config.model, + defaultMaxTokens: _config.maxTokens, + timeout: const Duration(seconds: 90), + ); + _provider = provider; + _resolvedBaseUrl = resolved; + return provider; + } + + String? _resolvedBaseUrl; + + AiRequestOptions? _buildOptions() { + return AiRequestOptions( + model: _config.model, + temperature: _config.temperature, + maxOutputTokens: _config.maxTokens, + ); + } + + String? _lastUserText(UseChatController c) { + for (final m in c.conversation.messages.reversed) { + if (m.role == AiRole.user) return m.text; + } + return null; + } + + void _schedulePersist() { + _persistTimer?.cancel(); + _persistTimer = Timer(const Duration(seconds: 1), () { + unawaited(_storage.saveAiConversations(_conversations)); + }); + } + + AiThread _newConversation() => AiThread( + id: _id(), + title: '新对话', + messages: const [], + updatedAt: DateTime.now(), + ); + + AiThread? _conversationById(String id) { + for (final c in _conversations) { + if (c.id == id) return c; + } + return null; + } + + String _deriveTitle(String text) { + final t = text.trim().replaceAll(RegExp(r'\s+'), ' '); + return t.isEmpty ? '新对话' : (t.length > 20 ? '${t.substring(0, 20)}…' : t); + } + + String _id() => 'm${DateTime.now().microsecondsSinceEpoch}-${_counter++}'; + int _counter = 0; + + @override + void dispose() { + _persistTimer?.cancel(); + _controller?.removeListener(_onControllerChanged); + _controller?.dispose(); + _provider?.close(); + super.dispose(); + } +} diff --git a/lib/services/ai_tools.dart b/lib/services/ai_tools.dart new file mode 100644 index 0000000..6710a4a --- /dev/null +++ b/lib/services/ai_tools.dart @@ -0,0 +1,299 @@ +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; + +import '../models/assignment.dart'; +import '../models/course_table.dart'; +import 'assignment_service.dart'; +import 'schedule_service.dart'; +import 'third_party_auth_service.dart'; + +/// Builds the [ToolRegistry] of campus-service tools the AI assistant can call. +/// +/// All four tools are read-only and safe to auto-execute (no confirmation +/// gate). Each executor returns a JSON-encodable Map (best for the model to +/// reason over) and never throws — failures become a `{'error': ...}` map so +/// the model can surface them to the user. eGate-gated tools return a friendly +/// "not bound" error when the user hasn't bound their campus account. +ToolRegistry buildAiTools({ + required ScheduleService scheduleService, + required AssignmentService assignmentService, + required ThirdPartyAuthService thirdPartyAuthService, +}) { + return ToolRegistry([ + ToolSpec( + name: 'get_current_time', + description: + 'Get the current date, time, day of week, and the current academic ' + 'week number (1-based, derived from the semester\'s term-begin date). ' + 'Call this whenever the user asks what time/day/week it is, or when ' + 'a relative date is needed.', + parametersSchema: const { + 'type': 'object', + 'properties': {}, + }, + execute: (args) async { + final now = DateTime.now(); + const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return { + 'now': now.toIso8601String(), + 'weekday': now.weekday, + 'weekdayName': weekdayNames[now.weekday], + 'currentWeek': scheduleService.currentWeek(), + }; + }, + ), + + ToolSpec( + name: 'get_semesters', + description: + 'List the student\'s available semesters (e.g. "2024-2025 春学期") ' + 'with their IDs, and which semester is currently selected. Requires ' + 'the eGate campus binding. Call when the user asks about available ' + 'terms or which semester is active. Served from the local cache; ' + 'pass refresh=true only if the user explicitly wants fresh data.', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch, bypassing the local cache. Default false.', + }, + }, + }, + execute: (args) async { + if (!thirdPartyAuthService.hasCpdailyBinding) { + return {'error': '未绑定校园账号(eGate),请在设置中绑定后再试。'}; + } + // Cache-first: in-memory/storage semester list, live only on miss or + // explicit refresh. Never writes ScheduleService's in-memory state. + final SemesterInfo info; + try { + info = await scheduleService.semestersCachedOrLive( + refresh: args['refresh'] == true, + ); + } catch (e) { + return {'error': '获取学期列表失败:$e'}; + } + return { + 'currentSemesterId': scheduleService.selectedSemesterId, + 'currentLabel': + scheduleService.selectedSemesterId == null + ? null + : info.findSemesterLabel(scheduleService.selectedSemesterId!), + 'semesters': [ + for (final e in info.allSemesters) + {'id': e.key, 'label': e.value}, + ], + }; + }, + ), + + ToolSpec( + name: 'get_week_schedule', + description: + 'Get the course schedule for a specific semester and week. Returns ' + 'courses grouped by weekday (周一~周日), each with name, location, ' + 'period range, time range, teachers, and active weeks. Requires the ' + 'eGate campus binding. If semesterId or week are omitted, defaults to ' + 'the current semester and current week. Call when the user asks about ' + 'their timetable / what classes they have. Served from the local ' + 'per-semester cache; pass refresh=true only if the user explicitly ' + 'wants fresh data. When querying a non-current semester, pass an ' + 'explicit week.', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'semesterId': { + 'type': 'string', + 'description': + 'Semester ID (from get_semesters). Omit for the current ' + 'semester.', + }, + 'week': { + 'type': 'integer', + 'description': 'Week number (1-based). Omit for the current week.', + 'minimum': 1, + 'maximum': 25, + }, + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch, bypassing the local cache. Default false.', + }, + }, + }, + execute: (args) async { + if (!thirdPartyAuthService.hasCpdailyBinding) { + return {'error': '未绑定校园账号(eGate),请在设置中绑定后再试。'}; + } + final semesterId = + (args['semesterId'] as String?)?.isNotEmpty == true + ? args['semesterId'] as String + : scheduleService.selectedSemesterId; + if (semesterId == null) { + return {'error': '无法确定当前学期,请先调用 get_semesters。'}; + } + // Cache-first per-semester table: storage is keyed by semesterId, so + // a non-selected (e.g. past) semester hits its own cache entry — the + // old bug came from reading the in-memory table, which only holds the + // selected semester. On miss/refresh this fetches live and writes back + // to storage only, never ScheduleService's shared _courseTable, so + // querying a non-selected semester doesn't pollute the schedule UI. + final CourseTable table; + try { + table = await scheduleService.courseTableFor( + semesterId, + refresh: args['refresh'] == true, + ); + } catch (e) { + return {'error': '获取课程表失败:$e'}; + } + int week; + String? weekNote; + if (args['week'] is int) { + week = args['week'] as int; + } else if (semesterId == scheduleService.selectedSemesterId) { + week = scheduleService.currentWeek(); + } else { + // Non-selected semester with no explicit week: currentWeek() is + // derived from the SELECTED semester's term begin, so it would be + // wrong here. Derive from this semester's cached term begin when + // today actually falls inside it; otherwise default to week 1. + final termBegin = scheduleService.termBeginFor(semesterId); + final diff = + termBegin == null + ? -1 + : DateTime.now().difference(termBegin).inDays; + if (diff >= 0 && diff < 25 * 7) { + week = (diff ~/ 7) + 1; + } else { + week = 1; + weekNote = '未指定周数,已默认第 1 周;如需其他周请传 week 参数。'; + } + } + final display = eamsToDisplayCourses(table.courses, week); + // Period index → time range, from the table's period definitions. + final periodTimes = {}; + for (final p in table.periods) { + periodTimes[p.index] = p.timeRange; + } + const dayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']; + // Group by weekday. + final byDay = >>{}; + for (final c in display) { + byDay.putIfAbsent(c.dayOfWeek, () => []).add({ + 'name': c.name, + 'location': c.location, + 'periods': '${c.startPeriod}-${c.endPeriod}', + 'time': + '${periodTimes[c.startPeriod] ?? ''}~${periodTimes[c.endPeriod] ?? ''}', + if (c.teachers != null && c.teachers!.isNotEmpty) + 'teachers': c.teachers, + if (c.weeksText != null && c.weeksText!.isNotEmpty) + 'weeks': c.weeksText, + if (c.isGhost) 'note': '本周无此课(仅供参考)', + }); + } + final days = >[]; + for (var d = 1; d <= 7; d++) { + final courses = byDay[d]; + if (courses == null || courses.isEmpty) continue; + days.add({'day': dayNames[d], 'courses': courses}); + } + final info = scheduleService.semesterInfo; + return { + 'semesterId': semesterId, + 'semesterLabel': info?.findSemesterLabel(semesterId), + 'week': week, + if (weekNote != null) 'weekNote': weekNote, + 'days': days, + if (days.isEmpty) 'note': '本周没有课程', + }; + }, + ), + + ToolSpec( + name: 'get_assignments', + description: + 'Get the student\'s upcoming assignments and exams (deadlines) across ' + 'all platforms (Blackboard, exams, Gradescope, Hydro), sorted by due ' + 'date. Each item has title, course, due date, platform, kind ' + '(作业/考试), and status. Optionally filter by platform or kind. Call ' + 'when the user asks about homework, deadlines, or exams. Served from ' + 'the local cache (refreshed in the background at app start); pass ' + 'refresh=true only if the user explicitly wants fresh data.', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch from all platforms, bypassing the local ' + 'cache. Default false.', + }, + 'platform': { + 'type': 'string', + 'description': + 'Filter to one platform: blackboard, exam, gradescope, or hydro. ' + 'Omit for all.', + 'enum': ['blackboard', 'exam', 'gradescope', 'hydro'], + }, + 'kind': { + 'type': 'string', + 'description': 'Filter to assignments or exams. Omit for both.', + 'enum': ['assignment', 'exam'], + }, + }, + }, + execute: (args) async { + // Cache-first: the in-memory assignments are hydrated from storage at + // boot and refreshed by the background fan-out, so they're fresh + // within a session. Only go live when the cache is empty or the model + // explicitly asks for a refresh. fetchAssignmentsLive is non-mutating: + // it never writes AssignmentService's shared _assignments / + // _platformErrors or notifies, so a live query here doesn't disturb + // the assignments UI or trigger the schedule→assignment refetch chain. + List source; + Map errors; + if (args['refresh'] != true && + assignmentService.assignments.isNotEmpty) { + source = assignmentService.assignments; + errors = assignmentService.platformErrors; + } else { + final result = await assignmentService.fetchAssignmentsLive(); + source = result.assignments; + errors = result.errors; + } + // Honor locally-hidden items (matches the UI's visibleAssignments + // behavior) without mutating the overrides store. + var items = + source.where((a) => !assignmentService.isHidden(a)).toList(); + final platform = args['platform'] as String?; + final kind = args['kind'] as String?; + if (platform != null) { + items = items.where((a) => a.platform == platform).toList(); + } + if (kind != null) { + items = items.where((a) => a.kind.id == kind).toList(); + } + return { + 'assignments': [ + for (final a in items) + { + 'title': a.title, + 'course': a.course, + 'due': a.due.toIso8601String(), + if (a.lateDue != null) 'lateDue': a.lateDue!.toIso8601String(), + 'platform': a.platform, + 'kind': a.kind.label, + if (a.status != null) 'status': a.status, + 'submitted': a.submitted, + if (a.url != null) 'url': a.url, + }, + ], + if (errors.isNotEmpty) 'platformErrors': errors, + }; + }, + ), + ]); +} diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 5808150..5f21f04 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -205,18 +205,46 @@ class AssignmentService extends ChangeNotifier { _platformErrors.clear(); notifyListeners(); + final successfulResults = await _fanOutFetches(_platformErrors); + + try { + final merged = _mergeAssignments( + successfulResults: successfulResults, + ); + + _assignments = merged; + await _storage.saveCachedAssignments( + merged.map((a) => a.toJson()).toList(), + ); + } catch (e) { + _error = '同步失败,请检查网络或稍后重试'; + } finally { + _loading = false; + notifyListeners(); + } + } + + /// Fan out all platform deadline fetches concurrently, writing per-platform + /// failures into [errors] and returning the successful platforms' results. + /// Shared by the state-mutating [fetchAssignments] (which uses + /// [_platformErrors]) and the non-mutating [fetchAssignmentsLive] (which + /// uses a local map). Never throws — a single platform failure is recorded + /// in [errors] and simply omitted from the results. + Future>> _fanOutFetches( + Map errors, + ) async { final successfulResults = >{}; final futures = >[]; if (_tpAuth.hasCpdailyBinding) { futures.add( - _fetchBlackboard().then((items) { + _fetchBlackboard(errors).then((items) { if (items != null) successfulResults['blackboard'] = items; }), ); futures.add( - _fetchExamTable().then((items) { + _fetchExamTable(errors).then((items) { if (items != null) successfulResults['exam'] = items; }), ); @@ -226,14 +254,14 @@ class AssignmentService extends ChangeNotifier { switch (acc.platform) { case ThirdPartyPlatform.gradescope: futures.add( - _fetchGradescope(acc).then((items) { + _fetchGradescope(acc, errors).then((items) { if (items != null) successfulResults[acc.platform.id] = items; }), ); break; case ThirdPartyPlatform.hydro: futures.add( - _fetchHydro(acc).then((items) { + _fetchHydro(acc, errors).then((items) { if (items != null) successfulResults[acc.platform.id] = items; }), ); @@ -244,22 +272,26 @@ class AssignmentService extends ChangeNotifier { } } - try { - await Future.wait(futures); - final merged = _mergeAssignments( - successfulResults: successfulResults, - ); + await Future.wait(futures); + return successfulResults; + } - _assignments = merged; - await _storage.saveCachedAssignments( - merged.map((a) => a.toJson()).toList(), - ); - } catch (e) { - _error = '同步失败,请检查网络或稍后重试'; - } finally { - _loading = false; - notifyListeners(); - } + /// Live, non-mutating fetch of all deadlines. Mirrors the fetch fan-out of + /// [fetchAssignments] but returns the merged list + per-platform errors + /// without touching [_assignments], [_platformErrors], the cache, or the + /// listeners. Used by the AI tools so a query for deadlines doesn't disturb + /// the UI's assignment state. Only successfully-fetched platforms are + /// included — there is no blending with the cached [_assignments] (so a + /// transient platform failure simply omits that platform from the result). + Future<({List assignments, Map errors})> + fetchAssignmentsLive() async { + final errors = {}; + final successfulResults = await _fanOutFetches(errors); + final assignments = successfulResults.values + .expand((items) => items) + .toList() + ..sort((a, b) => a.due.compareTo(b.due)); + return (assignments: assignments, errors: errors); } List _mergeAssignments({ @@ -287,22 +319,22 @@ class AssignmentService extends ChangeNotifier { Future? future; if (platformId == 'blackboard' && _tpAuth.hasCpdailyBinding) { - future = _fetchBlackboard().then((items) { + future = _fetchBlackboard(_platformErrors).then((items) { if (items != null) successfulResults['blackboard'] = items; }); } else if (platformId == 'exam' && _tpAuth.hasCpdailyBinding) { - future = _fetchExamTable().then((items) { + future = _fetchExamTable(_platformErrors).then((items) { if (items != null) successfulResults['exam'] = items; }); } else { for (final acc in _tpAuth.accounts) { if (acc.platform.id == platformId) { if (acc.platform == ThirdPartyPlatform.gradescope) { - future = _fetchGradescope(acc).then((items) { + future = _fetchGradescope(acc, _platformErrors).then((items) { if (items != null) successfulResults[platformId] = items; }); } else if (acc.platform == ThirdPartyPlatform.hydro) { - future = _fetchHydro(acc).then((items) { + future = _fetchHydro(acc, _platformErrors).then((items) { if (items != null) successfulResults[platformId] = items; }); } @@ -331,7 +363,9 @@ class AssignmentService extends ChangeNotifier { notifyListeners(); } - Future?> _fetchBlackboard() async { + Future?> _fetchBlackboard( + Map errors, + ) async { final node = _tpAuth.elearningNode; // withCookie handles initial minting if the downstream cookie isn't set. if (!_tpAuth.hasCpdailyBinding) return null; @@ -353,14 +387,16 @@ class AssignmentService extends ChangeNotifier { }, ); if (resp == null) return null; - return _parseDeadlinesResponse(resp, 'blackboard'); + return _parseDeadlinesResponse(resp, 'blackboard', errors); } catch (e) { - _platformErrors['blackboard'] = '同步失败,请检查网络或稍后重试'; + errors['blackboard'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchExamTable() async { + Future?> _fetchExamTable( + Map errors, + ) async { final semesterId = _selectedSemesterId(); final node = _tpAuth.eamsNode; if (!_tpAuth.hasCpdailyBinding || @@ -386,14 +422,17 @@ class AssignmentService extends ChangeNotifier { }, ); if (resp == null) return null; - return _parseExamTableResponse(resp); + return _parseExamTableResponse(resp, errors); } catch (e) { - _platformErrors['exam'] = '同步失败,请检查网络或稍后重试'; + errors['exam'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchGradescope(ThirdPartyAccount acc) async { + Future?> _fetchGradescope( + ThirdPartyAccount acc, + Map errors, + ) async { final node = _tpAuth.gradescopeNode; if (!node.isAvailable) return null; try { @@ -413,21 +452,24 @@ class AssignmentService extends ChangeNotifier { if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.gradescope); - _platformErrors['gradescope'] = 'token 已失效,请重新绑定'; + errors['gradescope'] = 'token 已失效,请重新绑定'; return null; } - return _parseDeadlinesResponse(resp, 'gradescope'); + return _parseDeadlinesResponse(resp, 'gradescope', errors); } catch (e) { - _platformErrors['gradescope'] = '同步失败,请检查网络或稍后重试'; + errors['gradescope'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchHydro(ThirdPartyAccount acc) async { + Future?> _fetchHydro( + ThirdPartyAccount acc, + Map errors, + ) async { final origin = acc.hydroOrigin ?? 'https://acm.shanghaitech.edu.cn'; final domains = acc.hydroDomains ?? const []; if (domains.isEmpty) { - _platformErrors['hydro'] = '未配置 Hydro 课程域 (domain),前往设置补全'; + errors['hydro'] = '未配置 Hydro 课程域 (domain),前往设置补全'; return null; } @@ -458,17 +500,17 @@ class AssignmentService extends ChangeNotifier { if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.hydro); - _platformErrors['hydro'] = 'token 已失效,请重新绑定'; + errors['hydro'] = 'token 已失效,请重新绑定'; return null; } - final items = _parseDeadlinesResponse(resp, 'hydro'); + final items = _parseDeadlinesResponse(resp, 'hydro', errors); if (items != null) { all.addAll(items); } else { hadError = true; } } catch (e) { - _platformErrors['hydro'] = '同步失败,请检查网络或稍后重试'; + errors['hydro'] = '同步失败,请检查网络或稍后重试'; hadError = true; } } @@ -479,17 +521,18 @@ class AssignmentService extends ChangeNotifier { List? _parseDeadlinesResponse( http.Response resp, String platformKey, + Map errors, ) { Map data; try { data = jsonDecode(resp.body) as Map; } catch (_) { - _platformErrors[platformKey] = '同步失败,服务器返回异常数据'; + errors[platformKey] = '同步失败,服务器返回异常数据'; return null; } if (resp.statusCode != 200 || data['success'] != true) { - _platformErrors[platformKey] = + errors[platformKey] = (data['error'] as String?) ?? '同步失败 (HTTP ${resp.statusCode})'; return null; } @@ -500,17 +543,20 @@ class AssignmentService extends ChangeNotifier { .toList(); } - List? _parseExamTableResponse(http.Response resp) { + List? _parseExamTableResponse( + http.Response resp, + Map errors, + ) { Map data; try { data = jsonDecode(resp.body) as Map; } catch (_) { - _platformErrors['exam'] = '同步失败,服务器返回异常数据'; + errors['exam'] = '同步失败,服务器返回异常数据'; return null; } if (resp.statusCode != 200 || data['success'] != true) { - _platformErrors['exam'] = + errors['exam'] = (data['error'] as String?) ?? '同步失败 (HTTP ${resp.statusCode})'; return null; } diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index 1fc813a..01bd0fb 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -187,6 +187,86 @@ class ScheduleService extends ChangeNotifier { notifyListeners(); } + /// Live, non-mutating fetch of the semester list. Mirrors [fetchSemesters] + /// but returns the parsed [SemesterInfo] instead of writing it into + /// [_semesterInfo] and never notifies. Used by the AI tools so a query about + /// available terms doesn't touch the UI's selected-semester state. + Future fetchSemestersLive() async { + final resp = await _postWithRetry( + '$_baseUrl/schedule/semesters', + const {}, + 'fetchSemestersLive', + ); + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) { + throw Exception(data['error'] as String? ?? 'Failed to fetch semesters'); + } + return SemesterInfo.fromJson(data['data'] as Map); + } + + /// Live, non-mutating fetch of one semester's course table. Mirrors + /// [fetchCourseTable] but returns the parsed [CourseTable] instead of + /// writing it into [_courseTable] and never notifies. The AI tools use this + /// (via [courseTableFor]) to read an arbitrary semester (e.g. a + /// non-selected one) without polluting the UI's selected-semester view. The + /// table_id is best-effort: sent only when [_semesterInfo] is available. + Future fetchCourseTableLive(String semesterId) async { + final tableId = _semesterInfo?.tableId; + final extra = { + 'semester_id': semesterId, + if (tableId != null && tableId.isNotEmpty) 'table_id': tableId, + }; + final resp = await _postWithRetry( + '$_baseUrl/schedule/course_table', + extra, + 'fetchCourseTableLive', + ); + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) { + throw Exception( + data['error'] as String? ?? 'Failed to fetch course table', + ); + } + return CourseTable.fromApiResponse(data['data'] as Map); + } + + /// Cache-first semester list for read-only consumers (AI tools): in-memory + /// [_semesterInfo] → storage → live fetch (written back to storage only, + /// never into [_semesterInfo], so UI state is untouched). + Future semestersCachedOrLive({bool refresh = false}) async { + if (!refresh) { + final cached = _semesterInfo ?? _storage.loadSemesters(); + if (cached != null) return cached; + } + final info = await fetchSemestersLive(); + await _storage.saveSemesters(info); + return info; + } + + /// Cache-first course table for an arbitrary semester. Storage is keyed per + /// semester, so a non-selected (e.g. past) semester hits its own cache + /// entry — the old AI-tool bug came from reading the in-memory + /// [_courseTable], which only ever holds the selected semester. On miss (or + /// [refresh]) fetches live and writes back to storage only, never into + /// [_courseTable], so querying another semester can't pollute the UI. + Future courseTableFor( + String semesterId, { + bool refresh = false, + }) async { + if (!refresh) { + final cached = _storage.loadCourseTable(semesterId); + if (cached != null) return cached; + } + final table = await fetchCourseTableLive(semesterId); + await _storage.saveCourseTable(semesterId, table); + return table; + } + + /// Cached term-begin date for [semesterId], if any (storage is per-semester + /// keyed). Used to derive a default week for non-selected semesters. + DateTime? termBeginFor(String semesterId) => + _storage.loadTermBegin(semesterId); + Future selectSemester(String semesterId) async { // No-op if the semester is already selected. if (_selectedSemesterId == semesterId) return; diff --git a/lib/services/service_provider.dart b/lib/services/service_provider.dart index c802068..4631022 100644 --- a/lib/services/service_provider.dart +++ b/lib/services/service_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'ai_service.dart'; import 'assignment_service.dart'; import 'auth_service.dart'; import 'debug_logger.dart'; @@ -22,6 +23,7 @@ class ServiceProvider extends InheritedWidget { final OaGymService oaGymService; final UniAuthService uniAuthService; final SyncService syncService; + final AiService aiService; const ServiceProvider({ super.key, @@ -35,12 +37,12 @@ class ServiceProvider extends InheritedWidget { required this.oaGymService, required this.uniAuthService, required this.syncService, + required this.aiService, required super.child, }); static ServiceProvider of(BuildContext context) { - final result = - context.dependOnInheritedWidgetOfExactType(); + final result = context.dependOnInheritedWidgetOfExactType(); assert(result != null, 'No ServiceProvider found in context'); return result!; } @@ -56,5 +58,6 @@ class ServiceProvider extends InheritedWidget { thirdPartyAuthService != oldWidget.thirdPartyAuthService || oaGymService != oldWidget.oaGymService || uniAuthService != oldWidget.uniAuthService || - syncService != oldWidget.syncService; + syncService != oldWidget.syncService || + aiService != oldWidget.aiService; } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 7a8d898..786e94e 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -10,6 +10,7 @@ import 'dart:math'; import 'package:flutter_secure_storage_ohos/flutter_secure_storage_ohos.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/ai_chat.dart'; import '../models/assignment_overrides.dart'; import '../models/course_table.dart'; import '../models/oa_gym.dart'; @@ -144,34 +145,27 @@ class StorageService { Future setDebugMode(bool value) => _prefs.setBool(_debugModeKey, value); String get cachedSchoolName => _prefs.getString(_schoolNameKey) ?? ''; - Future setCachedSchoolName(String name) => - _prefs.setString(_schoolNameKey, name); + Future setCachedSchoolName(String name) => _prefs.setString(_schoolNameKey, name); String get cachedPhone => _prefs.getString(_phoneKey) ?? ''; - Future setCachedPhone(String phone) => - _prefs.setString(_phoneKey, phone); + Future setCachedPhone(String phone) => _prefs.setString(_phoneKey, phone); String get themeMode => _prefs.getString(_themeModeKey) ?? 'system'; - Future setThemeMode(String mode) => - _prefs.setString(_themeModeKey, mode); + Future setThemeMode(String mode) => _prefs.setString(_themeModeKey, mode); String get colorScheme => _prefs.getString(_colorSchemeKey) ?? 'system'; - Future setColorScheme(String scheme) => - _prefs.setString(_colorSchemeKey, scheme); + Future setColorScheme(String scheme) => _prefs.setString(_colorSchemeKey, scheme); bool get useLocalhost => _prefs.getBool(_useLocalhostKey) ?? false; - Future setUseLocalhost(bool value) => - _prefs.setBool(_useLocalhostKey, value); + Future setUseLocalhost(bool value) => _prefs.setBool(_useLocalhostKey, value); // Cloud-sync settings. The master-password-derived key is the one sensitive // piece — it lives in secure storage, never in SharedPreferences. bool get syncEnabled => _prefs.getBool(_syncEnabledKey) ?? false; - Future setSyncEnabled(bool value) => - _prefs.setBool(_syncEnabledKey, value); + Future setSyncEnabled(bool value) => _prefs.setBool(_syncEnabledKey, value); String? get syncLastAt => _prefs.getString(_syncLastAtKey); - Future setSyncLastAt(String iso) => - _prefs.setString(_syncLastAtKey, iso); + Future setSyncLastAt(String iso) => _prefs.setString(_syncLastAtKey, iso); Future loadSyncMasterKey() => _secure.read(key: _syncMasterKeyKey); Future saveSyncMasterKey(String serialized) => @@ -209,8 +203,8 @@ class StorageService { return SemesterInfo.fromJson(jsonDecode(raw) as Map); } - Future saveCourseTable(String semesterId, CourseTable table) => _prefs - .setString('$_courseTablePrefix$semesterId', jsonEncode(table.toJson())); + Future saveCourseTable(String semesterId, CourseTable table) => + _prefs.setString('$_courseTablePrefix$semesterId', jsonEncode(table.toJson())); CourseTable? loadCourseTable(String semesterId) { final raw = _prefs.getString('$_courseTablePrefix$semesterId'); @@ -228,8 +222,7 @@ class StorageService { } String? get selectedSemester => _prefs.getString(_selectedSemesterKey); - Future setSelectedSemester(String id) => - _prefs.setString(_selectedSemesterKey, id); + Future setSelectedSemester(String id) => _prefs.setString(_selectedSemesterKey, id); // Assignments cache (non-sensitive — stored as JSON in SharedPreferences) static const _assignmentsKey = 'cached_assignments'; @@ -268,8 +261,7 @@ class StorageService { } } - Future clearAssignmentOverrides() => - _prefs.remove(_assignmentOverridesKey); + Future clearAssignmentOverrides() => _prefs.remove(_assignmentOverridesKey); // OA gym booking profile. This is non-sensitive contact info used to submit // reservation forms and can be edited by the user. @@ -291,4 +283,66 @@ class StorageService { return const OaBookingProfile(name: '', phone: '', email: ''); } } + + // ---- AI assistant ---- + // The auth token is the one sensitive piece — it lives in secure storage. + // Everything else (config minus the token, conversation history) is plain + // cache in SharedPreferences. + + static const _aiAuthTokenKey = 'ai_auth_token'; // secure storage + static const _aiConfigKey = 'ai_config'; // prefs JSON (token excluded) + static const _aiConversationsKey = 'ai_conversations'; // prefs JSON list + + Future saveAiAuthToken(String token) => _secure.write(key: _aiAuthTokenKey, value: token); + + Future loadAiAuthToken() async => await _secure.read(key: _aiAuthTokenKey) ?? ''; + + Future clearAiAuthToken() => _secure.delete(key: _aiAuthTokenKey); + + /// Persist config with the token stripped out — the token has its own secure + /// slot. We keep the two concerns separate so a prefs export never leaks the + /// secret. + Future saveAiConfig(AiConfig config) => _prefs.setString( + _aiConfigKey, + jsonEncode(config.copyWith(authToken: '').toJson()), + ); + + /// Load config and re-attach the auth token from secure storage. Callers + /// should `await` this so the token is present. + Future loadAiConfig() async { + final token = await loadAiAuthToken(); + final raw = _prefs.getString(_aiConfigKey); + if (raw == null) { + return AiConfig.defaults().copyWith(authToken: token); + } + try { + return AiConfig.fromJson( + jsonDecode(raw) as Map, + ).copyWith(authToken: token); + } catch (_) { + return AiConfig.defaults().copyWith(authToken: token); + } + } + + Future saveAiConversations(List conversations) => _prefs.setString( + _aiConversationsKey, + jsonEncode( + conversations.map((c) => c.toJson()).toList(), + ), + ); + + List loadAiConversations() { + final raw = _prefs.getString(_aiConversationsKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => AiThread.fromJson(e as Map)) + .toList(); + } catch (_) { + return const []; + } + } + + Future clearAiConversations() => _prefs.remove(_aiConversationsKey); } diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index ae97c3f..d6c655c 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -159,6 +159,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -171,6 +176,13 @@ class ThemeService extends ChangeNotifier { return _buildIosTheme(theme, brightness: Brightness.dark); } + /// Snackbars float as rounded cards above the glass bottom nav (which is a + /// 56dp capsule with 8dp margins) instead of a full-width strip glued to it. + static const _snackBarShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + static const _snackBarInsets = EdgeInsets.fromLTRB(12, 0, 12, 12); + ThemeData _buildDesktopTheme(ThemeData base) { return base.copyWith( appBarTheme: base.appBarTheme.copyWith( @@ -179,6 +191,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: base.snackBarTheme.copyWith( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -258,6 +275,9 @@ class ThemeService extends ChangeNotifier { contentTextStyle: TextStyle( color: isDark ? Colors.white : Colors.black, ), + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, ), inputDecorationTheme: InputDecorationTheme( filled: true, diff --git a/lib/widgets/adaptive_feedback.dart b/lib/widgets/adaptive_feedback.dart index 8886e57..feda53e 100644 --- a/lib/widgets/adaptive_feedback.dart +++ b/lib/widgets/adaptive_feedback.dart @@ -1,8 +1,38 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import '../utils/platform.dart'; +import 'app_shell/tg_bottom_nav_bar.dart'; + final GlobalKey rootMessengerKey = GlobalKey(); +final GlobalKey adaptiveFeedbackHostKey = + GlobalKey(); + +/// Tracks how many page routes are stacked above the shell so the feedback +/// banner knows whether the bottom nav is on screen. Dialog/popup routes +/// don't count — the nav stays visible underneath them. +final ValueNotifier _pageRouteDepth = ValueNotifier(0); + +class FeedbackRouteObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value++; + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } +} + enum AdaptiveFeedbackStyle { info, success, error } void showAdaptiveFeedback({ @@ -13,39 +43,220 @@ void showAdaptiveFeedback({ String? actionLabel, VoidCallback? onAction, }) { - final messenger = context != null - ? ScaffoldMessenger.maybeOf(context) - : rootMessengerKey.currentState; - if (messenger == null) return; - - final theme = messenger.context.mounted - ? Theme.of(messenger.context) - : ThemeData.fallback(); - final feedbackStyle = _FeedbackStyle.from(theme, style); - - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - backgroundColor: feedbackStyle.backgroundColor, - content: Row( - children: [ - Icon(feedbackStyle.icon, color: feedbackStyle.foregroundColor), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: TextStyle(color: feedbackStyle.foregroundColor), - ), + adaptiveFeedbackHostKey.currentState?.show( + message: message, + style: style, + duration: duration, + actionLabel: actionLabel, + onAction: onAction, + ); +} + +/// App-level host for feedback banners, mounted above the [Navigator] (via +/// `MaterialApp.builder`). +/// +/// A [SnackBar] can't be used here: the root [ScaffoldMessenger] renders the +/// current snackbar in BOTH the outgoing and incoming route's Scaffold during +/// a page transition, and since pages with and without the bottom nav give it +/// different bottom offsets, the same bar briefly shows twice ("ghosting"). +/// Hosting a single banner above the Navigator means there is exactly one +/// instance, and its bottom clearance simply animates in step with the route +/// transition: flush-ish near the screen edge on pushed pages, floating above +/// the glass capsule (with a larger corner radius) on shell pages. +class AdaptiveFeedbackHost extends StatefulWidget { + final Widget child; + + const AdaptiveFeedbackHost({super.key, required this.child}); + + @override + State createState() => AdaptiveFeedbackHostState(); +} + +class AdaptiveFeedbackHostState extends State + with SingleTickerProviderStateMixin { + // Matches the FadeThroughTransition page-transition timing so the banner + // glides together with the bottom nav's appearance. + static const _repositionDuration = Duration(milliseconds: 300); + static const _repositionCurve = Curves.easeInOutCubic; + + late final AnimationController _controller; + late final Animation _fade; + late final Animation _slide; + + _FeedbackEntry? _entry; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + // The host is (re)built before the Navigator pushes its initial route + // (fresh start and hot restart alike), so the route count starts clean. + _pageRouteDepth.value = 0; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 200), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _slide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(_fade); + } + + void show({ + required String message, + required AdaptiveFeedbackStyle style, + required Duration duration, + String? actionLabel, + VoidCallback? onAction, + }) { + _dismissTimer?.cancel(); + setState(() { + _entry = _FeedbackEntry( + message: message, + style: style, + actionLabel: actionLabel, + onAction: onAction, + ); + }); + _controller.forward(from: _controller.value == 0 ? 0 : _controller.value); + _dismissTimer = Timer(duration, dismiss); + } + + void dismiss() { + _dismissTimer?.cancel(); + _dismissTimer = null; + unawaited( + _controller.reverse().whenComplete(() { + if (mounted && _dismissTimer == null) setState(() => _entry = null); + }), + ); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_entry != null) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _pageRouteDepth, + builder: (context, depth, banner) { + final width = MediaQuery.sizeOf(context).width; + final safeBottom = MediaQuery.viewPaddingOf(context).bottom; + final navVisible = depth <= 1 && width < 600; + final navClearance = isIos() + ? 52.0 + 12 + : TgBottomNavBar.barHeight + 2 * TgBottomNavBar.margin; + final bottom = safeBottom + (navVisible ? navClearance : 12); + return IgnorePointer( + ignoring: _entry!.actionLabel == null, + child: AnimatedPadding( + duration: _repositionDuration, + curve: _repositionCurve, + padding: EdgeInsets.fromLTRB(12, 0, 12, bottom), + child: Align( + alignment: Alignment.bottomCenter, + child: FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: _FeedbackBanner( + entry: _entry!, + rounded: navVisible, + repositionDuration: _repositionDuration, + repositionCurve: _repositionCurve, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _FeedbackEntry { + const _FeedbackEntry({ + required this.message, + required this.style, + this.actionLabel, + this.onAction, + }); + + final String message; + final AdaptiveFeedbackStyle style; + final String? actionLabel; + final VoidCallback? onAction; +} + +class _FeedbackBanner extends StatelessWidget { + const _FeedbackBanner({ + required this.entry, + required this.rounded, + required this.repositionDuration, + required this.repositionCurve, + }); + + final _FeedbackEntry entry; + final bool rounded; + final Duration repositionDuration; + final Curve repositionCurve; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = _FeedbackStyle.from(theme, entry.style); + final radius = BorderRadius.circular(rounded ? 16 : 10); + + return AnimatedContainer( + duration: repositionDuration, + curve: repositionCurve, + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: radius, + boxShadow: kElevationToShadow[6], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, color: style.foregroundColor), + const SizedBox(width: 12), + Flexible( + child: Text( + entry.message, + style: theme.textTheme.bodyMedium + ?.copyWith(color: style.foregroundColor), + ), + ), + if (entry.actionLabel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + entry.onAction?.call(); + adaptiveFeedbackHostKey.currentState?.dismiss(); + }, + child: Text(entry.actionLabel!), ), ], - ), - duration: duration, - action: actionLabel != null && onAction != null - ? SnackBarAction(label: actionLabel, onPressed: onAction) - : null, + ], ), ); + } } class _FeedbackStyle { diff --git a/lib/widgets/ai/ai_code_highlighter.dart b/lib/widgets/ai/ai_code_highlighter.dart new file mode 100644 index 0000000..b32a51f --- /dev/null +++ b/lib/widgets/ai/ai_code_highlighter.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +/// Syntax-highlights AI code blocks using the `highlight` package, mapping +/// token classes to a VS Code–dark-ish palette. Returns `null` (plain +/// monospace) when the language is unknown or parsing fails, so rendering +/// never breaks. +/// +/// This matches the `CodeHighlighter` typedef from flutter_ai_elements +/// structurally (so it can be passed to `AiResponse.codeHighlighter`). +/// Ported verbatim from the flutter_ai demo (`demo/lib/code_highlighter.dart`) +/// so TechPie's code blocks match the demo's look exactly. +List? techpieCodeHighlighter( + String code, + String? language, + TextStyle base, +) { + try { + final result = (language != null && language.isNotEmpty) + ? highlight.parse(code, language: language) + : highlight.parse(code, autoDetection: true); + final nodes = result.nodes; + if (nodes == null) return null; + return [for (final node in nodes) _span(node, base)]; + } catch (_) { + return null; // unknown grammar → fall back to plain monospace + } +} + +const _tokenColors = { + 'keyword': Color(0xFFC586C0), + 'built_in': Color(0xFF4EC9B0), + 'type': Color(0xFF4EC9B0), + 'class': Color(0xFF4EC9B0), + 'title': Color(0xFFDCDCAA), + 'function': Color(0xFFDCDCAA), + 'string': Color(0xFFCE9178), + 'number': Color(0xFFB5CEA8), + 'symbol': Color(0xFFB5CEA8), + 'literal': Color(0xFF569CD6), + 'comment': Color(0xFF6A9955), + 'meta': Color(0xFF9CDCFE), + 'attr': Color(0xFF9CDCFE), +}; + +TextSpan _span(Node node, TextStyle base) { + final color = node.className == null ? null : _tokenColors[node.className!]; + final style = color == null ? base : base.copyWith(color: color); + final value = node.value; + if (value != null) return TextSpan(text: value, style: style); + final children = node.children ?? const []; + return TextSpan( + style: style, + children: [for (final child in children) _span(child, base)], + ); +} diff --git a/lib/widgets/ai/ai_text_renderer.dart b/lib/widgets/ai/ai_text_renderer.dart new file mode 100644 index 0000000..5a2264a --- /dev/null +++ b/lib/widgets/ai/ai_text_renderer.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import 'ai_code_highlighter.dart'; + +/// A [TextRenderer] that renders Markdown via [AiResponse] **even while +/// streaming**, so markdown and code blocks form up live as tokens arrive. +/// +/// This is the demo's "render Markdown during streaming too" opt-out from the +/// flutter_ai `ChatScreen` — the library's default [MarkdownTextRenderer] +/// switches to [AiAnimatedResponse] (plain-prose blur fade) while streaming, +/// which doesn't render Markdown. We prefer live Markdown here. +/// +/// [AiResponse] caches its parse and only re-parses when the text actually +/// changes, and the controller coalesces notifications, so re-rendering per +/// streamed token stays smooth. +class StreamingMarkdownRenderer implements AiTextRenderer { + const StreamingMarkdownRenderer({this.onLinkTap}); + + /// Called when a link is tapped. If `null`, links render but aren't tappable. + final void Function(Uri url)? onLinkTap; + + @override + Widget render(String text, {required bool isStreaming}) => AiResponse( + text: text, + onLinkTap: onLinkTap, + codeHighlighter: techpieCodeHighlighter, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 27b8da6..734c449 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -138,6 +138,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "24cbd5616f3d4008c335c197bb90bfa0eb43b9e55c6de5c60d1f805092636034" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "2125ffb6325e0045cc3cbfca1a87e6defe9afa27d85dc14857c1cf3835a9a636" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" ffi: dependency: transitive description: @@ -159,6 +175,51 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_ai_client: + dependency: "direct main" + description: + path: "packages/flutter_ai_client" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git + version: "0.3.0" + flutter_ai_core: + dependency: "direct main" + description: + path: "packages/flutter_ai_core" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git + version: "0.1.14" + flutter_ai_elements: + dependency: "direct main" + description: + path: "packages/flutter_ai_elements" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git + version: "0.2.0" + flutter_ai_provider_anthropic: + dependency: "direct main" + description: + path: "packages/flutter_ai_provider_anthropic" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git + version: "0.1.12" + flutter_ai_tools: + dependency: "direct main" + description: + path: "packages/flutter_ai_tools" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git + version: "0.1.4" flutter_inappwebview: dependency: transitive description: @@ -306,6 +367,14 @@ packages: description: flutter source: sdk version: "0.0.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.0" http: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index edb1919..81c1ea6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,12 +38,42 @@ dependencies: dynamic_color: ">=1.7.0 <1.8.0" flutter: sdk: flutter + # AI chat UI: the flutter_ai library family, from the HeZeBang fork + # (upstream ananmouaz/flutter_ai + OHOS/tool-call fixes not yet published + # to pub.dev). Pinned to a commit for reproducible builds. + flutter_ai_client: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_client + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + flutter_ai_core: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_core + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + flutter_ai_elements: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_elements + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + flutter_ai_provider_anthropic: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_provider_anthropic + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + flutter_ai_tools: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_tools + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_secure_storage: ^9.2.4 flutter_secure_storage_ohos: # HarmonyOS 安全存储 git: url: https://gitcode.com/openharmony-sig/fluttertpc_flutter_secure_storage.git path: flutter_secure_storage_ohos ref: br_v9.2.2_ohos + # Syntax highlighting for AI code blocks (port of highlight.js, pure Dart). + highlight: ^0.7.0 http: ^1.4.0 intl: ^0.20.2 open_filex: ^4.5.0 @@ -67,6 +97,20 @@ dependency_overrides: url: https://github.com/HeZeBang/desktop_webview_window_fork.git ref: master + # The flutter_ai packages declare transitive deps on flutter_ai_core/client + # as hosted (pub.dev) ranges, but we consume them from the fork. Force every + # transitive reference to the same git source so version solving agrees. + flutter_ai_client: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_client + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + flutter_ai_core: + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_core + ref: 589a47d2262864dfd0ddbac27de0926748b26130 + # OpenHarmony-SIG forks add OHOS platform implementations for plugins # whose upstream pub.dev releases don't ship them. Without these, the # MethodChannel calls on OHOS throw MissingPluginException at boot. diff --git a/test/ai_service_test.dart b/test/ai_service_test.dart new file mode 100644 index 0000000..9d6b001 --- /dev/null +++ b/test/ai_service_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart' + show ToolCallPart, ToolCallState, ToolResultPart; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:techpie/models/ai_chat.dart'; +import 'package:techpie/services/ai_service.dart'; +import 'package:techpie/services/assignment_service.dart'; +import 'package:techpie/services/auth_service.dart'; +import 'package:techpie/services/debug_logger.dart'; +import 'package:techpie/services/http_client.dart'; +import 'package:techpie/services/schedule_service.dart'; +import 'package:techpie/services/storage_service.dart'; +import 'package:techpie/services/third_party_auth_service.dart'; +import 'package:techpie/services/uni_auth_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // flutter_secure_storage_ohos reads via this method channel; in unit tests + // there's no plugin, so stub it to behave as empty storage. + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'read': + return null; // nothing stored → AiService loads defaults + case 'write': + case 'delete': + return null; + default: + return null; + } + }); + + late StorageService storage; + late AiService ai; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + storage = StorageService(prefs); + final logger = DebugLogger(); + final http = LoggingHttpClient(logger); + final uniAuth = UniAuthService(); + final auth = AuthService(storage, http, uniAuth); + final tpAuth = ThirdPartyAuthService(storage, http); + final schedule = ScheduleService(storage, http, auth, tpAuth); + final assignments = + AssignmentService(storage, http, auth, tpAuth, schedule); + ai = AiService(storage, schedule, assignments, tpAuth); + await ai.initialize(); + }); + + group('AiThread serialization', () { + test('round-trips through JSON with a single text part', () { + final thread = AiThread( + id: 't1', + title: 'hello', + updatedAt: DateTime.utc(2026, 7, 26), + messages: [ + const AiMessage( + id: 'm1', + role: AiRole.user, + parts: [TextPart('hi there')], + ), + ], + ); + final json = thread.toJson(); + final restored = AiThread.fromJson(json); + + expect(restored.id, 't1'); + expect(restored.title, 'hello'); + expect(restored.messages, hasLength(1)); + expect(restored.messages.first.role, AiRole.user); + expect(restored.messages.first.text, 'hi there'); + expect(restored.messages.first.parts.first, isA()); + }); + + test('empty-content non-system messages are dropped on load', () { + final raw = { + 'id': 't2', + 'title': 'x', + 'updatedAt': '2026-07-26T00:00:00.000Z', + 'messages': [ + {'id': 'a', 'role': 'user', 'status': 'complete', 'parts': [ + {'type': 'text', 'text': ''}, + ],}, + {'id': 'b', 'role': 'assistant', 'status': 'complete', 'parts': [ + {'type': 'text', 'text': 'real reply'}, + ],}, + ], + }; + final restored = AiThread.fromJson(raw); + expect(restored.messages, hasLength(1)); + expect(restored.messages.first.text, 'real reply'); + }); + + test('tool-result messages survive a persistence round-trip', () { + // Tool messages have no text part. The load filter must not drop them: + // losing a tool result leaves its tool_use unanswered, and providers + // reject the whole conversation on the next request. + final thread = AiThread( + id: 't3', + title: 'x', + updatedAt: DateTime.utc(2026, 7, 26), + messages: const [ + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('几点了')]), + AiMessage( + id: 'a', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_current_time', + state: ToolCallState.inputAvailable, + ), + ], + status: AiMessageStatus.complete, + ), + AiMessage( + id: 't', + role: AiRole.tool, + parts: [ + ToolResultPart( + toolCallId: 'c1', + result: {'now': '2026-07-26T12:00:00'}, + ), + ], + status: AiMessageStatus.complete, + ), + ], + ); + final restored = AiThread.fromJson(thread.toJson()); + expect(restored.messages, hasLength(3)); + expect(restored.messages[2].role, AiRole.tool); + expect( + restored.messages[2].parts.single, + isA(), + ); + }); + }); + + group('AiService lifecycle', () { + test('initialize seeds one fresh conversation', () { + expect(ai.conversations, hasLength(1)); + expect(ai.currentConversation, isNotNull); + expect(ai.currentConversation!.title, '新对话'); + expect(ai.isConfigured, isFalse); + expect(ai.isStreaming, isFalse); + }); + + test('newConversation adds a thread and switches to it', () { + final first = ai.currentConversation!.id; + final created = ai.newConversation(); + expect(ai.conversations, hasLength(2)); + expect(ai.currentConversation!.id, created.id); + expect(created.id, isNot(first)); + }); + + test('renameConversation updates title and is reflected in the list', () async { + final id = ai.currentConversation!.id; + await ai.renameConversation(id, '我的对话'); + expect(ai.currentConversation!.title, '我的对话'); + }); + + test('deleteConversation on the current thread re-points to another', () async { + final a = ai.newConversation(); + final b = ai.newConversation(); + expect(ai.currentConversation!.id, b.id); + await ai.deleteConversation(b.id); + expect(ai.conversations.any((c) => c.id == b.id), isFalse); + expect(ai.currentConversation, isNotNull); + expect(ai.currentConversation!.id, isNot(b.id)); + expect(ai.conversations.any((c) => c.id == a.id), isTrue); + }); + + test('clearAllConversations leaves exactly one fresh thread', () async { + ai.newConversation(); + ai.newConversation(); + await ai.clearAllConversations(); + expect(ai.conversations, hasLength(1)); + expect(ai.currentConversation!.title, '新对话'); + }); + + test('selectConversation switches the current pointer', () { + final a = ai.currentConversation!.id; + final b = ai.newConversation().id; + expect(ai.currentConversation!.id, b); + ai.selectConversation(a); + expect(ai.currentConversation!.id, a); + }); + }); + + group('baseUrl normalization', () { + test('appends /v1 when missing', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('strips a trailing /messages', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic/v1/messages'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('leaves an already-/v1 root untouched', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic/v1'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('trims trailing slashes', () { + expect( + normalizeAiBaseUrl('https://api.anthropic.com/v1/'), + 'https://api.anthropic.com/v1', + ); + }); + + test('falls back to the default for empty input', () { + expect(normalizeAiBaseUrl(''), AiConfig.defaultBaseUrl); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 4811f70..04ba7f3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:techpie/main.dart'; +import 'package:techpie/services/ai_service.dart'; import 'package:techpie/services/assignment_service.dart'; import 'package:techpie/services/auth_service.dart'; import 'package:techpie/services/debug_logger.dart'; @@ -39,6 +40,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); + final ai = AiService(storage, schedule, assignments, tpAuth); await tester.pumpWidget( TechPieApp( @@ -52,6 +54,7 @@ void main() { oaGymService: oaGym, uniAuthService: uniAuth, syncService: sync, + aiService: ai, ), ); @@ -80,6 +83,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); + final ai = AiService(storage, schedule, assignments, tpAuth); await tester.pumpWidget( TechPieApp( @@ -93,6 +97,7 @@ void main() { oaGymService: oaGym, uniAuthService: uniAuth, syncService: sync, + aiService: ai, ), );