feat(chat): Add prompt variables
This commit is contained in:
429
lib/core/utils/prompt_variable_parser.dart
Normal file
429
lib/core/utils/prompt_variable_parser.dart
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Represents a parsed prompt variable.
|
||||||
|
///
|
||||||
|
/// Variables can be either system variables (auto-resolved) or custom input
|
||||||
|
/// variables (require user input).
|
||||||
|
///
|
||||||
|
/// Syntax: `{{variable_name}}` or `{{variable_name | type:property=value}}`
|
||||||
|
class PromptVariable {
|
||||||
|
const PromptVariable({
|
||||||
|
required this.fullMatch,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.properties,
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The full matched string including `{{` and `}}`.
|
||||||
|
final String fullMatch;
|
||||||
|
|
||||||
|
/// The variable name (e.g., "description", "CURRENT_DATE").
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// The input type (e.g., "text", "textarea", "select", "number").
|
||||||
|
/// Null for simple variables without type specification.
|
||||||
|
final PromptVariableType type;
|
||||||
|
|
||||||
|
/// Additional properties like placeholder, default, required, options.
|
||||||
|
final Map<String, String> properties;
|
||||||
|
|
||||||
|
/// Start index of the match in the original string.
|
||||||
|
final int start;
|
||||||
|
|
||||||
|
/// End index of the match in the original string.
|
||||||
|
final int end;
|
||||||
|
|
||||||
|
/// Whether this variable requires user input.
|
||||||
|
bool get requiresUserInput => !isSystemVariable;
|
||||||
|
|
||||||
|
/// Whether this is a system variable that can be auto-resolved.
|
||||||
|
bool get isSystemVariable {
|
||||||
|
final upper = name.toUpperCase();
|
||||||
|
return _systemVariableNames.contains(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this field is marked as required.
|
||||||
|
bool get isRequired => properties['required'] == 'true';
|
||||||
|
|
||||||
|
/// Get the placeholder text, if specified.
|
||||||
|
String? get placeholder => properties['placeholder'];
|
||||||
|
|
||||||
|
/// Get the default value, if specified.
|
||||||
|
String? get defaultValue => properties['default'];
|
||||||
|
|
||||||
|
/// Get min value for number inputs.
|
||||||
|
double? get min {
|
||||||
|
final val = properties['min'];
|
||||||
|
return val != null ? double.tryParse(val) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get max value for number inputs.
|
||||||
|
double? get max {
|
||||||
|
final val = properties['max'];
|
||||||
|
return val != null ? double.tryParse(val) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get step value for number inputs.
|
||||||
|
double? get step {
|
||||||
|
final val = properties['step'];
|
||||||
|
return val != null ? double.tryParse(val) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get options for select inputs.
|
||||||
|
List<String> get options {
|
||||||
|
final optionsStr = properties['options'];
|
||||||
|
if (optionsStr == null) return const [];
|
||||||
|
// Parse JSON array format: ["Option1","Option2"]
|
||||||
|
final trimmed = optionsStr.trim();
|
||||||
|
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return const [];
|
||||||
|
final inner = trimmed.substring(1, trimmed.length - 1);
|
||||||
|
if (inner.isEmpty) return const [];
|
||||||
|
return inner
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.map((s) {
|
||||||
|
// Remove surrounding quotes
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) ||
|
||||||
|
(s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.substring(1, s.length - 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display label for the variable (formatted from name).
|
||||||
|
String get displayLabel {
|
||||||
|
// Convert snake_case or camelCase to Title Case
|
||||||
|
final words = name
|
||||||
|
.replaceAllMapped(
|
||||||
|
RegExp(r'([a-z])([A-Z])'),
|
||||||
|
(m) => '${m.group(1)} ${m.group(2)}',
|
||||||
|
)
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(
|
||||||
|
(w) => w.isNotEmpty
|
||||||
|
? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}'
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const Set<String> _systemVariableNames = {
|
||||||
|
'CLIPBOARD',
|
||||||
|
'CURRENT_DATE',
|
||||||
|
'CURRENT_DATETIME',
|
||||||
|
'CURRENT_TIME',
|
||||||
|
'CURRENT_TIMEZONE',
|
||||||
|
'CURRENT_WEEKDAY',
|
||||||
|
'USER_NAME',
|
||||||
|
'USER_LANGUAGE',
|
||||||
|
'USER_LOCATION',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types of prompt variable inputs.
|
||||||
|
enum PromptVariableType {
|
||||||
|
/// Simple text input (single line).
|
||||||
|
text,
|
||||||
|
|
||||||
|
/// Multi-line text input.
|
||||||
|
textarea,
|
||||||
|
|
||||||
|
/// Dropdown select.
|
||||||
|
select,
|
||||||
|
|
||||||
|
/// Number input.
|
||||||
|
number,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses prompt content to extract variables.
|
||||||
|
class PromptVariableParser {
|
||||||
|
const PromptVariableParser();
|
||||||
|
|
||||||
|
/// Regular expression to match prompt variables.
|
||||||
|
/// Matches: {{variable_name}} or {{variable_name | type:prop=value:prop2=value2}}
|
||||||
|
static final _variablePattern = RegExp(
|
||||||
|
r'\{\{([^{}|]+?)(?:\s*\|\s*([^{}]+?))?\}\}',
|
||||||
|
multiLine: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Parse all variables from prompt content.
|
||||||
|
List<PromptVariable> parse(String content) {
|
||||||
|
final variables = <PromptVariable>[];
|
||||||
|
final matches = _variablePattern.allMatches(content);
|
||||||
|
|
||||||
|
for (final match in matches) {
|
||||||
|
final fullMatch = match.group(0)!;
|
||||||
|
final name = match.group(1)!.trim();
|
||||||
|
final typeAndProps = match.group(2)?.trim();
|
||||||
|
|
||||||
|
var type = PromptVariableType.text;
|
||||||
|
final properties = <String, String>{};
|
||||||
|
|
||||||
|
if (typeAndProps != null && typeAndProps.isNotEmpty) {
|
||||||
|
// Parse type and properties: type:prop1=value1:prop2=value2
|
||||||
|
final parts = _parseTypeAndProperties(typeAndProps);
|
||||||
|
type = parts.type;
|
||||||
|
properties.addAll(parts.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
variables.add(
|
||||||
|
PromptVariable(
|
||||||
|
fullMatch: fullMatch,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
properties: properties,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse type and properties from the part after `|`.
|
||||||
|
_TypeAndProperties _parseTypeAndProperties(String input) {
|
||||||
|
var type = PromptVariableType.text;
|
||||||
|
final properties = <String, String>{};
|
||||||
|
|
||||||
|
// Split by `:` but handle nested structures like options=["a","b"]
|
||||||
|
final segments = _splitProperties(input);
|
||||||
|
|
||||||
|
for (var i = 0; i < segments.length; i++) {
|
||||||
|
final segment = segments[i].trim();
|
||||||
|
if (segment.isEmpty) continue;
|
||||||
|
|
||||||
|
if (i == 0 && !segment.contains('=')) {
|
||||||
|
// First segment without = is the type
|
||||||
|
type = _parseType(segment);
|
||||||
|
} else if (segment.contains('=')) {
|
||||||
|
// Property=value pair
|
||||||
|
final eqIndex = segment.indexOf('=');
|
||||||
|
final key = segment.substring(0, eqIndex).trim().toLowerCase();
|
||||||
|
final value = segment.substring(eqIndex + 1).trim();
|
||||||
|
properties[key] = value;
|
||||||
|
} else if (segment.toLowerCase() == 'required') {
|
||||||
|
// Boolean flag
|
||||||
|
properties['required'] = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _TypeAndProperties(type: type, properties: properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split properties while respecting brackets.
|
||||||
|
List<String> _splitProperties(String input) {
|
||||||
|
final segments = <String>[];
|
||||||
|
var current = StringBuffer();
|
||||||
|
var bracketDepth = 0;
|
||||||
|
var inQuotes = false;
|
||||||
|
String? quoteChar;
|
||||||
|
|
||||||
|
for (var i = 0; i < input.length; i++) {
|
||||||
|
final char = input[i];
|
||||||
|
|
||||||
|
if (inQuotes) {
|
||||||
|
current.write(char);
|
||||||
|
if (char == quoteChar && (i == 0 || input[i - 1] != r'\')) {
|
||||||
|
inQuotes = false;
|
||||||
|
quoteChar = null;
|
||||||
|
}
|
||||||
|
} else if (char == '"' || char == "'") {
|
||||||
|
inQuotes = true;
|
||||||
|
quoteChar = char;
|
||||||
|
current.write(char);
|
||||||
|
} else if (char == '[' || char == '{' || char == '(') {
|
||||||
|
bracketDepth++;
|
||||||
|
current.write(char);
|
||||||
|
} else if (char == ']' || char == '}' || char == ')') {
|
||||||
|
bracketDepth--;
|
||||||
|
current.write(char);
|
||||||
|
} else if (char == ':' && bracketDepth == 0) {
|
||||||
|
segments.add(current.toString());
|
||||||
|
current = StringBuffer();
|
||||||
|
} else {
|
||||||
|
current.write(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.isNotEmpty) {
|
||||||
|
segments.add(current.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptVariableType _parseType(String typeStr) {
|
||||||
|
switch (typeStr.toLowerCase()) {
|
||||||
|
case 'textarea':
|
||||||
|
return PromptVariableType.textarea;
|
||||||
|
case 'select':
|
||||||
|
return PromptVariableType.select;
|
||||||
|
case 'number':
|
||||||
|
return PromptVariableType.number;
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return PromptVariableType.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if content has any variables that require user input.
|
||||||
|
bool hasUserInputVariables(String content) {
|
||||||
|
final variables = parse(content);
|
||||||
|
return variables.any((v) => v.requiresUserInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if content has any variables (system or user input).
|
||||||
|
bool hasVariables(String content) {
|
||||||
|
return _variablePattern.hasMatch(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeAndProperties {
|
||||||
|
const _TypeAndProperties({required this.type, required this.properties});
|
||||||
|
|
||||||
|
final PromptVariableType type;
|
||||||
|
final Map<String, String> properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves system variables to their actual values.
|
||||||
|
class SystemVariableResolver {
|
||||||
|
const SystemVariableResolver({
|
||||||
|
this.userName,
|
||||||
|
this.userLanguage,
|
||||||
|
this.userLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? userName;
|
||||||
|
final String? userLanguage;
|
||||||
|
final String? userLocation;
|
||||||
|
|
||||||
|
/// Resolve a system variable to its value.
|
||||||
|
/// Returns null if the variable cannot be resolved.
|
||||||
|
Future<String?> resolve(String variableName) async {
|
||||||
|
final upper = variableName.toUpperCase();
|
||||||
|
|
||||||
|
switch (upper) {
|
||||||
|
case 'CLIPBOARD':
|
||||||
|
return _getClipboard();
|
||||||
|
case 'CURRENT_DATE':
|
||||||
|
return DateFormat.yMMMd().format(DateTime.now());
|
||||||
|
case 'CURRENT_DATETIME':
|
||||||
|
return DateFormat.yMMMd().add_jm().format(DateTime.now());
|
||||||
|
case 'CURRENT_TIME':
|
||||||
|
return DateFormat.jm().format(DateTime.now());
|
||||||
|
case 'CURRENT_TIMEZONE':
|
||||||
|
return DateTime.now().timeZoneName;
|
||||||
|
case 'CURRENT_WEEKDAY':
|
||||||
|
return DateFormat.EEEE().format(DateTime.now());
|
||||||
|
case 'USER_NAME':
|
||||||
|
return userName ?? '';
|
||||||
|
case 'USER_LANGUAGE':
|
||||||
|
return userLanguage ?? '';
|
||||||
|
case 'USER_LOCATION':
|
||||||
|
return userLocation ?? '';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getClipboard() async {
|
||||||
|
try {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
return data?.text ?? '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of processing a prompt with variables.
|
||||||
|
class ProcessedPrompt {
|
||||||
|
const ProcessedPrompt({
|
||||||
|
required this.content,
|
||||||
|
required this.userInputVariables,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The prompt content with system variables already resolved.
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
/// Variables that still require user input.
|
||||||
|
final List<PromptVariable> userInputVariables;
|
||||||
|
|
||||||
|
/// Whether user input is still needed.
|
||||||
|
bool get needsUserInput => userInputVariables.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes prompt content by resolving system variables and identifying
|
||||||
|
/// user input variables.
|
||||||
|
class PromptProcessor {
|
||||||
|
const PromptProcessor({required this.parser, required this.systemResolver});
|
||||||
|
|
||||||
|
final PromptVariableParser parser;
|
||||||
|
final SystemVariableResolver systemResolver;
|
||||||
|
|
||||||
|
/// Process prompt content.
|
||||||
|
///
|
||||||
|
/// Returns a [ProcessedPrompt] with system variables resolved and
|
||||||
|
/// a list of variables that need user input.
|
||||||
|
Future<ProcessedPrompt> process(String content) async {
|
||||||
|
final variables = parser.parse(content);
|
||||||
|
if (variables.isEmpty) {
|
||||||
|
return ProcessedPrompt(content: content, userInputVariables: const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var processedContent = content;
|
||||||
|
final userInputVars = <PromptVariable>[];
|
||||||
|
|
||||||
|
// Process variables in reverse order to preserve indices
|
||||||
|
for (final variable in variables.reversed) {
|
||||||
|
if (variable.isSystemVariable) {
|
||||||
|
final resolved = await systemResolver.resolve(variable.name);
|
||||||
|
if (resolved != null) {
|
||||||
|
processedContent = processedContent.replaceRange(
|
||||||
|
variable.start,
|
||||||
|
variable.end,
|
||||||
|
resolved,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userInputVars.insert(0, variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessedPrompt(
|
||||||
|
content: processedContent,
|
||||||
|
userInputVariables: userInputVars,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply user-provided values to the processed content.
|
||||||
|
String applyUserValues(String content, Map<String, String> values) {
|
||||||
|
final variables = parser.parse(content);
|
||||||
|
if (variables.isEmpty) return content;
|
||||||
|
|
||||||
|
var result = content;
|
||||||
|
|
||||||
|
// Apply in reverse order to preserve indices
|
||||||
|
for (final variable in variables.reversed) {
|
||||||
|
if (!variable.isSystemVariable && values.containsKey(variable.name)) {
|
||||||
|
result = result.replaceRange(
|
||||||
|
variable.start,
|
||||||
|
variable.end,
|
||||||
|
values[variable.name]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ import '../../../core/models/knowledge_base.dart';
|
|||||||
import '../../../shared/utils/platform_utils.dart';
|
import '../../../shared/utils/platform_utils.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import '../../../shared/widgets/modal_safe_area.dart';
|
import '../../../shared/widgets/modal_safe_area.dart';
|
||||||
|
import '../../../core/utils/prompt_variable_parser.dart';
|
||||||
|
import '../../prompts/widgets/prompt_variable_dialog.dart';
|
||||||
|
import '../../auth/providers/unified_auth_providers.dart';
|
||||||
|
|
||||||
class _SendMessageIntent extends Intent {
|
class _SendMessageIntent extends Intent {
|
||||||
const _SendMessageIntent();
|
const _SendMessageIntent();
|
||||||
@@ -468,10 +471,80 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
final TextRange? range = _currentPromptRange;
|
final TextRange? range = _currentPromptRange;
|
||||||
if (range == null) return;
|
if (range == null) return;
|
||||||
|
|
||||||
|
// Check if the prompt has variables that need processing
|
||||||
|
const parser = PromptVariableParser();
|
||||||
|
if (parser.hasVariables(prompt.content)) {
|
||||||
|
_processPromptWithVariables(prompt, range);
|
||||||
|
} else {
|
||||||
|
_insertPromptContent(prompt.content, range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processPromptWithVariables(
|
||||||
|
Prompt prompt,
|
||||||
|
TextRange range,
|
||||||
|
) async {
|
||||||
|
// Hide overlay first
|
||||||
|
setState(() {
|
||||||
|
_showPromptOverlay = false;
|
||||||
|
_currentPromptCommand = '';
|
||||||
|
_currentPromptRange = null;
|
||||||
|
_promptSelectionIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user info for system variables
|
||||||
|
final authUser = ref.read(currentUserProvider2);
|
||||||
|
final userAsync = ref.read(currentUserProvider);
|
||||||
|
final user = userAsync.maybeWhen(
|
||||||
|
data: (value) => value ?? authUser,
|
||||||
|
orElse: () => authUser,
|
||||||
|
);
|
||||||
|
final locale = Localizations.localeOf(context);
|
||||||
|
|
||||||
|
// Create the processor with system variable context
|
||||||
|
const parser = PromptVariableParser();
|
||||||
|
final systemResolver = SystemVariableResolver(
|
||||||
|
userName: user?.name ?? user?.email,
|
||||||
|
userLanguage: locale.languageCode,
|
||||||
|
// userLocation requires permission - left empty for now
|
||||||
|
);
|
||||||
|
final processor = PromptProcessor(
|
||||||
|
parser: parser,
|
||||||
|
systemResolver: systemResolver,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process system variables first
|
||||||
|
final processed = await processor.process(prompt.content);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
String finalContent = processed.content;
|
||||||
|
|
||||||
|
// If there are user input variables, show the dialog
|
||||||
|
if (processed.needsUserInput) {
|
||||||
|
final values = await PromptVariableDialog.show(
|
||||||
|
context,
|
||||||
|
variables: processed.userInputVariables,
|
||||||
|
promptTitle: prompt.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (values == null || !mounted) {
|
||||||
|
// User cancelled - restore focus
|
||||||
|
_ensureFocusedIfEnabled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply user-provided values
|
||||||
|
finalContent = processor.applyUserValues(finalContent, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the fully processed content
|
||||||
|
_insertPromptContent(finalContent, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertPromptContent(String content, TextRange range) {
|
||||||
final String text = _controller.text;
|
final String text = _controller.text;
|
||||||
final String before = text.substring(0, range.start);
|
final String before = text.substring(0, range.start);
|
||||||
final String after = text.substring(range.end);
|
final String after = text.substring(range.end);
|
||||||
final String content = prompt.content;
|
|
||||||
final int caret = before.length + content.length;
|
final int caret = before.length + content.length;
|
||||||
|
|
||||||
_controller.value = TextEditingValue(
|
_controller.value = TextEditingValue(
|
||||||
|
|||||||
351
lib/features/prompts/widgets/prompt_variable_dialog.dart
Normal file
351
lib/features/prompts/widgets/prompt_variable_dialog.dart
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:conduit/core/utils/prompt_variable_parser.dart';
|
||||||
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
|
import 'package:conduit/shared/theme/theme_extensions.dart';
|
||||||
|
|
||||||
|
// Use AppTypography constants for font sizes
|
||||||
|
|
||||||
|
/// A dialog that collects user input for prompt variables.
|
||||||
|
///
|
||||||
|
/// Displays input fields for each variable based on their type (text, textarea,
|
||||||
|
/// select, number) and validates required fields before submission.
|
||||||
|
class PromptVariableDialog extends StatefulWidget {
|
||||||
|
const PromptVariableDialog({
|
||||||
|
super.key,
|
||||||
|
required this.variables,
|
||||||
|
required this.promptTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The variables that require user input.
|
||||||
|
final List<PromptVariable> variables;
|
||||||
|
|
||||||
|
/// The title of the prompt being used.
|
||||||
|
final String promptTitle;
|
||||||
|
|
||||||
|
/// Shows the dialog and returns a map of variable name to user-provided value.
|
||||||
|
/// Returns null if the user cancels the dialog.
|
||||||
|
static Future<Map<String, String>?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<PromptVariable> variables,
|
||||||
|
required String promptTitle,
|
||||||
|
}) {
|
||||||
|
return showDialog<Map<String, String>>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) =>
|
||||||
|
PromptVariableDialog(variables: variables, promptTitle: promptTitle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PromptVariableDialog> createState() => _PromptVariableDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PromptVariableDialogState extends State<PromptVariableDialog> {
|
||||||
|
late final Map<String, TextEditingController> _controllers;
|
||||||
|
late final Map<String, String?> _selectValues;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controllers = {};
|
||||||
|
_selectValues = {};
|
||||||
|
|
||||||
|
for (final variable in widget.variables) {
|
||||||
|
if (variable.type == PromptVariableType.select) {
|
||||||
|
_selectValues[variable.name] = variable.defaultValue;
|
||||||
|
} else {
|
||||||
|
_controllers[variable.name] = TextEditingController(
|
||||||
|
text: variable.defaultValue ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in _controllers.values) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
final values = <String, String>{};
|
||||||
|
for (final variable in widget.variables) {
|
||||||
|
if (variable.type == PromptVariableType.select) {
|
||||||
|
values[variable.name] = _selectValues[variable.name] ?? '';
|
||||||
|
} else {
|
||||||
|
values[variable.name] = _controllers[variable.name]?.text ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: theme.surfaceBackground,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
||||||
|
),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.promptTitle.isNotEmpty
|
||||||
|
? widget.promptTitle
|
||||||
|
: l10n.promptVariablesTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textPrimary,
|
||||||
|
fontSize: AppTypography.bodyLarge,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
l10n.promptVariablesDescription,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < widget.variables.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: Spacing.md),
|
||||||
|
_buildField(widget.variables[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(null),
|
||||||
|
child: Text(
|
||||||
|
l10n.cancel,
|
||||||
|
style: TextStyle(color: theme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
child: Text(
|
||||||
|
l10n.continueAction,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isSubmitting ? theme.textSecondary : theme.buttonPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildField(PromptVariable variable) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
variable.displayLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textPrimary,
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (variable.isRequired)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.error,
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
_buildInputWidget(variable),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInputWidget(PromptVariable variable) {
|
||||||
|
switch (variable.type) {
|
||||||
|
case PromptVariableType.textarea:
|
||||||
|
return _buildTextareaField(variable);
|
||||||
|
case PromptVariableType.select:
|
||||||
|
return _buildSelectField(variable);
|
||||||
|
case PromptVariableType.number:
|
||||||
|
return _buildNumberField(variable);
|
||||||
|
case PromptVariableType.text:
|
||||||
|
return _buildTextField(variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(PromptVariable variable) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controllers[variable.name],
|
||||||
|
style: TextStyle(color: theme.inputText),
|
||||||
|
decoration: _inputDecoration(theme, variable.placeholder),
|
||||||
|
validator: (value) {
|
||||||
|
if (variable.isRequired && (value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.requiredFieldHelper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextareaField(PromptVariable variable) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controllers[variable.name],
|
||||||
|
style: TextStyle(color: theme.inputText),
|
||||||
|
decoration: _inputDecoration(theme, variable.placeholder),
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 6,
|
||||||
|
validator: (value) {
|
||||||
|
if (variable.isRequired && (value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.requiredFieldHelper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectField(PromptVariable variable) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final options = variable.options;
|
||||||
|
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectValues[variable.name],
|
||||||
|
decoration: _inputDecoration(theme, variable.placeholder),
|
||||||
|
dropdownColor: theme.surfaceBackground,
|
||||||
|
style: TextStyle(color: theme.inputText),
|
||||||
|
items: options.map((option) {
|
||||||
|
return DropdownMenuItem<String>(value: option, child: Text(option));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectValues[variable.name] = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (value) {
|
||||||
|
if (variable.isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return l10n.requiredFieldHelper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNumberField(PromptVariable variable) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controllers[variable.name],
|
||||||
|
style: TextStyle(color: theme.inputText),
|
||||||
|
decoration: _inputDecoration(theme, variable.placeholder),
|
||||||
|
keyboardType: TextInputType.numberWithOptions(
|
||||||
|
decimal: variable.step != null && variable.step! < 1,
|
||||||
|
signed: variable.min != null && variable.min! < 0,
|
||||||
|
),
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.-]'))],
|
||||||
|
validator: (value) {
|
||||||
|
if (variable.isRequired && (value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.requiredFieldHelper;
|
||||||
|
}
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
final num = double.tryParse(value);
|
||||||
|
if (num == null) {
|
||||||
|
return l10n.validationFormatError;
|
||||||
|
}
|
||||||
|
if (variable.min != null && num < variable.min!) {
|
||||||
|
return l10n.promptVariableNumberMin(variable.min!);
|
||||||
|
}
|
||||||
|
if (variable.max != null && num > variable.max!) {
|
||||||
|
return l10n.promptVariableNumberMax(variable.max!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(ConduitThemeExtension theme, String? hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: TextStyle(color: theme.inputPlaceholder),
|
||||||
|
filled: true,
|
||||||
|
fillColor: theme.inputBackground,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
borderSide: BorderSide(color: theme.inputBorder, width: 1),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
borderSide: BorderSide(color: theme.buttonPrimary, width: 1),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
borderSide: BorderSide(color: theme.error, width: 1),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
borderSide: BorderSide(color: theme.error, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.md,
|
||||||
|
vertical: Spacing.md,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Tippen zum Erweitern",
|
"tapToExpand": "Tippen zum Erweitern",
|
||||||
"byAuthor": "Von {name}",
|
"byAuthor": "Von {name}",
|
||||||
"wordCount": "{count} Wörter",
|
"wordCount": "{count} Wörter",
|
||||||
"charCount": "{count} Zeichen"
|
"charCount": "{count} Zeichen",
|
||||||
|
"promptVariablesTitle": "Details ausfüllen",
|
||||||
|
"promptVariablesDescription": "Füllen Sie die folgenden Felder aus, um diese Vorlage anzupassen.",
|
||||||
|
"promptVariableNumberMin": "Mindestwert ist {min}",
|
||||||
|
"promptVariableNumberMax": "Höchstwert ist {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1794,5 +1794,33 @@
|
|||||||
"chartPreviewUnavailable": "Chart preview is not available on this platform.",
|
"chartPreviewUnavailable": "Chart preview is not available on this platform.",
|
||||||
"@chartPreviewUnavailable": {
|
"@chartPreviewUnavailable": {
|
||||||
"description": "Shown when Chart.js charts cannot be rendered on the current platform."
|
"description": "Shown when Chart.js charts cannot be rendered on the current platform."
|
||||||
|
},
|
||||||
|
"promptVariablesTitle": "Fill in Details",
|
||||||
|
"@promptVariablesTitle": {
|
||||||
|
"description": "Default title for the prompt variables dialog."
|
||||||
|
},
|
||||||
|
"promptVariablesDescription": "Complete the fields below to customize this prompt.",
|
||||||
|
"@promptVariablesDescription": {
|
||||||
|
"description": "Description shown in the prompt variables dialog."
|
||||||
|
},
|
||||||
|
"promptVariableNumberMin": "Minimum value is {min}",
|
||||||
|
"@promptVariableNumberMin": {
|
||||||
|
"description": "Validation message when a number is below the minimum.",
|
||||||
|
"placeholders": {
|
||||||
|
"min": {
|
||||||
|
"type": "double",
|
||||||
|
"example": "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promptVariableNumberMax": "Maximum value is {max}",
|
||||||
|
"@promptVariableNumberMax": {
|
||||||
|
"description": "Validation message when a number is above the maximum.",
|
||||||
|
"placeholders": {
|
||||||
|
"max": {
|
||||||
|
"type": "double",
|
||||||
|
"example": "100"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Toca para expandir",
|
"tapToExpand": "Toca para expandir",
|
||||||
"byAuthor": "Por {name}",
|
"byAuthor": "Por {name}",
|
||||||
"wordCount": "{count} palabras",
|
"wordCount": "{count} palabras",
|
||||||
"charCount": "{count} caracteres"
|
"charCount": "{count} caracteres",
|
||||||
|
"promptVariablesTitle": "Completar detalles",
|
||||||
|
"promptVariablesDescription": "Complete los campos a continuación para personalizar este prompt.",
|
||||||
|
"promptVariableNumberMin": "El valor mínimo es {min}",
|
||||||
|
"promptVariableNumberMax": "El valor máximo es {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Appuyez pour développer",
|
"tapToExpand": "Appuyez pour développer",
|
||||||
"byAuthor": "Par {name}",
|
"byAuthor": "Par {name}",
|
||||||
"wordCount": "{count} mots",
|
"wordCount": "{count} mots",
|
||||||
"charCount": "{count} caractères"
|
"charCount": "{count} caractères",
|
||||||
|
"promptVariablesTitle": "Remplir les détails",
|
||||||
|
"promptVariablesDescription": "Complétez les champs ci-dessous pour personnaliser ce prompt.",
|
||||||
|
"promptVariableNumberMin": "La valeur minimale est {min}",
|
||||||
|
"promptVariableNumberMax": "La valeur maximale est {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Tocca per espandere",
|
"tapToExpand": "Tocca per espandere",
|
||||||
"byAuthor": "Di {name}",
|
"byAuthor": "Di {name}",
|
||||||
"wordCount": "{count} parole",
|
"wordCount": "{count} parole",
|
||||||
"charCount": "{count} caratteri"
|
"charCount": "{count} caratteri",
|
||||||
|
"promptVariablesTitle": "Compila i dettagli",
|
||||||
|
"promptVariablesDescription": "Compila i campi sottostanti per personalizzare questo prompt.",
|
||||||
|
"promptVariableNumberMin": "Il valore minimo è {min}",
|
||||||
|
"promptVariableNumberMax": "Il valore massimo è {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -585,5 +585,9 @@
|
|||||||
"tapToExpand": "탭하여 확장",
|
"tapToExpand": "탭하여 확장",
|
||||||
"byAuthor": "{name} 작성",
|
"byAuthor": "{name} 작성",
|
||||||
"wordCount": "{count}단어",
|
"wordCount": "{count}단어",
|
||||||
"charCount": "{count}자"
|
"charCount": "{count}자",
|
||||||
|
"promptVariablesTitle": "세부 정보 입력",
|
||||||
|
"promptVariablesDescription": "아래 필드를 작성하여 이 프롬프트를 사용자 정의하세요.",
|
||||||
|
"promptVariableNumberMin": "최소값은 {min}입니다",
|
||||||
|
"promptVariableNumberMax": "최대값은 {max}입니다"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Tik om uit te vouwen",
|
"tapToExpand": "Tik om uit te vouwen",
|
||||||
"byAuthor": "Door {name}",
|
"byAuthor": "Door {name}",
|
||||||
"wordCount": "{count} woorden",
|
"wordCount": "{count} woorden",
|
||||||
"charCount": "{count} tekens"
|
"charCount": "{count} tekens",
|
||||||
|
"promptVariablesTitle": "Details invullen",
|
||||||
|
"promptVariablesDescription": "Vul de onderstaande velden in om deze prompt aan te passen.",
|
||||||
|
"promptVariableNumberMin": "Minimale waarde is {min}",
|
||||||
|
"promptVariableNumberMax": "Maximale waarde is {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "Нажмите для раскрытия",
|
"tapToExpand": "Нажмите для раскрытия",
|
||||||
"byAuthor": "Автор: {name}",
|
"byAuthor": "Автор: {name}",
|
||||||
"wordCount": "{count} слов",
|
"wordCount": "{count} слов",
|
||||||
"charCount": "{count} символов"
|
"charCount": "{count} символов",
|
||||||
|
"promptVariablesTitle": "Заполните данные",
|
||||||
|
"promptVariablesDescription": "Заполните поля ниже, чтобы настроить этот промпт.",
|
||||||
|
"promptVariableNumberMin": "Минимальное значение: {min}",
|
||||||
|
"promptVariableNumberMax": "Максимальное значение: {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "点击展开",
|
"tapToExpand": "点击展开",
|
||||||
"byAuthor": "作者:{name}",
|
"byAuthor": "作者:{name}",
|
||||||
"wordCount": "{count} 字",
|
"wordCount": "{count} 字",
|
||||||
"charCount": "{count} 字符"
|
"charCount": "{count} 字符",
|
||||||
|
"promptVariablesTitle": "填写详情",
|
||||||
|
"promptVariablesDescription": "填写以下字段以自定义此提示词。",
|
||||||
|
"promptVariableNumberMin": "最小值为 {min}",
|
||||||
|
"promptVariableNumberMax": "最大值为 {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,5 +807,9 @@
|
|||||||
"tapToExpand": "點擊展開",
|
"tapToExpand": "點擊展開",
|
||||||
"byAuthor": "作者:{name}",
|
"byAuthor": "作者:{name}",
|
||||||
"wordCount": "{count} 字",
|
"wordCount": "{count} 字",
|
||||||
"charCount": "{count} 字元"
|
"charCount": "{count} 字元",
|
||||||
|
"promptVariablesTitle": "填寫詳情",
|
||||||
|
"promptVariablesDescription": "填寫以下欄位以自訂此提示詞。",
|
||||||
|
"promptVariableNumberMin": "最小值為 {min}",
|
||||||
|
"promptVariableNumberMax": "最大值為 {max}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user