🎨 Continued to rearrange core folders content

This commit is contained in:
2026-02-06 00:57:17 +08:00
parent 862e3b451b
commit dfcbfcb31e
154 changed files with 259 additions and 269 deletions

View File

@@ -0,0 +1,216 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
class FunctionCallsSection extends HookWidget {
const FunctionCallsSection({
super.key,
required this.isFinish,
required this.isStreaming,
this.callData,
this.resultData,
});
final bool isFinish;
final bool isStreaming;
final String? callData;
final String? resultData;
@override
Widget build(BuildContext context) {
String functionCallName;
if (callData != null) {
final parsed = jsonDecode(callData!) as Map;
functionCallName = (parsed['name'] as String?) ?? 'unknown'.tr();
} else {
functionCallName = 'unknown'.tr();
}
if (functionCallName.isEmpty) functionCallName = 'unknown'.tr();
final showSpinner = isStreaming && !isFinish;
final isExpanded = useState(false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
minTileHeight: 24,
backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
collapsedBackgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
trailing: SizedBox(
width: 30, // Specify desired width
height: 30, // Specify desired height
child: Icon(
isExpanded.value
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
size: 16,
color:
isExpanded.value
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.tertiaryFixedDim,
),
),
showTrailingIcon: !showSpinner,
title: Row(
spacing: 8,
children: [
Icon(
Symbols.hardware,
size: 16,
color: Theme.of(context).colorScheme.tertiary,
),
Expanded(
child: Text(
'thoughtFunctionCall'.tr(args: [functionCallName]),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.tertiary,
),
),
),
if (showSpinner) ...[
AnimateWidgetExtensions(
Text(
'Calling',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
)
.animate(
autoPlay: true,
onPlay: (c) => c.repeat(reverse: true),
)
.fade(duration: 1000.ms, begin: 0, end: 1),
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
padding: EdgeInsets.all(3),
),
).padding(right: 8),
],
],
),
childrenPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
children: [
if (callData != null)
_buildBlock(context, false, functionCallName, callData!),
if (resultData != null) ...[
if (callData != null && resultData != null) const Gap(8),
_buildBlock(context, true, functionCallName, resultData!),
],
],
),
],
);
}
Widget _buildBlock(
BuildContext context,
bool isResult,
String name,
String data,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
spacing: 8,
children: [
Icon(
isResult ? Symbols.check : Symbols.play_arrow_rounded,
size: 16,
fill: 1,
),
Text(
isResult
? "thoughtFunctionCallFinish".tr(args: [name])
: "thoughtFunctionCallBegin".tr(args: [name]),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
const Gap(4),
if (isResult)
Opacity(
opacity: 0.8,
child: Row(
spacing: 8,
children: [
Icon(Symbols.update, size: 16),
Expanded(
child: Text(
'Generated ${utf8.encode(data).length} bytes',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
SizedBox(
height: 16,
child: IconButton(
iconSize: 16,
icon: const Icon(Symbols.content_copy),
onPressed:
() => Clipboard.setData(ClipboardData(text: data)),
tooltip: 'Copy response',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
),
],
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: SelectableText(
data,
style: GoogleFonts.robotoMono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
height: 1.3,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class ProposalsSection extends StatelessWidget {
const ProposalsSection({
super.key,
required this.proposals,
required this.onProposalAction,
});
final List<Map<String, String>> proposals;
final void Function(BuildContext, Map<String, String>) onProposalAction;
@override
Widget build(BuildContext context) {
if (proposals.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children:
proposals.map((proposal) {
return ElevatedButton.icon(
onPressed: () => onProposalAction(context, proposal),
icon: Icon(switch (proposal['type']) {
'post_create' => Symbols.add,
_ => Symbols.lightbulb,
}, size: 16),
label: Text(switch (proposal['type']) {
'post_create' => 'Create Post',
_ => proposal['type'] ?? 'Action',
}),
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
);
}).toList(),
),
],
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class ReasoningSection extends StatelessWidget {
const ReasoningSection({super.key, required this.reasoningChunks});
final List<String> reasoningChunks;
@override
Widget build(BuildContext context) {
if (reasoningChunks.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.psychology,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const Gap(4),
Text(
'reasoning'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(4),
...reasoningChunks.map(
(chunk) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
chunk,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
height: 1.3,
),
),
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:island/thoughts/thought.dart';
import 'package:island/drive/content/markdown.dart';
import 'package:island/thoughts/thought_widgets/thought/thought_proposal.dart';
class ThoughtContent extends StatelessWidget {
const ThoughtContent({
super.key,
required this.isStreaming,
required this.streamingText,
this.thought,
});
final bool isStreaming;
final String streamingText;
final SnThinkingThought? thought;
bool get _isErrorMessage {
if (thought == null) return false;
// Check if this is an error thought by ID or content
if (thought!.id.startsWith('error-')) return true;
final textParts = thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join('');
return textParts.startsWith('Error:');
}
@override
Widget build(BuildContext context) {
final content = streamingText.isNotEmpty
? streamingText
: thought != null
? thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join('')
: '';
if (content.isEmpty) return const SizedBox.shrink();
final isError = content.startsWith('Error:') || _isErrorMessage;
return Container(
padding: isError ? const EdgeInsets.all(8) : EdgeInsets.zero,
decoration: isError
? BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
borderRadius: BorderRadius.circular(8),
)
: null,
child: MarkdownTextContent(
isSelectable: true,
content: content,
extraBlockSyntaxList: [ProposalBlockSyntax()],
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: isError ? Theme.of(context).colorScheme.error : null,
),
extraGenerators: [
ProposalGenerator(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
borderColor: Theme.of(context).colorScheme.outline,
),
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class ThoughtHeader extends StatelessWidget {
const ThoughtHeader({
super.key,
required this.isStreaming,
required this.isUser,
});
final bool isStreaming;
final bool isUser;
@override
Widget build(BuildContext context) {
if (!isStreaming) {
return Row(
spacing: 6,
children: [
Icon(
isUser ? Symbols.person : Symbols.smart_toy,
size: 16,
color:
isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
fill: 1,
),
Text(
isUser ? 'thoughtUserName'.tr() : 'thoughtAiName'.tr(),
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w600,
color:
isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
),
),
],
);
} else {
return Row(
spacing: 6,
children: [
Icon(
Symbols.smart_toy,
size: 16,
color:
isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
fill: 1,
),
Text(
'thoughtAiName'.tr(),
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w600,
color:
isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
),
),
],
);
}
}
}

View File

@@ -0,0 +1,137 @@
import "package:flutter/material.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:styled_widget/styled_widget.dart";
import "package:markdown/markdown.dart" as markdown;
import "package:markdown_widget/markdown_widget.dart";
class ProposalBlockSyntax extends markdown.BlockSyntax {
@override
RegExp get pattern => RegExp(r'^<proposal', caseSensitive: false);
@override
bool canParse(markdown.BlockParser parser) {
return pattern.hasMatch(parser.current.content);
}
@override
bool canEndBlock(markdown.BlockParser parser) {
return parser.current.content.contains('</proposal>');
}
@override
markdown.Node parse(markdown.BlockParser parser) {
final childLines = <String>[];
// Extract type from opening tag
final openingLine = parser.current.content;
final attrsMatch = RegExp(
r'<proposal(\s[^>]*)?>',
caseSensitive: false,
).firstMatch(openingLine);
final attrs = attrsMatch?.group(1) ?? '';
final typeMatch = RegExp(r'type="([^"]*)"').firstMatch(attrs);
final type = typeMatch?.group(1) ?? '';
// Collect all lines until closing tag
while (!parser.isDone) {
childLines.add(parser.current.content);
if (canEndBlock(parser)) {
parser.advance();
break;
}
parser.advance();
}
// Extract content between tags
final fullContent = childLines.join('\n');
final contentMatch = RegExp(
r'<proposal[^>]*>(.*?)</proposal>',
dotAll: true,
caseSensitive: false,
).firstMatch(fullContent);
final content = contentMatch?.group(1)?.trim() ?? '';
final element = markdown.Element('proposal', [markdown.Text(content)])
..attributes['type'] = type;
return element;
}
}
class ProposalGenerator extends SpanNodeGeneratorWithTag {
ProposalGenerator({
required Color backgroundColor,
required Color foregroundColor,
required Color borderColor,
}) : super(
tag: 'proposal',
generator: (
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return ProposalSpanNode(
text: element.textContent,
type: element.attributes['type'] ?? '',
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
borderColor: borderColor,
);
},
);
}
class ProposalSpanNode extends SpanNode {
final String text;
final String type;
final Color backgroundColor;
final Color foregroundColor;
final Color borderColor;
ProposalSpanNode({
required this.text,
required this.type,
required this.backgroundColor,
required this.foregroundColor,
required this.borderColor,
});
@override
InlineSpan build() {
return WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Row(
spacing: 6,
children: [
Icon(Symbols.lightbulb, size: 16, color: foregroundColor),
Text(
'SN-chan suggest you to ${type.split('_').reversed.join(' ')}',
).fontSize(13).opacity(0.8),
],
).padding(top: 3, bottom: 4),
Flexible(
child: Text(
text,
style: TextStyle(
color: foregroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/thoughts/thought.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/time.dart';
import 'package:island/drive/content/sheet_scaffold.dart';
import 'package:island/shared/widgets/pagination_list.dart';
final thoughtSequenceListNotifierProvider = AsyncNotifierProvider.autoDispose(
ThoughtSequenceListNotifier.new,
);
class ThoughtSequenceListNotifier
extends AsyncNotifier<PaginationState<SnThinkingSequence>>
with AsyncPaginationController<SnThinkingSequence> {
static const int pageSize = 20;
@override
Future<List<SnThinkingSequence>> fetch() async {
final client = ref.read(apiClientProvider);
final queryParams = {'offset': fetchedCount, 'take': pageSize};
final response = await client.get(
'/insight/thought/sequences',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
return data.map((json) => SnThinkingSequence.fromJson(json)).toList();
}
}
class ThoughtSequenceSelector extends HookConsumerWidget {
final Function(String) onSequenceSelected;
const ThoughtSequenceSelector({super.key, required this.onSequenceSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = thoughtSequenceListNotifierProvider;
return SheetScaffold(
titleText: 'Select Conversation',
child: PaginationList(
provider: provider,
notifier: provider.notifier,
itemBuilder: (context, index, sequence) {
return ListTile(
title: Text(sequence.topic ?? 'Untitled Conversation'),
subtitle: Text(sequence.createdAt.formatSystem()),
onTap: () {
onSequenceSelected(sequence.id);
Navigator.of(context).pop();
},
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:island/thoughts/thought.dart';
class TokenInfo extends StatelessWidget {
const TokenInfo({super.key, required this.thought});
final SnThinkingThought thought;
@override
Widget build(BuildContext context) {
if (thought.tokenCount == null && thought.modelName == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (thought.modelName != null) ...[
const Icon(Symbols.neurology, size: 16),
const Gap(4),
Text(
'${thought.modelName}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Gap(16),
],
if (thought.tokenCount != null) ...[
const Icon(Symbols.token, size: 16),
const Gap(4),
Text(
'${thought.tokenCount} tokens',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
],
);
}
}