tg_claw_kit 0.0.1
tg_claw_kit: ^0.0.1 copied to clipboard
Flutter plugin for Claw auth, hidden webcam env bootstrap, chat session management, and device authorization.
example/lib/main.dart
import 'dart:convert' as convert;
import 'dart:io';
import 'dart:typed_data' as typed_data;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:rapid_kit/rapid_kit.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tg_claw_kit/tg_claw_kit.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const TgClawKitExampleApp());
}
class TgClawKitExampleApp extends StatelessWidget {
const TgClawKitExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'tg_claw_kit example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF182238),
surface: const Color(0xFFF1F5FB),
),
scaffoldBackgroundColor: const Color(0xFFF1F5FB),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
useMaterial3: true,
),
home: const ExampleBootstrapPage(),
);
}
}
class ExampleBootstrapPage extends StatefulWidget {
const ExampleBootstrapPage({super.key});
@override
State<ExampleBootstrapPage> createState() => _ExampleBootstrapPageState();
}
class _ExampleBootstrapPageState extends State<ExampleBootstrapPage> {
bool _loading = true;
String? _error;
String? _accessToken;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
await ExampleStore.instance.init();
final ExampleEnvironment environment = ExampleEnvironment.fromIndex(
ExampleStore.instance.apiEnv,
);
await RapidKit.initialize(
Configurations(
id: ExampleStore.instance.appId,
package: ExampleStore.instance.packageName,
debugging: true,
httpLogging: false,
environment: environment.toRapidEnvironment(),
),
);
await TgClawKit.initialize(
environment: environment.toPluginEnvironment(),
authAppId: ExampleStore.instance.appId,
authPackageName: ExampleStore.instance.packageName,
);
final String storedAccessToken = ExampleStore.instance.accessToken.trim();
if (storedAccessToken.isNotEmpty) {
final Resp<VerifiedToken> authResp = await Authenticate.configure(
storedAccessToken,
);
if (!authResp.success) {
await ExampleStore.instance.clearLogin();
}
}
if (!mounted) {
return;
}
setState(() {
_loading = false;
_accessToken = ExampleStore.instance.accessToken.trim();
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loading = false;
_error = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_error!),
),
),
);
}
return _accessToken?.isNotEmpty == true
? HomePage(initialToken: _accessToken!)
: const LoginPage();
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _accountController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _submitting = false;
@override
void initState() {
super.initState();
_accountController.text = ExampleStore.instance.savedAccount;
}
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
FocusScope.of(context).unfocus();
final String account = _accountController.text.trim();
final String password = _passwordController.text;
if (account.isEmpty || password.isEmpty) {
_showSnack('请输入账号和密码');
return;
}
setState(() {
_submitting = true;
});
try {
final Resp<LoginRet> loginResp = await AccountService.login(
account,
password,
);
if (!mounted) {
return;
}
if (!loginResp.success ||
(loginResp.data?.accessToken?.isEmpty ?? true)) {
_showSnack('登录失败: ${loginResp.message ?? loginResp.code}');
return;
}
final String accessToken = loginResp.data!.accessToken!.trim();
final Resp<VerifiedToken> authResp = await Authenticate.configure(
accessToken,
);
if (!mounted) {
return;
}
if (!authResp.success) {
_showSnack('RapidKit 鉴权失败: ${authResp.message ?? authResp.code}');
return;
}
await ExampleStore.instance.saveLogin(
account: account,
accessToken: accessToken,
);
if (!mounted) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomePage(initialToken: accessToken)),
);
} catch (error) {
_showSnack('登录异常: $error');
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
void _showSnack(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
const Text(
'tg_claw_kit Demo',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Color(0xFF182238),
),
),
const SizedBox(height: 12),
Text(
'RapidKit 登录 + tg_claw_kit Claw 会话与设备授权',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.blueGrey.shade600,
fontSize: 14,
),
),
const SizedBox(height: 32),
TextField(
controller: _accountController,
decoration: const InputDecoration(
labelText: '账号',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _submitting ? null : _login,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
},
child: const Text('环境配置'),
),
],
),
),
),
),
),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key, required this.initialToken});
final String initialToken;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final List<Widget> pages = <Widget>[
ClawChatPage(accessToken: widget.initialToken),
DeviceAuthorizationPage(accessToken: widget.initialToken),
ExampleProfilePage(accessToken: widget.initialToken),
];
return Scaffold(
body: pages[_currentIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (int index) {
setState(() {
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
label: 'Claw',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
label: '授权',
),
NavigationDestination(icon: Icon(Icons.person_outline), label: '我的'),
],
),
);
}
}
class ClawChatPage extends StatefulWidget {
const ClawChatPage({super.key, required this.accessToken});
final String accessToken;
@override
State<ClawChatPage> createState() => _ClawChatPageState();
}
class _ClawChatPageState extends State<ClawChatPage> {
static const Color _pageBackground = Color(0xFFF1F5FB);
static const Color _panelColor = Colors.white;
static const Color _primaryInk = Color(0xFF182238);
static const Color _secondaryInk = Color(0xFF60708C);
static const Color _accentSurface = Color(0xFFEAF0F8);
static const Color _accentBorder = Color(0xFFDCE4F0);
static const MethodChannel _rapidMethodChannel = MethodChannel(
'com.tange.ai/rapid_kit/method.channel',
);
final TextEditingController _queryController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<ExampleChatMessage> _messages = <ExampleChatMessage>[];
final Map<String, int> _messageIndexById = <String, int>{};
bool _loadingClaw = false;
String _loadingClawText = '获取 token 中...';
bool _streaming = false;
bool _showToolBubbles = false;
String? _error;
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void dispose() {
_queryController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _bootstrap() async {
setState(() {
_loadingClaw = true;
_loadingClawText = '获取 token 中...';
_error = null;
});
try {
await TgClawKit.auth(accessToken: widget.accessToken);
if (!mounted) {
return;
}
setState(() {
_loadingClawText = '加载历史会话中...';
});
final TgClawChatHistory? history = await TgClawKit.loadChatHistory();
if (!mounted) {
return;
}
setState(() {
_messages
..clear()
..addAll(
(history?.messages ?? const <TgClawMessage>[]).map(
ExampleChatMessage.fromDomain,
),
);
_rebuildMessageIndex();
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_error = error.toString();
});
_showSnack('Claw 初始化失败: $error');
} finally {
if (mounted) {
setState(() {
_loadingClaw = false;
_loadingClawText = '获取 token 中...';
});
}
}
}
void _rebuildMessageIndex() {
_messageIndexById.clear();
for (int i = 0; i < _messages.length; i++) {
_messageIndexById[_messages[i].id] = i;
}
}
void _submitFromComposer() {
final String prompt = _queryController.text.trim();
if (prompt.isEmpty || _streaming || _loadingClaw) {
return;
}
if (TgClawKit.currentAuthSession == null) {
_showSnack('当前未获取到 Claw Token,请确认登录态后重试');
return;
}
_queryController.clear();
_sendPrompt(prompt);
}
Future<void> _sendPrompt(String prompt) async {
setState(() {
_messages.add(ExampleChatMessage.user(prompt));
_rebuildMessageIndex();
_streaming = true;
_error = null;
});
_scrollToBottom();
try {
await TgClawKit.chat(
prompt,
onEvent: _handleChatEvent,
onError: (TgClawException error) {
_addSystemMessage(error.toString());
_showSnack('聊天请求失败');
},
);
} catch (_) {
// error already surfaced through onError
} finally {
if (mounted) {
setState(() {
_streaming = false;
for (int i = 0; i < _messages.length; i++) {
if (_messages[i].streaming) {
_messages[i] = _messages[i].copyWith(streaming: false);
}
}
_rebuildMessageIndex();
});
_scrollToBottom();
}
}
}
void _handleChatEvent(TgClawChatEvent event) {
if (!mounted) {
return;
}
if (event.type == TgClawChatEventType.messageFailed &&
event.message != null) {
_showSnack('聊天请求失败');
}
final TgClawMessage? message = event.message;
if (message == null) {
return;
}
final ExampleChatMessage next = ExampleChatMessage.fromDomain(message);
final int? existingIndex = _messageIndexById[next.id];
setState(() {
if (existingIndex == null) {
_messages.add(next);
} else {
_messages[existingIndex] = next;
}
_rebuildMessageIndex();
});
_scrollToBottom();
}
void _addSystemMessage(String text) {
if (!mounted) {
return;
}
setState(() {
_messages.add(ExampleChatMessage.system(text));
_rebuildMessageIndex();
});
_scrollToBottom();
}
void _scrollToBottom({bool animated = true}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
return;
}
final double target = _scrollController.position.maxScrollExtent;
if (animated) {
_scrollController.animateTo(
target,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
return;
}
_scrollController.jumpTo(target);
});
}
void _showSnack(String message) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _pageBackground,
body: SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
decoration: BoxDecoration(
color: _pageBackground,
borderRadius: BorderRadius.circular(36),
),
child: Stack(
children: [
AbsorbPointer(
absorbing: _loadingClaw,
child: Column(
children: [
if (_messages.isEmpty) ...[
_buildRangeHint(),
const SizedBox(height: 16),
],
Expanded(child: _buildConversation()),
const SizedBox(height: 16),
_buildComposer(),
],
),
),
if (_loadingClaw)
Positioned.fill(child: _buildLoadingOverlay()),
],
),
),
),
],
),
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 8),
child: Stack(
alignment: Alignment.center,
children: [
const Align(
alignment: Alignment.center,
child: Text(
'Claw',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: _primaryInk,
),
),
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: _showToolBubbles ? '隐藏过程输出' : '显示过程输出',
onPressed: () {
setState(() {
_showToolBubbles = !_showToolBubbles;
});
},
icon: Icon(
_showToolBubbles
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: _secondaryInk,
size: 24,
),
),
),
],
),
);
}
Widget _buildLoadingOverlay() {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.92),
const Color(0xFFF0F5FC).withValues(alpha: 0.95),
],
),
),
child: Center(
child: Container(
width: 244,
padding: const EdgeInsets.fromLTRB(18, 18, 18, 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _accentBorder),
boxShadow: const [
BoxShadow(
color: Color(0x16000000),
blurRadius: 18,
offset: Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
strokeWidth: 3,
color: _secondaryInk,
),
),
const SizedBox(height: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Text(
_loadingClawText,
key: ValueKey<String>(_loadingClawText),
textAlign: TextAlign.center,
style: const TextStyle(
color: _primaryInk,
fontSize: 14,
fontWeight: FontWeight.w800,
height: 1.4,
),
),
),
],
),
),
),
);
}
Widget _buildRangeHint() {
const String hint =
'你好!我是你的 Claw 助手。这里已经接入 tg_claw_kit,支持多轮上下文、流式响应、工具调用展示和设备授权。';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _accentBorder),
),
child: Text(
hint,
style: const TextStyle(color: _secondaryInk, fontSize: 12, height: 1.4),
),
);
}
Widget _buildConversation() {
final List<ExampleChatMessage> visibleMessages = _messages
.where(
(ExampleChatMessage message) => !_shouldHideMessageBubble(message),
)
.toList(growable: false);
if (_error != null && visibleMessages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.w700,
),
),
),
);
}
if (visibleMessages.isEmpty) {
return const SizedBox.shrink();
}
return ListView.separated(
controller: _scrollController,
itemCount: visibleMessages.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ExampleChatMessage message = visibleMessages[index];
return message.isUser
? _buildUserBubble(message)
: _buildAssistantBubble(message);
},
);
}
bool _shouldHideMessageBubble(ExampleChatMessage message) {
if (_showToolBubbles) {
return false;
}
return message.role == TgClawMessageRole.reasoning ||
message.role == TgClawMessageRole.toolCall ||
message.role == TgClawMessageRole.toolOutput;
}
Widget _buildUserBubble(ExampleChatMessage message) {
return Align(
alignment: Alignment.centerRight,
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
color: _primaryInk,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(6),
),
),
child: Text(
message.text,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
height: 1.5,
),
),
),
);
}
Widget _buildAssistantBubble(ExampleChatMessage message) {
final bool isToolCard =
message.role == TgClawMessageRole.toolCall ||
message.role == TgClawMessageRole.toolOutput;
final bool isReasoning = message.role == TgClawMessageRole.reasoning;
return Align(
alignment: Alignment.centerLeft,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _panelColor,
borderRadius: BorderRadius.circular(26),
border: Border.all(
color: isToolCard ? const Color(0xFFCFE0F7) : _accentBorder,
),
boxShadow: const [
BoxShadow(
color: Color(0x12000000),
blurRadius: 18,
offset: Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Icon(
_iconForRole(message.role),
size: 16,
color: _secondaryInk,
),
const SizedBox(width: 6),
Text(
message.title,
style: const TextStyle(
color: _primaryInk,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
],
),
),
if ((message.toolName ?? '').trim().isNotEmpty)
_buildToolMetaRow('name', message.toolName!.trim()),
if ((message.toolArguments ?? '').trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildCodeBlock(message.toolArguments!.trim()),
),
if ((message.toolOutput ?? '').trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildCodeBlock(message.toolOutput!.trim()),
),
if (message.media.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildMediaBlocks(message.media),
),
if (message.text.trim().isNotEmpty)
Padding(
padding: EdgeInsets.only(
top:
((message.toolArguments ?? '').trim().isNotEmpty ||
(message.toolOutput ?? '').trim().isNotEmpty ||
message.media.isNotEmpty)
? 10
: 0,
),
child: MarkdownBody(
data: message.text,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isReasoning ? _secondaryInk : _primaryInk,
fontSize: isReasoning ? 13 : 14,
fontStyle: isReasoning
? FontStyle.italic
: FontStyle.normal,
fontWeight: isReasoning
? FontWeight.w500
: FontWeight.w600,
height: 1.6,
),
code: const TextStyle(
color: _primaryInk,
fontSize: 12,
fontWeight: FontWeight.w500,
fontFamily: 'monospace',
height: 1.45,
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF6F9FF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _accentBorder),
),
codeblockPadding: const EdgeInsets.all(10),
blockquoteDecoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _accentBorder),
),
blockquotePadding: const EdgeInsets.all(10),
h1: const TextStyle(
color: _primaryInk,
fontSize: 20,
fontWeight: FontWeight.w800,
),
h2: const TextStyle(
color: _primaryInk,
fontSize: 18,
fontWeight: FontWeight.w800,
),
h3: const TextStyle(
color: _primaryInk,
fontSize: 16,
fontWeight: FontWeight.w800,
),
listBullet: TextStyle(
color: isReasoning ? _secondaryInk : _primaryInk,
fontSize: isReasoning ? 13 : 14,
fontWeight: isReasoning
? FontWeight.w500
: FontWeight.w600,
),
strong: TextStyle(
color: isReasoning ? _secondaryInk : _primaryInk,
fontWeight: FontWeight.w800,
),
em: TextStyle(
color: isReasoning ? _secondaryInk : _primaryInk,
fontStyle: FontStyle.italic,
),
),
),
),
if (message.streaming)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: _secondaryInk,
),
),
SizedBox(width: 8),
Text(
'流式响应中...',
style: TextStyle(
color: _secondaryInk,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
);
}
IconData _iconForRole(TgClawMessageRole role) {
switch (role) {
case TgClawMessageRole.reasoning:
return Icons.psychology_alt_outlined;
case TgClawMessageRole.toolCall:
return Icons.build_circle_outlined;
case TgClawMessageRole.toolOutput:
return Icons.data_object_outlined;
case TgClawMessageRole.system:
return Icons.info_outline;
case TgClawMessageRole.assistant:
case TgClawMessageRole.user:
return Icons.chat_bubble_outline;
}
}
Widget _buildToolMetaRow(String label, String value) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: _accentSurface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label: ',
style: const TextStyle(
color: _secondaryInk,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
Expanded(
child: SelectableText(
value,
style: const TextStyle(
color: _primaryInk,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
Widget _buildCodeBlock(String value) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFF6F9FF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _accentBorder),
),
child: SelectableText(
value,
style: const TextStyle(
color: _primaryInk,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.45,
fontFamily: 'monospace',
),
),
);
}
Widget _buildMediaBlocks(List<ExampleChatMedia> mediaList) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < mediaList.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_buildMediaItem(mediaList[i]),
],
],
);
}
Widget _buildMediaItem(ExampleChatMedia media) {
if (media.type == TgClawMediaType.image) {
return _buildImageMediaItem(media);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _accentBorder),
),
child: Row(
children: [
const Icon(
Icons.insert_drive_file_outlined,
size: 18,
color: _secondaryInk,
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
(media.filename ?? '').trim().isNotEmpty
? media.filename!.trim()
: '文件输出',
style: const TextStyle(
color: _primaryInk,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
Widget _buildImageMediaItem(ExampleChatMedia media) {
final typed_data.Uint8List? bytes = _decodeImageBytes(media.uri);
if (bytes == null || bytes.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _accentBorder),
),
child: const Text(
'图片数据解析失败',
style: TextStyle(
color: _secondaryInk,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
);
}
final String filename = (media.filename ?? '').trim();
return Container(
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _accentBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => _openImagePreview(media: media, bytes: bytes),
child: Stack(
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: Image.memory(
bytes,
fit: BoxFit.cover,
width: double.infinity,
gaplessPlayback: true,
),
),
const Positioned(
right: 10,
top: 10,
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(0xB3000000),
borderRadius: BorderRadius.all(Radius.circular(999)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.zoom_in_rounded,
color: Colors.white,
size: 14,
),
SizedBox(width: 4),
Text(
'点击放大',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 6, 8),
child: Row(
children: [
Expanded(
child: Text(
filename.isNotEmpty ? filename : '图片',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _secondaryInk,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: '下载图片',
visualDensity: VisualDensity.compact,
onPressed: () =>
_downloadImageMedia(media: media, bytes: bytes),
icon: const Icon(
Icons.download_rounded,
size: 20,
color: _secondaryInk,
),
),
],
),
),
],
),
);
}
typed_data.Uint8List? _decodeImageBytes(String uri) {
final String trimmed = uri.trim();
if (trimmed.isEmpty) {
return null;
}
if (trimmed.startsWith('data:')) {
final int commaIndex = trimmed.indexOf(',');
if (commaIndex < 0 || commaIndex >= trimmed.length - 1) {
return null;
}
final String encoded = trimmed.substring(commaIndex + 1).trim();
try {
return convert.base64Decode(encoded);
} catch (_) {
return null;
}
}
try {
return convert.base64Decode(trimmed);
} catch (_) {
return null;
}
}
Future<void> _openImagePreview({
required ExampleChatMedia media,
required typed_data.Uint8List bytes,
}) async {
final String title = (media.filename ?? '').trim().isNotEmpty
? media.filename!.trim()
: '图片预览';
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => _ImagePreviewPage(
title: title,
bytes: bytes,
onDownload: () => _downloadImageMedia(media: media, bytes: bytes),
),
),
);
}
Future<void> _downloadImageMedia({
required ExampleChatMedia media,
required typed_data.Uint8List bytes,
}) async {
final bool granted = await _ensureGalleryPermission();
if (!granted) {
return;
}
try {
final String cacheBase =
(await _rapidMethodChannel.invokeMethod<String>(
'requireWriteableCachePath',
))?.trim() ??
'';
if (cacheBase.isEmpty) {
_showSnack('图片下载失败: 无法申请缓存路径');
return;
}
final String imagePath = '$cacheBase${_imageExtensionFor(media)}';
final File file = File(imagePath);
await file.writeAsBytes(bytes, flush: true);
final bool exported =
(await _rapidMethodChannel.invokeMethod<bool>(
'exportCapturedPicture',
<String, dynamic>{'cachePath': imagePath},
)) ??
false;
if (!exported && await file.exists()) {
await file.delete();
}
_showSnack(exported ? '图片下载成功' : '图片下载失败');
} catch (error) {
_showSnack('图片下载失败: $error');
}
}
Future<bool> _ensureGalleryPermission() async {
final bool granted = await RuntimePermissions.accessGranted(
PermissionType.gallery,
);
if (granted) {
return true;
}
final PermissionState state = await RuntimePermissions.requestAccess(
PermissionType.gallery,
);
if (!mounted) {
return false;
}
if (state != PermissionState.granted) {
_showSnack(
state == PermissionState.denied ? '请到系统设置中,手动授权访问相册。' : '请授权访问相册。',
);
return false;
}
return true;
}
String _imageExtensionFor(ExampleChatMedia media) {
final String filename = (media.filename ?? '').trim().toLowerCase();
if (filename.endsWith('.png')) {
return '.png';
}
if (filename.endsWith('.webp')) {
return '.webp';
}
if (filename.endsWith('.gif')) {
return '.gif';
}
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) {
return '.jpg';
}
final String mime = media.mimeType.trim().toLowerCase();
if (mime.contains('png')) {
return '.png';
}
if (mime.contains('webp')) {
return '.webp';
}
if (mime.contains('gif')) {
return '.gif';
}
return '.jpg';
}
Widget _buildComposer() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
decoration: BoxDecoration(
color: _panelColor,
borderRadius: BorderRadius.circular(999),
boxShadow: const [
BoxShadow(
color: Color(0x12000000),
blurRadius: 18,
offset: Offset(0, 8),
),
],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _queryController,
enabled: !_streaming && !_loadingClaw,
onSubmitted: (_) => _submitFromComposer(),
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
hintText: _loadingClaw
? _loadingClawText
: (_streaming ? '响应中,请稍候...' : '输入指令...'),
hintStyle: const TextStyle(
color: Color(0xFF98A4BA),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
style: const TextStyle(
color: _primaryInk,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
InkWell(
borderRadius: BorderRadius.circular(999),
onTap: (_streaming || _loadingClaw) ? null : _submitFromComposer,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: (_streaming || _loadingClaw)
? const Color(0xFF9AA8BF)
: _primaryInk,
shape: BoxShape.circle,
),
child: const Icon(
Icons.send_rounded,
color: Colors.white,
size: 28,
),
),
),
],
),
);
}
}
class DeviceAuthorizationPage extends StatefulWidget {
const DeviceAuthorizationPage({super.key, required this.accessToken});
final String accessToken;
@override
State<DeviceAuthorizationPage> createState() =>
_DeviceAuthorizationPageState();
}
class _DeviceAuthorizationPageState extends State<DeviceAuthorizationPage> {
static const Color _pageBackground = Color(0xFFF1F5FB);
static const Color _panelColor = Colors.white;
static const Color _primaryInk = Color(0xFF182238);
static const Color _secondaryInk = Color(0xFF60708C);
static const Color _accentBorder = Color(0xFFDCE4F0);
bool _loading = true;
String? _error;
List<Primary> _devices = const <Primary>[];
final Set<String> _authorizedDeviceIds = <String>{};
final Set<String> _authorizingDeviceIds = <String>{};
final Set<String> _revokingDeviceIds = <String>{};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final Resp<PrimaryByPage> deviceResp = await SpecService.primaryByPage(
0,
limit: 100,
);
if (!deviceResp.success) {
throw StateError(
'${deviceResp.code} ${deviceResp.message ?? '设备列表加载失败'}'.trim(),
);
}
final List<TgClawAuthorizedDevice> authorizedDevices =
await TgClawKit.getAuthorizationDevices();
if (!mounted) {
return;
}
setState(() {
_devices = deviceResp.data?.list ?? const <Primary>[];
_authorizedDeviceIds
..clear()
..addAll(
authorizedDevices.map(
(TgClawAuthorizedDevice device) =>
_normalizeDeviceId(device.deviceId),
),
);
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_error = error.toString();
});
_showSnack('更新设备授权状态失败: $error');
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
Future<void> _authorizeDevice(String deviceId) async {
final String normalizedId = _normalizeDeviceId(deviceId);
if (normalizedId.isEmpty) {
_showSnack('设备 ID 为空,无法授权');
return;
}
setState(() {
_authorizingDeviceIds.add(normalizedId);
});
try {
await TgClawKit.authorize(deviceId: deviceId);
if (!mounted) {
return;
}
setState(() {
_authorizedDeviceIds.add(normalizedId);
});
_showSnack('设备授权成功');
} catch (error) {
_showSnack('设备授权失败: $error');
} finally {
if (mounted) {
setState(() {
_authorizingDeviceIds.remove(normalizedId);
});
}
}
}
Future<void> _revokeDevice(String deviceId, String displayName) async {
final bool confirmed = await _confirmRevokeAuthorization(
deviceName: displayName,
deviceId: deviceId,
);
if (!confirmed) {
return;
}
final String normalizedId = _normalizeDeviceId(deviceId);
setState(() {
_revokingDeviceIds.add(normalizedId);
});
try {
await TgClawKit.revoke(deviceId: deviceId);
if (!mounted) {
return;
}
setState(() {
_authorizedDeviceIds.remove(normalizedId);
});
_showSnack('已解除授权');
} catch (error) {
_showSnack('解除设备授权失败: $error');
} finally {
if (mounted) {
setState(() {
_revokingDeviceIds.remove(normalizedId);
});
}
}
}
String _normalizeDeviceId(String value) => value.trim().toLowerCase();
void _showSnack(String message) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<bool> _confirmRevokeAuthorization({
required String deviceName,
required String deviceId,
}) async {
final String target = deviceName.trim().isNotEmpty
? deviceName.trim()
: (deviceId.trim().isNotEmpty ? deviceId.trim() : '当前设备');
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('确认解除授权?'),
content: Text('解除后,AI 将无法继续访问设备“$target”。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: _secondaryInk),
child: const Text('确认解除'),
),
],
);
},
);
return confirmed == true;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _pageBackground,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 8),
child: Row(
children: [
const Expanded(
child: Text(
'设备授权',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: _primaryInk,
),
),
),
IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(
Icons.refresh_rounded,
color: _secondaryInk,
),
),
],
),
),
Expanded(
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
decoration: BoxDecoration(
color: _pageBackground,
borderRadius: BorderRadius.circular(36),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(left: 2),
child: Text(
'选择设备进行授权或解除授权。授权后,AI 可访问该设备;解除后将取消访问权限。',
style: TextStyle(
color: _secondaryInk,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 10),
const Padding(
padding: EdgeInsets.only(left: 2),
child: Text(
'我的设备',
style: TextStyle(
color: _secondaryInk,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 10),
Expanded(child: _buildDeviceList()),
],
),
),
),
],
),
),
);
}
Widget _buildDeviceList() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: _secondaryInk),
);
}
if (_error != null && _devices.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _panelColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _accentBorder),
),
child: Text(
_error!,
style: const TextStyle(
color: _secondaryInk,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
);
}
if (_devices.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _panelColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _accentBorder),
),
child: const Text(
'暂无设备,请先返回首页添加设备',
style: TextStyle(
color: _secondaryInk,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
);
}
return ListView.separated(
itemCount: _devices.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (BuildContext context, int index) {
final Primary device = _devices[index];
final String name = (device.deviceName?.trim().isNotEmpty ?? false)
? device.deviceName!.trim()
: (device.deviceId?.trim().isNotEmpty ?? false)
? device.deviceId!.trim()
: '未命名设备';
final String id = device.deviceId?.trim() ?? '';
final String normalizedId = _normalizeDeviceId(id);
final bool isAuthorized =
normalizedId.isNotEmpty &&
_authorizedDeviceIds.contains(normalizedId);
final bool isAuthorizing = _authorizingDeviceIds.contains(normalizedId);
final bool isRevoking = _revokingDeviceIds.contains(normalizedId);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: _panelColor,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _accentBorder),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: _primaryInk,
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
id.isNotEmpty ? id : '设备 ID 为空',
style: const TextStyle(
color: _secondaryInk,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAuthorized && !isRevoking) ...[
const Text(
'已授权',
style: TextStyle(
color: Color(0xFF2F7D4A),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 8),
],
OutlinedButton(
onPressed:
(normalizedId.isEmpty || isAuthorizing || isRevoking)
? null
: () {
if (isAuthorized) {
_revokeDevice(id, name);
return;
}
_authorizeDevice(id);
},
style: OutlinedButton.styleFrom(
minimumSize: const Size(72, 34),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
side: const BorderSide(color: _secondaryInk, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
foregroundColor: _secondaryInk,
backgroundColor: Colors.white,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
child: Text(
isAuthorizing
? '授权中...'
: (isRevoking
? '解除中...'
: (isAuthorized ? '去解除' : '去授权')),
),
),
],
),
],
),
);
},
);
}
}
class ExampleProfilePage extends StatelessWidget {
const ExampleProfilePage({super.key, required this.accessToken});
final String accessToken;
Future<void> _logout(BuildContext context) async {
await ExampleStore.instance.clearLogin();
await TgClawKit.initialize(
environment: ExampleEnvironment.fromIndex(
ExampleStore.instance.apiEnv,
).toPluginEnvironment(),
authAppId: ExampleStore.instance.appId,
authPackageName: ExampleStore.instance.packageName,
);
if (!context.mounted) {
return;
}
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(Route<dynamic> route) => false,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'当前环境: ${ExampleEnvironment.fromIndex(ExampleStore.instance.apiEnv).label}',
),
const SizedBox(height: 8),
Text('App ID: ${ExampleStore.instance.appId}'),
const SizedBox(height: 8),
Text('Package: ${ExampleStore.instance.packageName}'),
const SizedBox(height: 8),
Text('AccessToken: ${accessToken.isNotEmpty ? '已登录' : '未登录'}'),
const SizedBox(height: 24),
FilledButton(
onPressed: () => _logout(context),
child: const Text('退出登录'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsPage()));
},
child: const Text('环境配置'),
),
],
),
),
);
}
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _appIdController;
late final TextEditingController _packageNameController;
late int _apiEnv;
@override
void initState() {
super.initState();
_appIdController = TextEditingController(text: ExampleStore.instance.appId);
_packageNameController = TextEditingController(
text: ExampleStore.instance.packageName,
);
_apiEnv = ExampleStore.instance.apiEnv;
}
@override
void dispose() {
_appIdController.dispose();
_packageNameController.dispose();
super.dispose();
}
Future<void> _save() async {
await ExampleStore.instance.saveConfig(
appId: _appIdController.text.trim(),
packageName: _packageNameController.text.trim(),
apiEnv: _apiEnv,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('配置已保存,重启示例页后生效')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('环境配置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _appIdController,
decoration: const InputDecoration(
labelText: 'App ID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _packageNameController,
decoration: const InputDecoration(
labelText: 'Package Name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _apiEnv,
decoration: const InputDecoration(
labelText: '环境',
border: OutlineInputBorder(),
),
items: ExampleEnvironment.values
.map(
(ExampleEnvironment environment) => DropdownMenuItem<int>(
value: environment.index,
child: Text(environment.label),
),
)
.toList(growable: false),
onChanged: (int? value) {
if (value == null) {
return;
}
setState(() {
_apiEnv = value;
});
},
),
const SizedBox(height: 16),
FilledButton(onPressed: _save, child: const Text('保存配置')),
],
),
);
}
}
class ExampleStore {
ExampleStore._();
static final ExampleStore instance = ExampleStore._();
static const String _keyAppId = 'tg_claw_example_app_id';
static const String _keyPackageName = 'tg_claw_example_package_name';
static const String _keyApiEnv = 'tg_claw_example_api_env';
static const String _keyLoginAccount = 'tg_claw_example_login_account';
static const String _keyLoginToken = 'tg_claw_example_login_token';
late SharedPreferences _preferences;
String appId = '5920020';
String packageName = 'com.tange365.icam365';
int apiEnv = 0;
String savedAccount = '';
String accessToken = '';
Future<void> init() async {
_preferences = await SharedPreferences.getInstance();
appId = _preferences.getString(_keyAppId) ?? appId;
packageName = _preferences.getString(_keyPackageName) ?? packageName;
apiEnv = _preferences.getInt(_keyApiEnv) ?? apiEnv;
savedAccount = _preferences.getString(_keyLoginAccount) ?? '';
accessToken = _preferences.getString(_keyLoginToken) ?? '';
}
Future<void> saveConfig({
required String appId,
required String packageName,
required int apiEnv,
}) async {
this.appId = appId;
this.packageName = packageName;
this.apiEnv = apiEnv;
await _preferences.setString(_keyAppId, appId);
await _preferences.setString(_keyPackageName, packageName);
await _preferences.setInt(_keyApiEnv, apiEnv);
}
Future<void> saveLogin({
required String account,
required String accessToken,
}) async {
savedAccount = account;
this.accessToken = accessToken;
await _preferences.setString(_keyLoginAccount, account);
await _preferences.setString(_keyLoginToken, accessToken);
}
Future<void> clearLogin() async {
savedAccount = '';
accessToken = '';
await _preferences.remove(_keyLoginAccount);
await _preferences.remove(_keyLoginToken);
}
}
enum ExampleEnvironment {
production('正式'),
preRelease('预发'),
test('测试');
const ExampleEnvironment(this.label);
final String label;
static ExampleEnvironment fromIndex(int index) {
if (index < 0 || index >= values.length) {
return ExampleEnvironment.production;
}
return values[index];
}
TgClawEnvironment toPluginEnvironment() {
switch (this) {
case ExampleEnvironment.production:
return TgClawEnvironment.production;
case ExampleEnvironment.preRelease:
return TgClawEnvironment.preRelease;
case ExampleEnvironment.test:
return TgClawEnvironment.test;
}
}
Environment toRapidEnvironment() {
switch (this) {
case ExampleEnvironment.production:
return Environment.release;
case ExampleEnvironment.preRelease:
return Environment.pre;
case ExampleEnvironment.test:
return Environment.test;
}
}
}
class ExampleChatMessage {
const ExampleChatMessage({
required this.id,
required this.role,
required this.text,
required this.streaming,
required this.media,
this.toolName,
this.toolArguments,
this.toolOutput,
});
final String id;
final TgClawMessageRole role;
final String text;
final bool streaming;
final String? toolName;
final String? toolArguments;
final String? toolOutput;
final List<ExampleChatMedia> media;
bool get isUser => role == TgClawMessageRole.user;
String get title {
switch (role) {
case TgClawMessageRole.reasoning:
return '思考过程';
case TgClawMessageRole.toolCall:
return '工具调用';
case TgClawMessageRole.toolOutput:
return '工具结果';
case TgClawMessageRole.system:
return '系统消息';
case TgClawMessageRole.assistant:
case TgClawMessageRole.user:
return '';
}
}
factory ExampleChatMessage.user(String text) {
return ExampleChatMessage(
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
role: TgClawMessageRole.user,
text: text,
streaming: false,
media: const <ExampleChatMedia>[],
);
}
factory ExampleChatMessage.system(String text) {
return ExampleChatMessage(
id: 'system_${DateTime.now().millisecondsSinceEpoch}',
role: TgClawMessageRole.system,
text: text,
streaming: false,
media: const <ExampleChatMedia>[],
);
}
factory ExampleChatMessage.fromDomain(TgClawMessage message) {
return ExampleChatMessage(
id: message.id,
role: message.role,
text: message.text,
streaming: message.streaming,
toolName: message.toolName,
toolArguments: message.toolArguments,
toolOutput: message.toolOutput,
media: message.media
.map((TgClawMedia media) => ExampleChatMedia.fromDomain(media))
.toList(growable: false),
);
}
ExampleChatMessage copyWith({
bool? streaming,
String? text,
String? toolName,
String? toolArguments,
String? toolOutput,
List<ExampleChatMedia>? media,
}) {
return ExampleChatMessage(
id: id,
role: role,
text: text ?? this.text,
streaming: streaming ?? this.streaming,
toolName: toolName ?? this.toolName,
toolArguments: toolArguments ?? this.toolArguments,
toolOutput: toolOutput ?? this.toolOutput,
media: media ?? this.media,
);
}
}
class ExampleChatMedia {
const ExampleChatMedia({
required this.type,
required this.uri,
required this.mimeType,
this.filename,
});
final TgClawMediaType type;
final String uri;
final String mimeType;
final String? filename;
factory ExampleChatMedia.fromDomain(TgClawMedia media) {
return ExampleChatMedia(
type: media.type,
uri: media.uri,
mimeType: media.mimeType,
filename: media.filename,
);
}
}
class _ImagePreviewPage extends StatelessWidget {
const _ImagePreviewPage({
required this.title,
required this.bytes,
required this.onDownload,
});
final String title;
final typed_data.Uint8List bytes;
final VoidCallback onDownload;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(title),
actions: [
IconButton(
onPressed: onDownload,
icon: const Icon(Icons.download_rounded),
),
],
),
body: Center(
child: InteractiveViewer(
minScale: 0.8,
maxScale: 4,
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
);
}
}