🎨 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

177
lib/thoughts/think.dart Normal file
View File

@@ -0,0 +1,177 @@
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:gap/gap.dart";
import "package:island/thoughts/thought_widgets/thought/thought_sequence_list.dart";
import "package:island/thoughts/thought_widgets/thought/thought_shared.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/thoughts/thought.dart";
import "package:island/core/network.dart";
import "package:island/shared/widgets/alert.dart";
import "package:island/shared/widgets/app_scaffold.dart";
import "package:island/shared/widgets/response.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
part 'think.g.dart';
@riverpod
Future<bool> thoughtAvailableStaus(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/billing/status');
return response.data['status'] == 'ok';
}
@riverpod
Future<List<SnThinkingThought>> thoughtSequence(
Ref ref,
String sequenceId,
) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get(
'/insight/thought/sequences/$sequenceId',
);
return (response.data as List)
.map((e) => SnThinkingThought.fromJson(e))
.toList();
}
@riverpod
Future<ThoughtServicesResponse> thoughtServices(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/thought/services');
return ThoughtServicesResponse.fromJson(response.data);
}
class ThoughtScreen extends HookConsumerWidget {
const ThoughtScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedSequenceId = useState<String?>(null);
final thoughts = selectedSequenceId.value != null
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
: const AsyncValue<List<SnThinkingThought>>.data([]);
// Extract sequence ID from loaded thoughts for the chat interface
final sequenceIdFromThoughts = thoughts.maybeWhen(
data: (thoughts) {
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
return thoughts.first.sequenceId;
}
return null;
},
orElse: () => null,
);
// Get initial thoughts and topic from provider
final initialThoughts = thoughts.value;
final initialTopic =
(initialThoughts?.isNotEmpty ?? false) &&
initialThoughts!.first.sequence?.topic != null
? initialThoughts.first.sequence!.topic
: 'aiThought'.tr();
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(initialTopic ?? 'aiThought'.tr()),
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.history),
onPressed: () {
// Show sequence selector
showModalBottomSheet(
context: context,
builder: (context) => ThoughtSequenceSelector(
onSequenceSelected: (sequenceId) {
selectedSequenceId.value = sequenceId;
},
),
);
},
),
const Gap(8),
],
),
body: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
await ref
.read(apiClientProvider)
.post('/insight/billing/retry');
showSnackBar('Retried billing process');
ref.invalidate(thoughtAvailableStausProvider);
} catch (e) {
showSnackBar('Failed to retry billing');
}
if (context.mounted) hideLoadingModal(context);
},
[context, ref],
);
final thoughtsBody = thoughts.when(
data: (thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialSequenceId: sequenceIdFromThoughts,
initialTopic: initialTopic,
isDisabled: !status,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(selectedSequenceId.value!),
)
: null,
),
);
return status
? thoughtsBody
: Column(
children: [
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: thoughtsBody),
],
);
},
orElse: () => thoughts.when(
data: (thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialTopic: initialTopic,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(selectedSequenceId.value!),
)
: null,
),
),
),
);
}
}

162
lib/thoughts/think.g.dart Normal file
View File

@@ -0,0 +1,162 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'think.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(thoughtAvailableStaus)
final thoughtAvailableStausProvider = ThoughtAvailableStausProvider._();
final class ThoughtAvailableStausProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
ThoughtAvailableStausProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'thoughtAvailableStausProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$thoughtAvailableStausHash();
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
return thoughtAvailableStaus(ref);
}
}
String _$thoughtAvailableStausHash() =>
r'720e04e56bff8c4d4ca6854ce997da4e7926c84c';
@ProviderFor(thoughtSequence)
final thoughtSequenceProvider = ThoughtSequenceFamily._();
final class ThoughtSequenceProvider
extends
$FunctionalProvider<
AsyncValue<List<SnThinkingThought>>,
List<SnThinkingThought>,
FutureOr<List<SnThinkingThought>>
>
with
$FutureModifier<List<SnThinkingThought>>,
$FutureProvider<List<SnThinkingThought>> {
ThoughtSequenceProvider._({
required ThoughtSequenceFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'thoughtSequenceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$thoughtSequenceHash();
@override
String toString() {
return r'thoughtSequenceProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<SnThinkingThought>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnThinkingThought>> create(Ref ref) {
final argument = this.argument as String;
return thoughtSequence(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ThoughtSequenceProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7';
final class ThoughtSequenceFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<SnThinkingThought>>, String> {
ThoughtSequenceFamily._()
: super(
retry: null,
name: r'thoughtSequenceProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ThoughtSequenceProvider call(String sequenceId) =>
ThoughtSequenceProvider._(argument: sequenceId, from: this);
@override
String toString() => r'thoughtSequenceProvider';
}
@ProviderFor(thoughtServices)
final thoughtServicesProvider = ThoughtServicesProvider._();
final class ThoughtServicesProvider
extends
$FunctionalProvider<
AsyncValue<ThoughtServicesResponse>,
ThoughtServicesResponse,
FutureOr<ThoughtServicesResponse>
>
with
$FutureModifier<ThoughtServicesResponse>,
$FutureProvider<ThoughtServicesResponse> {
ThoughtServicesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'thoughtServicesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$thoughtServicesHash();
@$internal
@override
$FutureProviderElement<ThoughtServicesResponse> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<ThoughtServicesResponse> create(Ref ref) {
return thoughtServices(ref);
}
}
String _$thoughtServicesHash() => r'0ddeaec713ecfcdc9786c197f3d4cb41d36c26a5';

View File

@@ -0,0 +1,109 @@
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/core/network.dart";
import "package:island/shared/widgets/alert.dart";
import "package:island/drive/content/sheet_scaffold.dart";
import "package:island/thoughts/think.dart";
import "package:island/thoughts/thought_widgets/thought/thought_shared.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
class ThoughtSheet extends HookConsumerWidget {
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
const ThoughtSheet({
super.key,
this.initialMessage,
this.attachedMessages = const [],
this.attachedPosts = const [],
});
static Future<void> show(
BuildContext context, {
String? initialMessage,
List<Map<String, dynamic>> attachedMessages = const [],
List<String> attachedPosts = const [],
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => ThoughtSheet(
initialMessage: initialMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatState = useThoughtChat(
ref,
initialMessage: initialMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return SheetScaffold(
titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
child: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
await ref
.read(apiClientProvider)
.post('/insight/billing/retry');
showSnackBar('Retried billing process');
ref.invalidate(thoughtAvailableStausProvider);
} catch (e) {
showSnackBar('Failed to retry billing');
}
if (context.mounted) hideLoadingModal(context);
},
[context, ref],
);
final chatInterface = ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
isDisabled: !status,
);
return status
? chatInterface
: Column(
children: [
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: chatInterface),
],
);
},
orElse: () => ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
),
);
}
}

201
lib/thoughts/thought.dart Normal file
View File

@@ -0,0 +1,201 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/drive/drive_models/file.dart';
part 'thought.freezed.dart';
part 'thought.g.dart';
enum ThinkingThoughtRole {
assistant(0),
user(1);
const ThinkingThoughtRole(this.value);
final int value;
static ThinkingThoughtRole fromValue(int value) {
return values.firstWhere((e) => e.value == value);
}
}
class ThinkingThoughtRoleConverter
implements JsonConverter<ThinkingThoughtRole, int> {
const ThinkingThoughtRoleConverter();
@override
ThinkingThoughtRole fromJson(int json) => ThinkingThoughtRole.fromValue(json);
@override
int toJson(ThinkingThoughtRole object) => object.value;
}
class ThinkingChunkTypeConverter
implements JsonConverter<ThinkingChunkType, int> {
const ThinkingChunkTypeConverter();
@override
ThinkingChunkType fromJson(int json) => ThinkingChunkType.fromValue(json);
@override
int toJson(ThinkingChunkType object) => object.value;
}
enum ThinkingMessagePartType {
text(0),
functionCall(1),
functionResult(2);
const ThinkingMessagePartType(this.value);
final int value;
static ThinkingMessagePartType fromValue(int value) {
return values.firstWhere((e) => e.value == value, orElse: () => text);
}
}
class ThinkingMessagePartTypeConverter
implements JsonConverter<ThinkingMessagePartType, int> {
const ThinkingMessagePartTypeConverter();
@override
ThinkingMessagePartType fromJson(int json) =>
ThinkingMessagePartType.fromValue(json);
@override
int toJson(ThinkingMessagePartType object) => object.value;
}
@freezed
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
const factory StreamThinkingRequest({
required String userMessage,
String? sequenceId,
@Default([]) List<String> accpetProposals,
List<String>? attachedPosts,
List<Map<String, dynamic>>? attachedMessages,
@JsonKey(name: 'service_id') String? serviceId,
}) = _StreamThinkingRequest;
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
_$StreamThinkingRequestFromJson(json);
}
enum ThinkingChunkType {
text(0),
reasoning(1),
functionCall(2),
unknown(3);
const ThinkingChunkType(this.value);
final int value;
static ThinkingChunkType fromValue(int value) {
return values.firstWhere((e) => e.value == value);
}
}
@freezed
sealed class SnThinkingChunk with _$SnThinkingChunk {
const factory SnThinkingChunk({
@ThinkingChunkTypeConverter() required ThinkingChunkType type,
Map<String, dynamic>? data,
}) = _SnThinkingChunk;
factory SnThinkingChunk.fromJson(Map<String, dynamic> json) =>
_$SnThinkingChunkFromJson(json);
}
@freezed
sealed class SnFunctionCall with _$SnFunctionCall {
const factory SnFunctionCall({
required String id,
required String name,
required String arguments,
}) = _SnFunctionCall;
factory SnFunctionCall.fromJson(Map<String, dynamic> json) =>
_$SnFunctionCallFromJson(json);
}
@freezed
sealed class SnFunctionResult with _$SnFunctionResult {
const factory SnFunctionResult({
required String callId,
required dynamic result,
required bool isError,
}) = _SnFunctionResult;
factory SnFunctionResult.fromJson(Map<String, dynamic> json) =>
_$SnFunctionResultFromJson(json);
}
@freezed
sealed class SnThinkingMessagePart with _$SnThinkingMessagePart {
const factory SnThinkingMessagePart({
@ThinkingMessagePartTypeConverter() required ThinkingMessagePartType type,
String? text,
SnFunctionCall? functionCall,
SnFunctionResult? functionResult,
}) = _SnThinkingMessagePart;
factory SnThinkingMessagePart.fromJson(Map<String, dynamic> json) =>
_$SnThinkingMessagePartFromJson(json);
}
@freezed
sealed class SnThinkingSequence with _$SnThinkingSequence {
const factory SnThinkingSequence({
required String id,
String? topic,
@Default(0) int totalToken,
@Default(0) int paidToken,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
}) = _SnThinkingSequence;
factory SnThinkingSequence.fromJson(Map<String, dynamic> json) =>
_$SnThinkingSequenceFromJson(json);
}
@freezed
sealed class SnThinkingThought with _$SnThinkingThought {
const factory SnThinkingThought({
required String id,
@Default([]) List<SnThinkingMessagePart> parts,
@Default([]) List<SnCloudFile> files,
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
int? tokenCount,
String? modelName,
required String sequenceId,
SnThinkingSequence? sequence,
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
}) = _SnThinkingThought;
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
_$SnThinkingThoughtFromJson(json);
}
@freezed
sealed class ThoughtService with _$ThoughtService {
const factory ThoughtService({
@JsonKey(name: 'service_id') required String serviceId,
required double billingMultiplier,
required int perkLevel,
}) = _ThoughtService;
factory ThoughtService.fromJson(Map<String, dynamic> json) =>
_$ThoughtServiceFromJson(json);
}
@freezed
sealed class ThoughtServicesResponse with _$ThoughtServicesResponse {
const factory ThoughtServicesResponse({
@JsonKey(name: 'default_service') required String defaultService,
required List<ThoughtService> services,
}) = _ThoughtServicesResponse;
factory ThoughtServicesResponse.fromJson(Map<String, dynamic> json) =>
_$ThoughtServicesResponseFromJson(json);
}

File diff suppressed because it is too large Load Diff

210
lib/thoughts/thought.g.dart Normal file
View File

@@ -0,0 +1,210 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'thought.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_StreamThinkingRequest _$StreamThinkingRequestFromJson(
Map<String, dynamic> json,
) => _StreamThinkingRequest(
userMessage: json['user_message'] as String,
sequenceId: json['sequence_id'] as String?,
accpetProposals:
(json['accpet_proposals'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachedPosts: (json['attached_posts'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
attachedMessages: (json['attached_messages'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList(),
serviceId: json['service_id'] as String?,
);
Map<String, dynamic> _$StreamThinkingRequestToJson(
_StreamThinkingRequest instance,
) => <String, dynamic>{
'user_message': instance.userMessage,
'sequence_id': instance.sequenceId,
'accpet_proposals': instance.accpetProposals,
'attached_posts': instance.attachedPosts,
'attached_messages': instance.attachedMessages,
'service_id': instance.serviceId,
};
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
_SnThinkingChunk(
type: const ThinkingChunkTypeConverter().fromJson(
(json['type'] as num).toInt(),
),
data: json['data'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
<String, dynamic>{
'type': const ThinkingChunkTypeConverter().toJson(instance.type),
'data': instance.data,
};
_SnFunctionCall _$SnFunctionCallFromJson(Map<String, dynamic> json) =>
_SnFunctionCall(
id: json['id'] as String,
name: json['name'] as String,
arguments: json['arguments'] as String,
);
Map<String, dynamic> _$SnFunctionCallToJson(_SnFunctionCall instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'arguments': instance.arguments,
};
_SnFunctionResult _$SnFunctionResultFromJson(Map<String, dynamic> json) =>
_SnFunctionResult(
callId: json['call_id'] as String,
result: json['result'],
isError: json['is_error'] as bool,
);
Map<String, dynamic> _$SnFunctionResultToJson(_SnFunctionResult instance) =>
<String, dynamic>{
'call_id': instance.callId,
'result': instance.result,
'is_error': instance.isError,
};
_SnThinkingMessagePart _$SnThinkingMessagePartFromJson(
Map<String, dynamic> json,
) => _SnThinkingMessagePart(
type: const ThinkingMessagePartTypeConverter().fromJson(
(json['type'] as num).toInt(),
),
text: json['text'] as String?,
functionCall: json['function_call'] == null
? null
: SnFunctionCall.fromJson(json['function_call'] as Map<String, dynamic>),
functionResult: json['function_result'] == null
? null
: SnFunctionResult.fromJson(
json['function_result'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SnThinkingMessagePartToJson(
_SnThinkingMessagePart instance,
) => <String, dynamic>{
'type': const ThinkingMessagePartTypeConverter().toJson(instance.type),
'text': instance.text,
'function_call': instance.functionCall?.toJson(),
'function_result': instance.functionResult?.toJson(),
};
_SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
_SnThinkingSequence(
id: json['id'] as String,
topic: json['topic'] as String?,
totalToken: (json['total_token'] as num?)?.toInt() ?? 0,
paidToken: (json['paid_token'] as num?)?.toInt() ?? 0,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
<String, dynamic>{
'id': instance.id,
'topic': instance.topic,
'total_token': instance.totalToken,
'paid_token': instance.paidToken,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
_SnThinkingThought(
id: json['id'] as String,
parts:
(json['parts'] as List<dynamic>?)
?.map(
(e) =>
SnThinkingMessagePart.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
files:
(json['files'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
role: const ThinkingThoughtRoleConverter().fromJson(
(json['role'] as num).toInt(),
),
tokenCount: (json['token_count'] as num?)?.toInt(),
modelName: json['model_name'] as String?,
sequenceId: json['sequence_id'] as String,
sequence: json['sequence'] == null
? null
: SnThinkingSequence.fromJson(
json['sequence'] as Map<String, dynamic>,
),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
<String, dynamic>{
'id': instance.id,
'parts': instance.parts.map((e) => e.toJson()).toList(),
'files': instance.files.map((e) => e.toJson()).toList(),
'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
'token_count': instance.tokenCount,
'model_name': instance.modelName,
'sequence_id': instance.sequenceId,
'sequence': instance.sequence?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_ThoughtService _$ThoughtServiceFromJson(Map<String, dynamic> json) =>
_ThoughtService(
serviceId: json['service_id'] as String,
billingMultiplier: (json['billing_multiplier'] as num).toDouble(),
perkLevel: (json['perk_level'] as num).toInt(),
);
Map<String, dynamic> _$ThoughtServiceToJson(_ThoughtService instance) =>
<String, dynamic>{
'service_id': instance.serviceId,
'billing_multiplier': instance.billingMultiplier,
'perk_level': instance.perkLevel,
};
_ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
Map<String, dynamic> json,
) => _ThoughtServicesResponse(
defaultService: json['default_service'] as String,
services: (json['services'] as List<dynamic>)
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$ThoughtServicesResponseToJson(
_ThoughtServicesResponse instance,
) => <String, dynamic>{
'default_service': instance.defaultService,
'services': instance.services.map((e) => e.toJson()).toList(),
};

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,
),
),
],
],
),
],
);
}
}