feat(studio): per-field ports in flow editor + store-doc scroll fix
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Picks up: - fai_studio_flow_editor 0.9.0: per-field input + output ports rendered from ModuleInfo, i18n tooltips, four additional canvas patterns ported from jai_client. - fai_client_sdk 0.18.0: regenerated ModuleField bindings for the schema_version 3 / per-field-description proto bump in fai/platform. Wires StudioFlowRunDriver.moduleInfo so the editor can fetch declared field info per step capability. The hub maps schema_version 3 manifest fields (inputs/outputs with description.en/de) through ModuleInfoResponse; this patch wraps them back into the editor's ModuleSpec / ModuleField vocabulary so the canvas paints them as distinct anchors with hover tooltips. ModuleDetail in hub.dart now carries the same inputs + outputs lists (with ModuleFieldInfo type + i18n description map), enabling other Studio surfaces — e.g. the module-detail sheet — to render bilingual field docs in a later pass. Plus: store-doc nested-scroll fix. The module detail panel's docs section sat inside its own 480px viewport inside the outer sheet's SingleChildScrollView, producing the double-scrollbar feel. Setting physics: NeverScrollableScrollPhysics on the Markdown widget and dropping the maxHeight lets the docs scroll as part of the outer sheet — one scrollbar, predictable mouse-wheel behaviour. Bumps fai_studio to 0.56.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
34d086c29b
commit
a698be0b57
5 changed files with 466 additions and 336 deletions
|
|
@ -40,6 +40,39 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
|
|||
return mapped;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<editor.ModuleSpec?> moduleInfo(String capability) async {
|
||||
try {
|
||||
final detail = await HubService.instance.moduleInfo(capability);
|
||||
return editor.ModuleSpec(
|
||||
capability: capability,
|
||||
inputs: detail.inputs
|
||||
.map(
|
||||
(f) => editor.ModuleField(
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: f.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
outputs: detail.outputs
|
||||
.map(
|
||||
(f) => editor.ModuleField(
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: f.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
} catch (_) {
|
||||
// Module not installed locally, or transient hub error.
|
||||
// Returning null lets the editor fall back to YAML-
|
||||
// derived ports instead of failing the whole canvas.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<editor.FlowRunEvent> events() {
|
||||
return HubService.instance
|
||||
|
|
@ -82,10 +115,7 @@ class StudioFlowRunDriver implements editor.FlowRunDriver {
|
|||
error: e.error ?? '',
|
||||
);
|
||||
case 'step.awaiting_approval':
|
||||
return editor.StepAwaitingApproval(
|
||||
flowName: flowName,
|
||||
stepId: stepId,
|
||||
);
|
||||
return editor.StepAwaitingApproval(flowName: flowName, stepId: stepId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,12 +133,9 @@ class HubService {
|
|||
return ModuleSummary(
|
||||
name: e.key,
|
||||
version: caps.first.moduleVersion,
|
||||
capabilities: caps
|
||||
.map((c) => '${c.capability}@${c.version}')
|
||||
.toList(),
|
||||
capabilities: caps.map((c) => '${c.capability}@${c.version}').toList(),
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
}).toList()..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
|
||||
/// Every capability the hub can execute, with the `kind` tag
|
||||
|
|
@ -151,7 +148,8 @@ class HubService {
|
|||
Future<List<CapabilityInfo>> allCapabilities() async {
|
||||
final caps = await _client.listCapabilities();
|
||||
return caps
|
||||
.map((c) => CapabilityInfo(
|
||||
.map(
|
||||
(c) => CapabilityInfo(
|
||||
capability: c.capability,
|
||||
version: c.version,
|
||||
moduleName: c.moduleName,
|
||||
|
|
@ -159,7 +157,8 @@ class HubService {
|
|||
provider: c.provider,
|
||||
sourceKind: c.sourceKind,
|
||||
versionAdvisory: c.versionAdvisory,
|
||||
))
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +174,24 @@ class HubService {
|
|||
permissions: r.permissions,
|
||||
directory: r.directory,
|
||||
acceptsMime: r.acceptsMime,
|
||||
inputs: r.inputs
|
||||
.map(
|
||||
(f) => ModuleFieldInfo(
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: Map<String, String>.from(f.description),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
outputs: r.outputs
|
||||
.map(
|
||||
(f) => ModuleFieldInfo(
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: Map<String, String>.from(f.description),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -300,8 +317,7 @@ class HubService {
|
|||
/// Pull (download) an Ollama model via /api/pull.
|
||||
/// Synchronous; can take minutes for large models. Errors
|
||||
/// come back as `errorKind`.
|
||||
Future<({String errorKind, String text, int elapsedMs})>
|
||||
pullSystemAiModel({
|
||||
Future<({String errorKind, String text, int elapsedMs})> pullSystemAiModel({
|
||||
required String endpoint,
|
||||
required String model,
|
||||
String apiKeyEnv = '',
|
||||
|
|
@ -311,11 +327,7 @@ class HubService {
|
|||
model: model,
|
||||
apiKeyEnv: apiKeyEnv,
|
||||
);
|
||||
return (
|
||||
errorKind: r.errorKind,
|
||||
text: r.text,
|
||||
elapsedMs: r.elapsedMs,
|
||||
);
|
||||
return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs);
|
||||
}
|
||||
|
||||
/// Detected host hardware. Used by the System-AI editor to
|
||||
|
|
@ -368,10 +380,7 @@ class HubService {
|
|||
required String reviewer,
|
||||
required String reason,
|
||||
}) async {
|
||||
final r = await _client.clearEventLog(
|
||||
reviewer: reviewer,
|
||||
reason: reason,
|
||||
);
|
||||
final r = await _client.clearEventLog(reviewer: reviewer, reason: reason);
|
||||
return (purged: r.purged.toInt(), channel: r.channel);
|
||||
}
|
||||
|
||||
|
|
@ -414,8 +423,9 @@ class HubService {
|
|||
/// Replace the operator's `default_scope:` and hot-swap on the
|
||||
/// running hub. Caller passes the new ordered list; hub rejects
|
||||
/// an empty list with InvalidArgument.
|
||||
Future<({List<String> scope, List<String> knownProviders})>
|
||||
setDefaultScope(List<String> scope) async {
|
||||
Future<({List<String> scope, List<String> knownProviders})> setDefaultScope(
|
||||
List<String> scope,
|
||||
) async {
|
||||
return _client.setDefaultScope(scope);
|
||||
}
|
||||
|
||||
|
|
@ -425,7 +435,8 @@ class HubService {
|
|||
/// is installed side-by-side.
|
||||
Future<List<String>> installedVersions(String moduleName) async {
|
||||
final caps = await _client.listCapabilities();
|
||||
final versions = caps
|
||||
final versions =
|
||||
caps
|
||||
.where((c) => c.moduleName == moduleName)
|
||||
.where((c) => c.kind.isEmpty || c.kind == 'wasm')
|
||||
.map((c) => c.moduleVersion)
|
||||
|
|
@ -742,7 +753,9 @@ class HubService {
|
|||
int backfill = 0,
|
||||
List<String> types = const [],
|
||||
}) {
|
||||
return _client.streamEvents(backfill: backfill, types: types).map(
|
||||
return _client
|
||||
.streamEvents(backfill: backfill, types: types)
|
||||
.map(
|
||||
(e) => AuditEvent(
|
||||
eventId: e.eventId,
|
||||
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
|
||||
|
|
@ -774,12 +787,9 @@ class HubService {
|
|||
flowName: e.flowName.isEmpty ? null : e.flowName,
|
||||
stepId: e.stepId.isEmpty ? null : e.stepId,
|
||||
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
||||
moduleVersion:
|
||||
e.moduleVersion.isEmpty ? null : e.moduleVersion,
|
||||
invocationId:
|
||||
e.invocationId.isEmpty ? null : e.invocationId,
|
||||
flowExecution:
|
||||
e.flowExecution.isEmpty ? null : e.flowExecution,
|
||||
moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion,
|
||||
invocationId: e.invocationId.isEmpty ? null : e.invocationId,
|
||||
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
||||
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
||||
error: e.error.isEmpty ? null : e.error,
|
||||
detail: e.detail.isEmpty ? null : e.detail,
|
||||
|
|
@ -826,8 +836,7 @@ class HubService {
|
|||
flowName: e.flowName,
|
||||
stepId: e.stepId,
|
||||
prompt: e.prompt,
|
||||
payloadPreview:
|
||||
e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
||||
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
||||
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
||||
expiresAt: e.expiresAt.isEmpty
|
||||
? null
|
||||
|
|
@ -847,26 +856,19 @@ class HubService {
|
|||
_client.approve(approvalId: id, reviewer: reviewer);
|
||||
|
||||
Future<void> reject(String id, String reviewer, String reason) =>
|
||||
_client.reject(
|
||||
approvalId: id,
|
||||
reviewer: reviewer,
|
||||
reason: reason,
|
||||
);
|
||||
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
||||
|
||||
/// Composite "doctor" snapshot. One round-trip per piece, run
|
||||
/// in parallel so the page populates fast.
|
||||
Future<DoctorSnapshot> doctor() async {
|
||||
final results = await Future.wait([
|
||||
_client.listCapabilities().catchError((_) => <CapabilityEntry>[]),
|
||||
_client.listApprovals(statuses: const ['pending'])
|
||||
_client
|
||||
.listApprovals(statuses: const ['pending'])
|
||||
.catchError((_) => <PendingApprovalEntry>[]),
|
||||
_client.verifyEventChain().catchError(
|
||||
(_) => VerifyEventChainResponse(),
|
||||
),
|
||||
_client.verifyEventChain().catchError((_) => VerifyEventChainResponse()),
|
||||
_client.listServices().catchError((_) => <DeclaredService>[]),
|
||||
_client.checkUpdate().catchError(
|
||||
(_) => CheckUpdateResponse(),
|
||||
),
|
||||
_client.checkUpdate().catchError((_) => CheckUpdateResponse()),
|
||||
_client.daemonPaths().catchError((_) => DaemonPathsResponse()),
|
||||
]);
|
||||
|
||||
|
|
@ -896,15 +898,11 @@ class HubService {
|
|||
pendingApprovals: approvals.length,
|
||||
eventChainTotal: chain.total.toInt(),
|
||||
eventChainVerified: chain.verified.toInt(),
|
||||
eventChainTamperedAt:
|
||||
chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
||||
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
||||
services: services
|
||||
.map(
|
||||
(s) => ServiceEntry(
|
||||
name: s.name,
|
||||
endpoint: s.endpoint,
|
||||
tags: s.tags,
|
||||
),
|
||||
(s) =>
|
||||
ServiceEntry(name: s.name, endpoint: s.endpoint, tags: s.tags),
|
||||
)
|
||||
.toList(),
|
||||
update: UpdateStatus(
|
||||
|
|
@ -913,8 +911,9 @@ class HubService {
|
|||
latestVersion: update.latestVersion,
|
||||
updateAvailable: update.updateAvailable,
|
||||
manifestReachable: update.manifestReachable,
|
||||
releaseNotesUrl:
|
||||
update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl,
|
||||
releaseNotesUrl: update.releaseNotesUrl.isEmpty
|
||||
? null
|
||||
: update.releaseNotesUrl,
|
||||
reason: update.reason.isEmpty ? null : update.reason,
|
||||
),
|
||||
paths: DaemonPathsSnapshot(
|
||||
|
|
@ -958,9 +957,11 @@ class DoctorSnapshot {
|
|||
final String? eventChainTamperedAt;
|
||||
final List<ServiceEntry> services;
|
||||
final UpdateStatus update;
|
||||
|
||||
/// Filesystem paths the daemon owns. Empty fields when the
|
||||
/// hub couldn't resolve them (older daemon, no $HOME).
|
||||
final DaemonPathsSnapshot paths;
|
||||
|
||||
/// Per-source-kind capability count: bundle / system / mcp /
|
||||
/// n8n / temporal / webhook (0.12+). Pre-0.12 hubs return
|
||||
/// an empty map and the panel falls back to a flat count.
|
||||
|
|
@ -1023,14 +1024,17 @@ class CapabilityInfo {
|
|||
final String version;
|
||||
final String moduleName;
|
||||
final String kind;
|
||||
|
||||
/// Publisher segment of the fully-qualified identifier.
|
||||
/// "fai" for legacy bundles, "fai.system" for built-ins,
|
||||
/// operator-config service name for federated capabilities.
|
||||
final String provider;
|
||||
|
||||
/// Transport that delivers the capability: bundle / system /
|
||||
/// mcp / n8n / temporal / webhook. Empty string falls back
|
||||
/// to deriving from `kind` for pre-0.12 hubs.
|
||||
final String sourceKind;
|
||||
|
||||
/// Whether the version on this capability is a label rather
|
||||
/// than a hard contract. True for federation sources whose
|
||||
/// upstream service does not honour semver.
|
||||
|
|
@ -1065,6 +1069,7 @@ class SavedFlow {
|
|||
final String name;
|
||||
final String path;
|
||||
final int sizeBytes;
|
||||
|
||||
/// Verbatim `use:` strings of every step in this flow,
|
||||
/// deduplicated and sorted by the hub. Lets Studio decide
|
||||
/// whether the flow is currently runnable (every entry
|
||||
|
|
@ -1085,11 +1090,24 @@ class ModuleDetail {
|
|||
final List<String> capabilities;
|
||||
final List<String> permissions;
|
||||
final String directory;
|
||||
|
||||
/// MIME allow-list declared in the module's `module.yaml`.
|
||||
/// Empty when the module didn't declare any — caller falls
|
||||
/// back to its built-in extension heuristic.
|
||||
final List<String> acceptsMime;
|
||||
|
||||
/// Declared inputs (schema v3 verbose form when present). The
|
||||
/// flow editor uses these to render per-field input ports with
|
||||
/// canonical names + i18n tooltips.
|
||||
final List<ModuleFieldInfo> inputs;
|
||||
|
||||
/// Declared outputs (schema v3 verbose form when present).
|
||||
/// Rendered by the flow editor as distinct output ports so a
|
||||
/// step like `summarize` exposes its `response`,
|
||||
/// `model_endpoint`, `model_name`, `model_digest` separately
|
||||
/// instead of fanning them all out of one anchor.
|
||||
final List<ModuleFieldInfo> outputs;
|
||||
|
||||
const ModuleDetail({
|
||||
required this.name,
|
||||
required this.version,
|
||||
|
|
@ -1097,9 +1115,43 @@ class ModuleDetail {
|
|||
required this.permissions,
|
||||
required this.directory,
|
||||
this.acceptsMime = const [],
|
||||
this.inputs = const [],
|
||||
this.outputs = const [],
|
||||
});
|
||||
}
|
||||
|
||||
/// One declared input or output field on a module. Mirrors
|
||||
/// `proto.ModuleField`: name, type descriptor, and a locale
|
||||
/// → description map for tooltips.
|
||||
class ModuleFieldInfo {
|
||||
/// Field name, e.g. "prompt" or "model_endpoint".
|
||||
final String name;
|
||||
|
||||
/// Type descriptor (text / json / bytes / file).
|
||||
final String type;
|
||||
|
||||
/// Locale tag → description. Use [descriptionFor] to look up
|
||||
/// with English fallback.
|
||||
final Map<String, String> description;
|
||||
|
||||
const ModuleFieldInfo({
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.description = const {},
|
||||
});
|
||||
|
||||
/// Returns the description in [locale] when present, else the
|
||||
/// English peer, else null. Studio passes its active locale's
|
||||
/// short code (e.g. "de") and shows the result as a tooltip.
|
||||
String? descriptionFor(String locale) {
|
||||
final exact = description[locale];
|
||||
if (exact != null && exact.isNotEmpty) return exact;
|
||||
final en = description['en'];
|
||||
if (en != null && en.isNotEmpty) return en;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AuditEvent {
|
||||
final String eventId;
|
||||
final DateTime timestamp;
|
||||
|
|
@ -1112,6 +1164,7 @@ class AuditEvent {
|
|||
final String? flowExecution;
|
||||
final int? durationMs;
|
||||
final String? error;
|
||||
|
||||
/// Free-form JSON-string carrying event-type-specific fields.
|
||||
/// Surfaced verbatim in the audit drill-down dialog.
|
||||
final String? detail;
|
||||
|
|
@ -1163,12 +1216,15 @@ class ApprovalRecord {
|
|||
final String? payloadPreview;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// One of: pending / approved / rejected / expired.
|
||||
final String status;
|
||||
final DateTime? decidedAt;
|
||||
|
||||
/// Reviewer handle (e.g. "alice@studio") or "system" for
|
||||
/// auto-expired approvals. Empty until decided.
|
||||
final String decidedBy;
|
||||
|
||||
/// Reject reason — empty for approve / pending / expired.
|
||||
final String reason;
|
||||
|
||||
|
|
@ -1192,9 +1248,11 @@ class SystemAiStatus {
|
|||
final String provider;
|
||||
final String endpoint;
|
||||
final String model;
|
||||
|
||||
/// off / redacted / full.
|
||||
final String privacyMode;
|
||||
final String apiKeyEnv;
|
||||
|
||||
/// Number of cached System-AI explanations currently stored.
|
||||
/// Surfaced in Doctor + Settings so operators see the cache
|
||||
/// earning its keep.
|
||||
|
|
@ -1215,17 +1273,22 @@ class AskAiResult {
|
|||
/// "" on success; otherwise one of:
|
||||
/// disabled / env_missing / network / http / parse / empty_response.
|
||||
final String errorKind;
|
||||
|
||||
/// On success: the assistant's reply.
|
||||
/// On failure: a human-readable diagnostic.
|
||||
final String text;
|
||||
|
||||
/// Round-trip latency in ms (success only). For cache hits
|
||||
/// this is the *original* generation latency.
|
||||
final int latencyMs;
|
||||
|
||||
/// True iff the answer came from the persistent cache.
|
||||
final bool cached;
|
||||
|
||||
/// ISO-8601 timestamp the cached entry was first generated.
|
||||
/// Empty for fresh answers.
|
||||
final String cachedAt;
|
||||
|
||||
/// How many times this entry has been served from cache.
|
||||
/// 0 for fresh answers.
|
||||
final int cacheHits;
|
||||
|
|
@ -1283,10 +1346,7 @@ class ChannelStatusSnapshot {
|
|||
final String active;
|
||||
final List<ChannelInfo> channels;
|
||||
|
||||
const ChannelStatusSnapshot({
|
||||
required this.active,
|
||||
required this.channels,
|
||||
});
|
||||
const ChannelStatusSnapshot({required this.active, required this.channels});
|
||||
}
|
||||
|
||||
/// One configured n8n endpoint plus the last-discovery
|
||||
|
|
@ -1320,13 +1380,16 @@ class McpClientInfo {
|
|||
final String endpoint;
|
||||
final String apiKeyEnv;
|
||||
final String description;
|
||||
|
||||
/// Number of tools the last discovery returned. 0 before
|
||||
/// discovery has run or on failure.
|
||||
final int toolCount;
|
||||
|
||||
/// Empty on success; otherwise one of:
|
||||
/// invalid_endpoint / not_implemented / network / http /
|
||||
/// server / parse / env_missing.
|
||||
final String errorKind;
|
||||
|
||||
/// Human-readable diagnostic.
|
||||
final String message;
|
||||
|
||||
|
|
@ -1375,6 +1438,7 @@ class HardwareSnapshot {
|
|||
final int ramMb;
|
||||
final bool appleSilicon;
|
||||
final bool dedicatedGpu;
|
||||
|
||||
/// Human-readable summary the editor renders verbatim.
|
||||
final String summary;
|
||||
|
||||
|
|
@ -1414,8 +1478,10 @@ class CuratedModelInfo {
|
|||
final String id;
|
||||
final String family;
|
||||
final double paramsB;
|
||||
|
||||
/// basic / good / excellent.
|
||||
final String qualityTier;
|
||||
|
||||
/// tiny / small / balanced / large.
|
||||
final String minHwTier;
|
||||
final int contextK;
|
||||
|
|
@ -1456,20 +1522,19 @@ class CuratedSnapshot {
|
|||
final String lastReviewed;
|
||||
final List<CuratedModelInfo> models;
|
||||
|
||||
const CuratedSnapshot({
|
||||
required this.lastReviewed,
|
||||
required this.models,
|
||||
});
|
||||
const CuratedSnapshot({required this.lastReviewed, required this.models});
|
||||
}
|
||||
|
||||
/// One row from the store-index search.
|
||||
class StoreItem {
|
||||
final String name;
|
||||
final String taglineEn;
|
||||
|
||||
/// German tagline. Falls back to [taglineEn] in the UI when
|
||||
/// the index entry does not ship a localized one.
|
||||
final String taglineDe;
|
||||
final String descriptionEn;
|
||||
|
||||
/// German long-form description. Falls back to [descriptionEn]
|
||||
/// when missing.
|
||||
final String descriptionDe;
|
||||
|
|
@ -1480,30 +1545,38 @@ class StoreItem {
|
|||
final String license;
|
||||
final String repository;
|
||||
final String bestVersion;
|
||||
|
||||
/// "published", "alpha", "planned", or empty when unknown.
|
||||
final String status;
|
||||
final bool installed;
|
||||
|
||||
/// True iff the store-index marks this entry as editorially
|
||||
/// featured (curated quarterly in the bundled seed.yaml).
|
||||
/// Drives the "Featured" strip at the top of the Store page.
|
||||
final bool featured;
|
||||
|
||||
/// Optional icon URL — square PNG / SVG. Empty for entries
|
||||
/// that don't supply one; UI falls back to a category icon.
|
||||
final String iconUrl;
|
||||
|
||||
/// Ordered list of screenshot URLs; empty when none. Studio
|
||||
/// renders them as a horizontally-scrollable strip in the
|
||||
/// detail sheet.
|
||||
final List<String> screenshotUrls;
|
||||
|
||||
/// Explicit docs URL override. When empty, the hub falls back
|
||||
/// to the well-known raw-README paths off [repository].
|
||||
final String docsUrl;
|
||||
|
||||
/// "native" (downloadable bundle) or "federated" (routes
|
||||
/// through a bridge — MCP, n8n, …). Empty falls back to
|
||||
/// "native".
|
||||
final String kind;
|
||||
|
||||
/// Provider id for federated entries. Empty for native.
|
||||
/// E.g. "filesystem" for an `mcp.filesystem.read_file` tool.
|
||||
final String provider;
|
||||
|
||||
/// Transport delivering this entry. Distinct from `kind` —
|
||||
/// `sourceKind` answers "HOW does the hub reach this", not
|
||||
/// "is it installable". Values: "bundle", "mcp", "n8n",
|
||||
|
|
|
|||
|
|
@ -31,11 +31,13 @@ class _StorePageState extends State<StorePage> {
|
|||
final _queryCtrl = TextEditingController();
|
||||
String _category = '';
|
||||
String _status = '';
|
||||
|
||||
/// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
|
||||
/// client-side after the hub returns results — the search RPC
|
||||
/// has no source field.
|
||||
String _source = '';
|
||||
bool _installedOnly = false;
|
||||
|
||||
/// "Show installable modules only". On by default: roughly
|
||||
/// 2/3 of the seed-index entries currently carry status
|
||||
/// `planned` (no bundle to install), and pre-filter polish
|
||||
|
|
@ -43,20 +45,24 @@ class _StorePageState extends State<StorePage> {
|
|||
/// install half the cards. Planned modules are one chip-flip
|
||||
/// away in the filter dialog.
|
||||
bool _installableOnly = true;
|
||||
|
||||
/// Tracks per-recommended-source add buttons to disable them
|
||||
/// while the request is in flight.
|
||||
final Set<String> _addingSources = <String>{};
|
||||
|
||||
/// Whether the operator dismissed the editorial hero this
|
||||
/// session. Not persisted — the next Studio launch shows it
|
||||
/// again so a release-bumped story has a chance to be seen.
|
||||
bool _todayDismissed = false;
|
||||
|
||||
/// The carousel of stories rendered in the hero. When an
|
||||
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
|
||||
/// it is the only entry (carousel collapses to a single
|
||||
/// slide). Otherwise the curated fallback list rotates.
|
||||
late final List<TodayStoryData> _todayStories = (() {
|
||||
final loaded =
|
||||
TodayStoryLoader.loadOrFallback(_kFallbackTodayStories.first);
|
||||
final loaded = TodayStoryLoader.loadOrFallback(
|
||||
_kFallbackTodayStories.first,
|
||||
);
|
||||
// When the loader fell back to the const, it returned the
|
||||
// first entry of our list — surface the whole list so the
|
||||
// operator can browse. Otherwise the loader produced a real
|
||||
|
|
@ -66,6 +72,7 @@ class _StorePageState extends State<StorePage> {
|
|||
}
|
||||
return <TodayStoryData>[loaded];
|
||||
})();
|
||||
|
||||
/// Carousel index. Wraps around modulo `_todayStories.length`.
|
||||
int _todayIndex = 0;
|
||||
late Future<List<StoreItem>> _future;
|
||||
|
|
@ -82,11 +89,13 @@ class _StorePageState extends State<StorePage> {
|
|||
List<_AiMatch>? _aiMatches;
|
||||
Set<String>? _aiMatchedNames;
|
||||
String? _aiError;
|
||||
|
||||
/// Debounce-Timer für Such-Filterung beim Tippen. Ohne
|
||||
/// Debounce flackert "Keine Treffer" zwischen Buchstaben
|
||||
/// (für 2-3 Buchstaben matched z.B. nichts, das nächste
|
||||
/// dann doch). 250ms ist comfortable für Tippen.
|
||||
Timer? _typingDebounce;
|
||||
|
||||
/// Whether the operator has a System-AI configured. Drives
|
||||
/// the Ask-bar hint copy and disables the AI path when off.
|
||||
bool _systemAiEnabled = false;
|
||||
|
|
@ -151,8 +160,10 @@ class _StorePageState extends State<StorePage> {
|
|||
// An already-installed item counts as "installable" for
|
||||
// this filter — the user can still update / uninstall it.
|
||||
out = out
|
||||
.where((e) =>
|
||||
e.installed || e.status == 'published' || e.status == 'alpha')
|
||||
.where(
|
||||
(e) =>
|
||||
e.installed || e.status == 'published' || e.status == 'alpha',
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return out;
|
||||
|
|
@ -292,7 +303,8 @@ class _StorePageState extends State<StorePage> {
|
|||
// Browsing-only chrome — every editorial
|
||||
// surface hides the moment a filter is set so
|
||||
// the operator's query stays the focus.
|
||||
final isBrowsing = _queryCtrl.text.trim().isEmpty &&
|
||||
final isBrowsing =
|
||||
_queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
_source.isEmpty &&
|
||||
|
|
@ -312,7 +324,8 @@ class _StorePageState extends State<StorePage> {
|
|||
// out of the way.
|
||||
final showStandaloneRecommended =
|
||||
isBrowsing && _todayDismissed && hasNoFederation;
|
||||
final showFeatured = isBrowsing &&
|
||||
final showFeatured =
|
||||
isBrowsing &&
|
||||
_todayDismissed &&
|
||||
items.any((e) => e.featured);
|
||||
return SingleChildScrollView(
|
||||
|
|
@ -334,13 +347,15 @@ class _StorePageState extends State<StorePage> {
|
|||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story:
|
||||
_todayStories[_todayIndex % _todayStories.length],
|
||||
_todayStories[_todayIndex %
|
||||
_todayStories.length],
|
||||
locale: _locale,
|
||||
currentIndex: _todayIndex,
|
||||
totalCount: _todayStories.length,
|
||||
onPrev: _todayStories.length > 1
|
||||
? () => setState(() {
|
||||
_todayIndex = (_todayIndex -
|
||||
_todayIndex =
|
||||
(_todayIndex -
|
||||
1 +
|
||||
_todayStories.length) %
|
||||
_todayStories.length;
|
||||
|
|
@ -348,14 +363,16 @@ class _StorePageState extends State<StorePage> {
|
|||
: null,
|
||||
onNext: _todayStories.length > 1
|
||||
? () => setState(() {
|
||||
_todayIndex = (_todayIndex + 1) %
|
||||
_todayIndex =
|
||||
(_todayIndex + 1) %
|
||||
_todayStories.length;
|
||||
})
|
||||
: null,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta: _todayStories[
|
||||
_todayIndex % _todayStories.length]
|
||||
onCta:
|
||||
_todayStories[_todayIndex %
|
||||
_todayStories.length]
|
||||
.cta ==
|
||||
TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
|
|
@ -538,13 +555,15 @@ class _StorePageState extends State<StorePage> {
|
|||
try {
|
||||
final all = await HubService.instance.searchStore(limit: 200);
|
||||
final modules = all
|
||||
.map((s) => {
|
||||
.map(
|
||||
(s) => {
|
||||
'name': s.name,
|
||||
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
|
||||
? s.taglineDe
|
||||
: s.taglineEn,
|
||||
'category': s.category,
|
||||
})
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
final prompt = _buildAiSearchPrompt(query, modules, _locale);
|
||||
final result = await HubService.instance.askAi(prompt);
|
||||
|
|
@ -794,12 +813,14 @@ class _AskBar extends StatelessWidget {
|
|||
// can write multi-line questions naturally.
|
||||
child: Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.meta,
|
||||
LogicalKeyboardKey.enter):
|
||||
const _SubmitAskIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.control,
|
||||
LogicalKeyboardKey.enter):
|
||||
const _SubmitAskIntent(),
|
||||
LogicalKeySet(
|
||||
LogicalKeyboardKey.meta,
|
||||
LogicalKeyboardKey.enter,
|
||||
): const _SubmitAskIntent(),
|
||||
LogicalKeySet(
|
||||
LogicalKeyboardKey.control,
|
||||
LogicalKeyboardKey.enter,
|
||||
): const _SubmitAskIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
|
|
@ -822,10 +843,10 @@ class _AskBar extends StatelessWidget {
|
|||
// Cmd/Ctrl+Enter.
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 6),
|
||||
hintText:
|
||||
aiAvailable ? l.storeAskHint : l.storeSearchHint,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||
hintText: aiAvailable
|
||||
? l.storeAskHint
|
||||
: l.storeSearchHint,
|
||||
hintMaxLines: 2,
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
|
|
@ -857,9 +878,7 @@ class _AskBar extends StatelessWidget {
|
|||
aiAvailable ? Icons.auto_awesome : Icons.search,
|
||||
size: 14,
|
||||
),
|
||||
label: Text(
|
||||
thinking ? l.storeAskAiThinking : l.storeAskSubmit,
|
||||
),
|
||||
label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
|
|
@ -883,10 +902,7 @@ class _FilterButton extends StatelessWidget {
|
|||
final int activeCount;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _FilterButton({
|
||||
required this.activeCount,
|
||||
required this.onPressed,
|
||||
});
|
||||
const _FilterButton({required this.activeCount, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -902,10 +918,7 @@ class _FilterButton extends StatelessWidget {
|
|||
if (activeCount > 0) ...[
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 1,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -921,9 +934,7 @@ class _FilterButton extends StatelessWidget {
|
|||
],
|
||||
],
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -986,9 +997,11 @@ class _FilterDialogState extends State<_FilterDialog> {
|
|||
),
|
||||
),
|
||||
);
|
||||
Widget chipRow(List<({String value, String label})> items, String selected,
|
||||
void Function(String) onSelect) =>
|
||||
Wrap(
|
||||
Widget chipRow(
|
||||
List<({String value, String label})> items,
|
||||
String selected,
|
||||
void Function(String) onSelect,
|
||||
) => Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
|
|
@ -1279,10 +1292,7 @@ class _CategoryDropdown extends StatelessWidget {
|
|||
initialValue: selected,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: '',
|
||||
child: Text(l.storeCategoryAll),
|
||||
),
|
||||
PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
|
||||
const PopupMenuDivider(),
|
||||
for (final c in categories)
|
||||
PopupMenuItem(
|
||||
|
|
@ -1419,6 +1429,7 @@ class _ChoiceChip extends StatelessWidget {
|
|||
class _StoreGrid extends StatelessWidget {
|
||||
final List<StoreItem> items;
|
||||
final String locale;
|
||||
|
||||
/// Map of installed module name → installed version. Cards
|
||||
/// compare against `bestVersion` to render an "Update" badge
|
||||
/// when the store offers something newer.
|
||||
|
|
@ -1594,8 +1605,9 @@ class _FeaturedTile extends StatelessWidget {
|
|||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor:
|
||||
theme.colorScheme.primary.withValues(alpha: 0.18),
|
||||
backgroundColor: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
child: Icon(
|
||||
_iconForCategory(item.category),
|
||||
size: 26,
|
||||
|
|
@ -1720,6 +1732,7 @@ class _FeaturedTile extends StatelessWidget {
|
|||
class _StoreCard extends StatelessWidget {
|
||||
final StoreItem item;
|
||||
final String locale;
|
||||
|
||||
/// Installed version for this name, when the operator has it.
|
||||
/// `null` for not-installed entries.
|
||||
final String? installedVersion;
|
||||
|
|
@ -1775,8 +1788,9 @@ class _StoreCard extends StatelessWidget {
|
|||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor:
|
||||
theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
backgroundColor: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
child: Icon(
|
||||
_iconForCategory(item.category),
|
||||
size: 18,
|
||||
|
|
@ -1944,6 +1958,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
String get _locale => Localizations.localeOf(context).languageCode;
|
||||
bool _busy = false;
|
||||
String? _toast;
|
||||
|
||||
/// `null` while the initial probe is in flight; non-null once
|
||||
/// `readInstalledModuleDocs` has returned. The probe is cheap —
|
||||
/// a single file stat on disk — so we run it eagerly when the
|
||||
|
|
@ -1951,6 +1966,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
/// behind a "Load docs" click.
|
||||
({String state, String text, String sourcePath})? _docsResult;
|
||||
bool _docsProbed = false;
|
||||
|
||||
/// Live module-info for installed entries: declared
|
||||
/// permissions and on-disk directory. Pulled lazily so
|
||||
/// not-installed entries don't run an unnecessary RPC. Stays
|
||||
|
|
@ -1980,22 +1996,25 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
Future<void> _probeInstalledDocs() async {
|
||||
final locale = Localizations.localeOf(context).languageCode;
|
||||
try {
|
||||
final r = await HubService.instance
|
||||
.readInstalledModuleDocs(widget.item.name, locale: locale);
|
||||
final r = await HubService.instance.readInstalledModuleDocs(
|
||||
widget.item.name,
|
||||
locale: locale,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _docsResult = r);
|
||||
} catch (_) {
|
||||
// Treat probe failures as "no docs available" — the section
|
||||
// simply hides, the rest of the sheet still works.
|
||||
if (!mounted) return;
|
||||
setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''));
|
||||
setState(
|
||||
() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadModuleDetail() async {
|
||||
try {
|
||||
final detail =
|
||||
await HubService.instance.moduleInfo(widget.item.name);
|
||||
final detail = await HubService.instance.moduleInfo(widget.item.name);
|
||||
if (!mounted) return;
|
||||
setState(() => _moduleDetail = detail);
|
||||
} catch (_) {
|
||||
|
|
@ -2009,7 +2028,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
_toast = null;
|
||||
});
|
||||
try {
|
||||
final r = await HubService.instance.installModule(source: widget.item.name);
|
||||
final r = await HubService.instance.installModule(
|
||||
source: widget.item.name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
setState(() {
|
||||
|
|
@ -2033,8 +2054,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
// more than one is installed side-by-side. Otherwise the
|
||||
// hub picks "highest version" and the operator can be
|
||||
// surprised by what disappeared.
|
||||
final versions =
|
||||
await HubService.instance.installedVersions(widget.item.name);
|
||||
final versions = await HubService.instance.installedVersions(
|
||||
widget.item.name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
String? targetVersion;
|
||||
if (versions.length > 1) {
|
||||
|
|
@ -2189,13 +2211,17 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
if (item.status.isNotEmpty)
|
||||
FaiPill(
|
||||
label: _statusDisplayName(
|
||||
context, item.status),
|
||||
context,
|
||||
item.status,
|
||||
),
|
||||
tone: _toneForStatus(item.status),
|
||||
),
|
||||
if (item.category.isNotEmpty)
|
||||
FaiPill(
|
||||
label: _categoryDisplayName(
|
||||
context, item.category),
|
||||
context,
|
||||
item.category,
|
||||
),
|
||||
tone: FaiPillTone.neutral,
|
||||
),
|
||||
if (item.installed)
|
||||
|
|
@ -2322,8 +2348,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
Icon(
|
||||
_iconForPermission(p),
|
||||
size: 14,
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
SelectableText(
|
||||
|
|
@ -2360,9 +2385,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
itemCount: item.screenshotUrls.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
itemBuilder: (context, i) => _Screenshot(
|
||||
url: item.screenshotUrls[i],
|
||||
),
|
||||
itemBuilder: (context, i) =>
|
||||
_Screenshot(url: item.screenshotUrls[i]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
|
|
@ -2424,16 +2448,18 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.delete_outline, size: 16),
|
||||
label: Text(l.buttonUninstall),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error
|
||||
.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.error.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -2444,8 +2470,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download, size: 16),
|
||||
label: Text(
|
||||
|
|
@ -2480,10 +2507,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
Expanded(
|
||||
child: Text(
|
||||
l.storeNotInstallableInline(item.status),
|
||||
style:
|
||||
theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -2676,9 +2701,13 @@ class _DocsPanel extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// No maxHeight + NeverScrollableScrollPhysics so the docs
|
||||
// section participates in the outer sheet's scroll instead
|
||||
// of opening its own viewport. The previous nested-scroll
|
||||
// form trapped the reader on a 480 px window with two
|
||||
// scrollbars fighting for the wheel.
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxHeight: 480),
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
|
|
@ -2688,6 +2717,7 @@ class _DocsPanel extends StatelessWidget {
|
|||
child: Markdown(
|
||||
data: text,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null || href.isEmpty) return;
|
||||
|
|
@ -2926,6 +2956,7 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
|||
class _StoreTodayHero extends StatelessWidget {
|
||||
final TodayStoryData story;
|
||||
final String locale;
|
||||
|
||||
/// Carousel position (0-based) and total slide count. Drives
|
||||
/// the dot indicator and the visibility of the prev/next
|
||||
/// arrows. When `totalCount == 1` the chrome collapses to a
|
||||
|
|
@ -2936,10 +2967,12 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
final VoidCallback? onPrev;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
/// CTA action — null hides the button. Wired by the parent
|
||||
/// because navigation targets live in the store-page state,
|
||||
/// not in const story data.
|
||||
final VoidCallback? onCta;
|
||||
|
||||
/// Inline one-click "+ Add public source" chips rendered in
|
||||
/// the hero footer. Empty list hides the row — the parent
|
||||
/// passes [_kRecommendedSources] only when the store has zero
|
||||
|
|
@ -3011,11 +3044,7 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
story.icon,
|
||||
size: 24,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.lg),
|
||||
Expanded(
|
||||
|
|
@ -3027,10 +3056,7 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
FaiPill(
|
||||
label: l.storeTodayBadge,
|
||||
tone: FaiPillTone.accent,
|
||||
),
|
||||
FaiPill(label: l.storeTodayBadge, tone: FaiPillTone.accent),
|
||||
Text(
|
||||
badge,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
|
|
@ -3091,13 +3117,15 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(s.icon, size: 14),
|
||||
label: Text('+ ${s.label}'),
|
||||
onPressed:
|
||||
recommendedAdding.contains(s.name) || onAddRecommended == null
|
||||
recommendedAdding.contains(s.name) ||
|
||||
onAddRecommended == null
|
||||
? null
|
||||
: () => onAddRecommended!(s),
|
||||
),
|
||||
|
|
@ -3131,9 +3159,7 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
onPressed: onPrev,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -3141,9 +3167,7 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 2,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i == currentIndex
|
||||
|
|
@ -3178,11 +3202,14 @@ class _RecommendedSource {
|
|||
/// the prefix of every synthetic store entry the server emits
|
||||
/// (`mcp.<name>.<tool>`).
|
||||
final String name;
|
||||
|
||||
/// Display label shown on the card.
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
/// HTTPS Streamable-HTTP endpoint. No auth required.
|
||||
final String endpoint;
|
||||
|
||||
/// Bilingual description used as the MCP-client `description`
|
||||
/// field — keeps the audit log in the operator's locale.
|
||||
final String descriptionEn;
|
||||
|
|
|
|||
|
|
@ -119,16 +119,16 @@ packages:
|
|||
path: "../fai_client_sdk_dart"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.17.1"
|
||||
version: "0.18.0"
|
||||
fai_studio_flow_editor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: main
|
||||
resolved-ref: "1f5601a461097c62140354fb0e9b9bdea598b89c"
|
||||
resolved-ref: b1fe765468d83db97344440fcfd6848a09ec06c2
|
||||
url: "https://git.flemming.ai/fai/studio-flow-editor"
|
||||
source: git
|
||||
version: "0.8.0"
|
||||
version: "0.9.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: fai_studio
|
||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||
publish_to: 'none'
|
||||
version: 0.55.0
|
||||
version: 0.56.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0-200.1.beta
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue