neoagent 2.4.1-beta.36 → 2.4.1-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1240,11 +1240,14 @@ class MemoryPanel extends StatefulWidget {
1240
1240
  State<MemoryPanel> createState() => _MemoryPanelState();
1241
1241
  }
1242
1242
 
1243
- class _MemoryPanelState extends State<MemoryPanel> {
1243
+ class _MemoryPanelState extends State<MemoryPanel>
1244
+ with SingleTickerProviderStateMixin {
1244
1245
  late final TextEditingController _searchController;
1245
1246
  late final TextEditingController _llmPromptController;
1246
1247
  late final TextEditingController _llmImportController;
1248
+ late final TabController _tabController;
1247
1249
  final Set<String> _selectedMemoryIds = <String>{};
1250
+ String? _entityFilter;
1248
1251
  bool _bulkActionInFlight = false;
1249
1252
  bool _llmPromptLoading = false;
1250
1253
  bool _llmImporting = false;
@@ -1257,6 +1260,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
1257
1260
  _searchController = TextEditingController();
1258
1261
  _llmPromptController = TextEditingController();
1259
1262
  _llmImportController = TextEditingController();
1263
+ _tabController = TabController(length: 3, vsync: this);
1260
1264
  }
1261
1265
 
1262
1266
  @override
@@ -1264,62 +1268,41 @@ class _MemoryPanelState extends State<MemoryPanel> {
1264
1268
  _searchController.dispose();
1265
1269
  _llmPromptController.dispose();
1266
1270
  _llmImportController.dispose();
1271
+ _tabController.dispose();
1267
1272
  super.dispose();
1268
1273
  }
1269
1274
 
1270
1275
  Future<void> _loadLlmPrompt(NeoAgentController controller) async {
1271
- if (_llmPromptLoading) {
1272
- return;
1273
- }
1274
- setState(() {
1275
- _llmPromptLoading = true;
1276
- });
1276
+ if (_llmPromptLoading) return;
1277
+ setState(() => _llmPromptLoading = true);
1277
1278
  try {
1278
1279
  final prompt = await controller.fetchMemoryTransferPrompt();
1279
- if (!mounted) {
1280
- return;
1281
- }
1282
- setState(() {
1283
- _llmPromptController.text = prompt;
1284
- });
1280
+ if (!mounted) return;
1281
+ setState(() => _llmPromptController.text = prompt);
1285
1282
  } catch (error) {
1286
- if (!mounted) {
1287
- return;
1288
- }
1283
+ if (!mounted) return;
1289
1284
  ScaffoldMessenger.of(context).showSnackBar(
1290
1285
  SnackBar(content: Text('Failed to generate prompt: $error')),
1291
1286
  );
1292
1287
  } finally {
1293
- if (mounted) {
1294
- setState(() {
1295
- _llmPromptLoading = false;
1296
- });
1297
- }
1288
+ if (mounted) setState(() => _llmPromptLoading = false);
1298
1289
  }
1299
1290
  }
1300
1291
 
1301
1292
  Future<void> _copyLlmPrompt() async {
1302
1293
  final prompt = _llmPromptController.text.trim();
1303
- if (prompt.isEmpty) {
1304
- return;
1305
- }
1294
+ if (prompt.isEmpty) return;
1306
1295
  await Clipboard.setData(ClipboardData(text: prompt));
1307
- if (!mounted) {
1308
- return;
1309
- }
1296
+ if (!mounted) return;
1310
1297
  ScaffoldMessenger.of(
1311
1298
  context,
1312
1299
  ).showSnackBar(const SnackBar(content: Text('Prompt copied.')));
1313
1300
  }
1314
1301
 
1315
1302
  Future<void> _importLlmMemories(NeoAgentController controller) async {
1316
- if (_llmImporting) {
1317
- return;
1318
- }
1303
+ if (_llmImporting) return;
1319
1304
  final text = _llmImportController.text.trim();
1320
- if (text.isEmpty) {
1321
- return;
1322
- }
1305
+ if (text.isEmpty) return;
1323
1306
  final confirmImport = await showDialog<bool>(
1324
1307
  context: context,
1325
1308
  builder: (context) {
@@ -1328,11 +1311,12 @@ class _MemoryPanelState extends State<MemoryPanel> {
1328
1311
  if (_llmApplyCoreMemory) 'core memory',
1329
1312
  'memories',
1330
1313
  ];
1331
- final targetLabel = applyTargets.join(', ');
1332
1314
  return AlertDialog(
1333
1315
  backgroundColor: _bgCard,
1334
1316
  title: Text('Import memory transfer?'),
1335
- content: Text('This will import the response into $targetLabel.'),
1317
+ content: Text(
1318
+ 'This will import the response into ${applyTargets.join(', ')}.',
1319
+ ),
1336
1320
  actions: <Widget>[
1337
1321
  TextButton(
1338
1322
  onPressed: () => Navigator.of(context).pop(false),
@@ -1346,25 +1330,18 @@ class _MemoryPanelState extends State<MemoryPanel> {
1346
1330
  );
1347
1331
  },
1348
1332
  );
1349
- if (confirmImport != true) {
1350
- return;
1351
- }
1352
- setState(() {
1353
- _llmImporting = true;
1354
- });
1333
+ if (confirmImport != true) return;
1334
+ setState(() => _llmImporting = true);
1355
1335
  try {
1356
1336
  final result = await controller.importMemoryTransfer(
1357
1337
  text,
1358
1338
  applyBehaviorNotes: _llmApplyBehaviorNotes,
1359
1339
  applyCoreMemory: _llmApplyCoreMemory,
1360
1340
  );
1361
- if (!mounted) {
1362
- return;
1363
- }
1341
+ if (!mounted) return;
1364
1342
  _llmImportController.clear();
1365
- final warningText = result.warnings.isEmpty
1366
- ? ''
1367
- : ' ${result.warnings.join(' ')}';
1343
+ final warningText =
1344
+ result.warnings.isEmpty ? '' : ' ${result.warnings.join(' ')}';
1368
1345
  ScaffoldMessenger.of(context).showSnackBar(
1369
1346
  SnackBar(
1370
1347
  content: Text(
@@ -1376,30 +1353,30 @@ class _MemoryPanelState extends State<MemoryPanel> {
1376
1353
  ),
1377
1354
  );
1378
1355
  } catch (error) {
1379
- if (!mounted) {
1380
- return;
1381
- }
1356
+ if (!mounted) return;
1382
1357
  ScaffoldMessenger.of(
1383
1358
  context,
1384
1359
  ).showSnackBar(SnackBar(content: Text('Import failed: $error')));
1385
1360
  } finally {
1386
- if (mounted) {
1387
- setState(() {
1388
- _llmImporting = false;
1389
- });
1390
- }
1361
+ if (mounted) setState(() => _llmImporting = false);
1391
1362
  }
1392
1363
  }
1393
1364
 
1394
1365
  List<MemoryItem> get _visibleMemories {
1395
1366
  final controller = widget.controller;
1396
- return controller.memoryRecallResults.isNotEmpty
1367
+ final base = controller.memoryRecallResults.isNotEmpty
1397
1368
  ? controller.memoryRecallResults
1398
1369
  : controller.memories;
1370
+ if (_entityFilter == null) return base;
1371
+ return base
1372
+ .where(
1373
+ (m) => m.entities.any((e) => e.name == _entityFilter),
1374
+ )
1375
+ .toList();
1399
1376
  }
1400
1377
 
1401
1378
  List<String> get _selectedVisibleMemoryIds {
1402
- final visibleIds = _visibleMemories.map((memory) => memory.id).toSet();
1379
+ final visibleIds = _visibleMemories.map((m) => m.id).toSet();
1403
1380
  return _selectedMemoryIds
1404
1381
  .where(visibleIds.contains)
1405
1382
  .toList(growable: false);
@@ -1416,20 +1393,14 @@ class _MemoryPanelState extends State<MemoryPanel> {
1416
1393
  }
1417
1394
 
1418
1395
  void _clearMemorySelection() {
1419
- if (_selectedMemoryIds.isEmpty) {
1420
- return;
1421
- }
1422
- setState(() {
1423
- _selectedMemoryIds.clear();
1424
- });
1396
+ if (_selectedMemoryIds.isEmpty) return;
1397
+ setState(() => _selectedMemoryIds.clear());
1425
1398
  }
1426
1399
 
1427
1400
  void _selectAllVisibleMemories(List<MemoryItem> memories) {
1428
- if (memories.isEmpty) {
1429
- return;
1430
- }
1401
+ if (memories.isEmpty) return;
1431
1402
  setState(() {
1432
- _selectedMemoryIds.addAll(memories.map((memory) => memory.id));
1403
+ _selectedMemoryIds.addAll(memories.map((m) => m.id));
1433
1404
  });
1434
1405
  }
1435
1406
 
@@ -1446,6 +1417,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
1446
1417
  void _resetMemorySearch(NeoAgentController controller) {
1447
1418
  _searchController.clear();
1448
1419
  _clearMemorySelection();
1420
+ setState(() => _entityFilter = null);
1449
1421
  controller.clearMemorySearch();
1450
1422
  }
1451
1423
 
@@ -1454,12 +1426,8 @@ class _MemoryPanelState extends State<MemoryPanel> {
1454
1426
  String id,
1455
1427
  ) async {
1456
1428
  await controller.deleteMemory(id);
1457
- if (!mounted) {
1458
- return;
1459
- }
1460
- setState(() {
1461
- _selectedMemoryIds.remove(id);
1462
- });
1429
+ if (!mounted) return;
1430
+ setState(() => _selectedMemoryIds.remove(id));
1463
1431
  }
1464
1432
 
1465
1433
  Future<void> _runBulkMemoryAction({
@@ -1469,55 +1437,50 @@ class _MemoryPanelState extends State<MemoryPanel> {
1469
1437
  required Future<void> Function(List<String> ids) onConfirm,
1470
1438
  }) async {
1471
1439
  final ids = _selectedVisibleMemoryIds;
1472
- if (ids.isEmpty || _bulkActionInFlight) {
1473
- return;
1474
- }
1440
+ if (ids.isEmpty || _bulkActionInFlight) return;
1475
1441
  await _confirmDelete(
1476
1442
  context,
1477
1443
  title: title,
1478
1444
  message: message,
1479
1445
  confirmLabel: confirmLabel,
1480
1446
  onConfirm: () async {
1481
- setState(() {
1482
- _bulkActionInFlight = true;
1483
- });
1447
+ setState(() => _bulkActionInFlight = true);
1484
1448
  try {
1485
1449
  await onConfirm(ids);
1486
- if (!mounted) {
1487
- return;
1488
- }
1489
- setState(() {
1490
- _selectedMemoryIds.removeAll(ids);
1491
- });
1450
+ if (!mounted) return;
1451
+ setState(() => _selectedMemoryIds.removeAll(ids));
1492
1452
  } finally {
1493
- if (mounted) {
1494
- setState(() {
1495
- _bulkActionInFlight = false;
1496
- });
1497
- }
1453
+ if (mounted) setState(() => _bulkActionInFlight = false);
1498
1454
  }
1499
1455
  },
1500
1456
  );
1501
1457
  }
1502
1458
 
1459
+ void _onEntityTapped(String entityName) {
1460
+ setState(() {
1461
+ _entityFilter = _entityFilter == entityName ? null : entityName;
1462
+ _tabController.animateTo(0);
1463
+ });
1464
+ }
1465
+
1503
1466
  @override
1504
1467
  Widget build(BuildContext context) {
1505
1468
  final controller = widget.controller;
1469
+ final stats = controller.memoryOverview.stats;
1506
1470
  final memoriesToShow = _visibleMemories;
1507
- final selectedMemoryIds = _selectedVisibleMemoryIds.toSet();
1508
- final selectedCount = selectedMemoryIds.length;
1509
- final allVisibleSelected =
1510
- memoriesToShow.isNotEmpty &&
1511
- memoriesToShow.every((memory) => selectedMemoryIds.contains(memory.id));
1471
+ final selectedIds = _selectedVisibleMemoryIds.toSet();
1472
+ final selectedCount = selectedIds.length;
1473
+ final allVisibleSelected = memoriesToShow.isNotEmpty &&
1474
+ memoriesToShow.every((m) => selectedIds.contains(m.id));
1512
1475
  final showingSearchResults = controller.memoryRecallResults.isNotEmpty;
1476
+ final compact = MediaQuery.sizeOf(context).width < 760;
1513
1477
 
1514
1478
  return ListView(
1515
1479
  padding: _pagePadding(context),
1516
1480
  children: <Widget>[
1517
1481
  _PageTitle(
1518
1482
  title: 'Memory',
1519
- subtitle:
1520
- 'Structured facts, entities, reflections, long-term recall, and behavior notes.',
1483
+ subtitle: 'Long-term recall, structured facts, and knowledge graph.',
1521
1484
  trailing: Wrap(
1522
1485
  spacing: 10,
1523
1486
  runSpacing: 10,
@@ -1535,628 +1498,554 @@ class _MemoryPanelState extends State<MemoryPanel> {
1535
1498
  ],
1536
1499
  ),
1537
1500
  ),
1538
- Row(
1539
- children: <Widget>[
1540
- Expanded(
1541
- child: _OverviewCard(
1542
- title: 'Active Memories',
1543
- value: '${controller.memoryOverview.stats.active}',
1544
- helper: 'Recallable long-term entries',
1545
- ),
1546
- ),
1547
- const SizedBox(width: 12),
1548
- Expanded(
1549
- child: _OverviewCard(
1550
- title: 'Extracted Facts',
1551
- value: '${controller.memoryOverview.stats.facts}',
1552
- helper: 'Structured statements for recall',
1553
- ),
1554
- ),
1555
- const SizedBox(width: 12),
1556
- Expanded(
1557
- child: _OverviewCard(
1558
- title: 'Entities',
1559
- value: '${controller.memoryOverview.stats.entities}',
1560
- helper: 'People, projects, files, and concepts',
1561
- ),
1562
- ),
1563
- const SizedBox(width: 12),
1564
- Expanded(
1565
- child: _OverviewCard(
1566
- title: 'Reflections',
1567
- value: '${controller.memoryOverview.stats.knowledgeViews}',
1568
- helper: 'Materialized knowledge views',
1501
+
1502
+ // --- Stats bar ---
1503
+ _EntranceMotion(
1504
+ child: Card(
1505
+ child: Padding(
1506
+ padding: const EdgeInsets.all(18),
1507
+ child: Row(
1508
+ children: <Widget>[
1509
+ _MemoryConfidenceGauge(
1510
+ confidence: stats.averageConfidence,
1511
+ ),
1512
+ const SizedBox(width: 18),
1513
+ Expanded(
1514
+ child: Wrap(
1515
+ spacing: 10,
1516
+ runSpacing: 10,
1517
+ children: <Widget>[
1518
+ _MemoryStatChip(
1519
+ label: '${stats.active}',
1520
+ caption: 'Active',
1521
+ icon: Icons.memory_outlined,
1522
+ ),
1523
+ _MemoryStatChip(
1524
+ label: '${stats.facts}',
1525
+ caption: 'Facts',
1526
+ icon: Icons.fact_check_outlined,
1527
+ ),
1528
+ _MemoryStatChip(
1529
+ label: '${stats.entities}',
1530
+ caption: 'Entities',
1531
+ icon: Icons.hub_outlined,
1532
+ ),
1533
+ _MemoryStatChip(
1534
+ label: '${stats.knowledgeViews}',
1535
+ caption: 'Reflections',
1536
+ icon: Icons.auto_stories_outlined,
1537
+ ),
1538
+ _MemoryStatChip(
1539
+ label: '${stats.ingestionDocuments}',
1540
+ caption: 'Docs',
1541
+ icon: Icons.source_outlined,
1542
+ ),
1543
+ _MemoryStatChip(
1544
+ label: stats.averageImportance.toStringAsFixed(1),
1545
+ caption: 'Avg imp.',
1546
+ icon: Icons.priority_high_outlined,
1547
+ ),
1548
+ ],
1549
+ ),
1550
+ ),
1551
+ ],
1569
1552
  ),
1570
1553
  ),
1571
- ],
1554
+ ),
1572
1555
  ),
1556
+
1573
1557
  const SizedBox(height: 16),
1574
- Card(
1575
- child: Padding(
1576
- padding: const EdgeInsets.all(18),
1577
- child: Column(
1578
- crossAxisAlignment: CrossAxisAlignment.start,
1579
- children: <Widget>[
1580
- const _SectionTitle('Recall Search'),
1581
- const SizedBox(height: 12),
1582
- Row(
1558
+
1559
+ // --- Entity knowledge graph ---
1560
+ if (controller.memoryOverview.entities.isNotEmpty) ...<Widget>[
1561
+ _EntranceMotion(
1562
+ child: Card(
1563
+ child: Padding(
1564
+ padding: const EdgeInsets.all(18),
1565
+ child: Column(
1566
+ crossAxisAlignment: CrossAxisAlignment.start,
1583
1567
  children: <Widget>[
1584
- Expanded(
1585
- child: TextField(
1586
- controller: _searchController,
1587
- decoration: const InputDecoration(
1588
- labelText: 'Search memory',
1568
+ Row(
1569
+ children: <Widget>[
1570
+ Expanded(
1571
+ child: const _SectionTitle('Knowledge Graph'),
1589
1572
  ),
1590
- onSubmitted: (_) => _runMemorySearch(controller),
1591
- ),
1573
+ if (_entityFilter != null)
1574
+ TextButton.icon(
1575
+ onPressed: () =>
1576
+ setState(() => _entityFilter = null),
1577
+ icon: Icon(Icons.close, size: 16),
1578
+ label: Text('Clear filter'),
1579
+ ),
1580
+ ],
1592
1581
  ),
1593
- const SizedBox(width: 10),
1594
- FilledButton(
1595
- onPressed: () => _runMemorySearch(controller),
1596
- child: Text('Search'),
1582
+ const SizedBox(height: 4),
1583
+ Text(
1584
+ 'Tap an entity to filter memories by it.',
1585
+ style: TextStyle(
1586
+ color: _textSecondary,
1587
+ fontSize: 12,
1588
+ ),
1597
1589
  ),
1598
- const SizedBox(width: 10),
1599
- OutlinedButton(
1600
- onPressed: () => _resetMemorySearch(controller),
1601
- child: Text('Reset'),
1590
+ const SizedBox(height: 14),
1591
+ SizedBox(
1592
+ height: compact ? 260 : 320,
1593
+ child: _EntityGraphView(
1594
+ entities: controller.memoryOverview.entities,
1595
+ knowledgeViews:
1596
+ controller.memoryOverview.knowledgeViews,
1597
+ selectedEntity: _entityFilter,
1598
+ onEntityTapped: _onEntityTapped,
1599
+ ),
1602
1600
  ),
1603
1601
  ],
1604
1602
  ),
1605
- ],
1603
+ ),
1606
1604
  ),
1607
1605
  ),
1608
- ),
1609
- const SizedBox(height: 16),
1610
- Card(
1611
- child: Padding(
1612
- padding: const EdgeInsets.all(18),
1606
+ const SizedBox(height: 16),
1607
+ ],
1608
+
1609
+ // --- Tabbed content ---
1610
+ _EntranceMotion(
1611
+ child: Card(
1613
1612
  child: Column(
1614
- crossAxisAlignment: CrossAxisAlignment.start,
1615
1613
  children: <Widget>[
1616
- const _SectionTitle('Memory Intelligence'),
1617
- const SizedBox(height: 12),
1618
- Wrap(
1619
- spacing: 10,
1620
- runSpacing: 10,
1621
- children: <Widget>[
1622
- _MetaPill(
1623
- label:
1624
- 'Confidence ${(controller.memoryOverview.stats.averageConfidence * 100).round()}%',
1625
- icon: Icons.verified_outlined,
1626
- ),
1627
- _MetaPill(
1628
- label:
1629
- 'Avg importance ${controller.memoryOverview.stats.averageImportance.toStringAsFixed(1)}',
1630
- icon: Icons.priority_high_outlined,
1631
- ),
1632
- _MetaPill(
1633
- label:
1634
- '${controller.memoryOverview.stats.ingestionDocuments} ingested docs',
1635
- icon: Icons.source_outlined,
1636
- ),
1637
- ],
1638
- ),
1639
- const SizedBox(height: 16),
1640
- if (controller.memoryOverview.entities.isNotEmpty) ...<Widget>[
1641
- Text(
1642
- 'Top entities',
1643
- style: TextStyle(fontWeight: FontWeight.w700),
1644
- ),
1645
- const SizedBox(height: 8),
1646
- Wrap(
1647
- spacing: 8,
1648
- runSpacing: 8,
1649
- children: controller.memoryOverview.entities.map((entity) {
1650
- return _MetaPill(
1651
- label: entity.name,
1652
- icon: Icons.hub_outlined,
1653
- );
1654
- }).toList(),
1655
- ),
1656
- const SizedBox(height: 16),
1657
- ],
1658
- if (controller
1659
- .memoryOverview
1660
- .knowledgeViews
1661
- .isNotEmpty) ...<Widget>[
1662
- Text(
1663
- 'Reflections',
1664
- style: TextStyle(fontWeight: FontWeight.w700),
1614
+ TabBar(
1615
+ controller: _tabController,
1616
+ labelColor: _accentHover,
1617
+ unselectedLabelColor: _textSecondary,
1618
+ indicatorColor: _accent,
1619
+ indicatorSize: TabBarIndicatorSize.label,
1620
+ dividerColor: _border,
1621
+ labelStyle: const TextStyle(
1622
+ fontWeight: FontWeight.w700,
1623
+ fontSize: 13,
1624
+ letterSpacing: 0.3,
1665
1625
  ),
1666
- const SizedBox(height: 8),
1667
- ...controller.memoryOverview.knowledgeViews.take(5).map((
1668
- view,
1669
- ) {
1670
- return Container(
1671
- width: double.infinity,
1672
- margin: const EdgeInsets.only(bottom: 10),
1673
- padding: const EdgeInsets.all(12),
1674
- decoration: BoxDecoration(
1675
- color: _bgSecondary,
1676
- borderRadius: BorderRadius.circular(8),
1677
- border: Border.all(color: _border),
1678
- ),
1679
- child: Column(
1680
- crossAxisAlignment: CrossAxisAlignment.start,
1626
+ tabs: <Widget>[
1627
+ Tab(
1628
+ child: Row(
1629
+ mainAxisSize: MainAxisSize.min,
1681
1630
  children: <Widget>[
1682
- Row(
1683
- children: <Widget>[
1684
- Expanded(
1685
- child: Text(
1686
- view.title,
1687
- style: TextStyle(fontWeight: FontWeight.w700),
1688
- ),
1689
- ),
1690
- _MetaPill(
1691
- label: view.viewType,
1692
- icon: Icons.auto_stories_outlined,
1693
- ),
1694
- ],
1695
- ),
1696
- if (view.summary.trim().isNotEmpty) ...<Widget>[
1697
- const SizedBox(height: 8),
1698
- Text(
1699
- view.summary,
1700
- maxLines: 4,
1701
- overflow: TextOverflow.ellipsis,
1702
- style: TextStyle(color: _textSecondary),
1703
- ),
1704
- ],
1631
+ Icon(Icons.psychology_outlined, size: 16),
1632
+ const SizedBox(width: 6),
1633
+ Text('Memories'),
1705
1634
  ],
1706
1635
  ),
1707
- );
1708
- }),
1709
- ],
1710
- if (controller.memoryOverview.entities.isEmpty &&
1711
- controller.memoryOverview.knowledgeViews.isEmpty)
1712
- Text(
1713
- 'No structured entities or reflections yet.',
1714
- style: TextStyle(color: _textSecondary),
1715
- ),
1716
- ],
1717
- ),
1718
- ),
1719
- ),
1720
- const SizedBox(height: 16),
1721
- Card(
1722
- child: Theme(
1723
- data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
1724
- child: ExpansionTile(
1725
- initiallyExpanded: false,
1726
- tilePadding: const EdgeInsets.symmetric(
1727
- horizontal: 18,
1728
- vertical: 6,
1729
- ),
1730
- childrenPadding: const EdgeInsets.fromLTRB(18, 0, 18, 18),
1731
- leading: Icon(Icons.swap_horiz_outlined, color: _textSecondary),
1732
- title: const _SectionTitle('LLM Memory Transfer'),
1733
- subtitle: Text(
1734
- 'Export/import memories with another AI in one shot.',
1735
- style: TextStyle(color: _textSecondary),
1736
- ),
1737
- children: <Widget>[
1738
- Text(
1739
- 'Generate a prompt to use in another AI, then paste the response here to import memories.',
1740
- style: TextStyle(color: _textSecondary),
1741
- ),
1742
- const SizedBox(height: 12),
1743
- Wrap(
1744
- spacing: 10,
1745
- runSpacing: 10,
1746
- children: <Widget>[
1747
- FilledButton.icon(
1748
- onPressed: _llmPromptLoading
1749
- ? null
1750
- : () => _loadLlmPrompt(controller),
1751
- icon: Icon(Icons.auto_awesome_outlined),
1752
- label: Text(
1753
- _llmPromptLoading ? 'Generating...' : 'Generate Prompt',
1754
- ),
1755
- ),
1756
- OutlinedButton.icon(
1757
- onPressed: _llmPromptController.text.trim().isEmpty
1758
- ? null
1759
- : _copyLlmPrompt,
1760
- icon: Icon(Icons.copy_all_outlined),
1761
- label: Text('Copy Prompt'),
1762
- ),
1763
- ],
1764
- ),
1765
- const SizedBox(height: 12),
1766
- TextField(
1767
- controller: _llmPromptController,
1768
- minLines: 6,
1769
- maxLines: 10,
1770
- readOnly: true,
1771
- decoration: const InputDecoration(
1772
- labelText: 'Prompt to paste into another AI',
1773
- ),
1774
- ),
1775
- const SizedBox(height: 12),
1776
- SwitchListTile.adaptive(
1777
- contentPadding: EdgeInsets.zero,
1778
- value: _llmApplyBehaviorNotes,
1779
- onChanged: _llmImporting
1780
- ? null
1781
- : (value) {
1782
- setState(() {
1783
- _llmApplyBehaviorNotes = value;
1784
- });
1785
- },
1786
- title: Text('Apply behavior notes'),
1787
- subtitle: Text(
1788
- 'Overwrite assistant behavior notes from the import.',
1789
- ),
1790
- ),
1791
- SwitchListTile.adaptive(
1792
- contentPadding: EdgeInsets.zero,
1793
- value: _llmApplyCoreMemory,
1794
- onChanged: _llmImporting
1795
- ? null
1796
- : (value) {
1797
- setState(() {
1798
- _llmApplyCoreMemory = value;
1799
- });
1800
- },
1801
- title: Text('Apply core memory'),
1802
- subtitle: Text(
1803
- 'Update core memory key/value entries from the import.',
1804
- ),
1805
- ),
1806
- const SizedBox(height: 16),
1807
- Text(
1808
- 'Paste the response from the other AI below, then import.',
1809
- style: TextStyle(color: _textSecondary),
1810
- ),
1811
- const SizedBox(height: 12),
1812
- TextField(
1813
- controller: _llmImportController,
1814
- minLines: 6,
1815
- maxLines: 12,
1816
- decoration: const InputDecoration(
1817
- labelText: 'LLM memory export response',
1818
- ),
1819
- ),
1820
- const SizedBox(height: 12),
1821
- FilledButton.icon(
1822
- onPressed: _llmImporting
1823
- ? null
1824
- : () => _importLlmMemories(controller),
1825
- icon: Icon(Icons.file_download_outlined),
1826
- label: Text(_llmImporting ? 'Importing...' : 'Import'),
1827
- ),
1828
- ],
1829
- ),
1830
- ),
1831
- ),
1832
- const SizedBox(height: 16),
1833
- Card(
1834
- child: Padding(
1835
- padding: const EdgeInsets.all(18),
1836
- child: Column(
1837
- crossAxisAlignment: CrossAxisAlignment.start,
1838
- children: <Widget>[
1839
- Row(
1840
- children: <Widget>[
1841
- Expanded(child: _SectionTitle('Core Memory')),
1842
- TextButton.icon(
1843
- onPressed: () =>
1844
- _openCoreMemoryEditor(context, controller),
1845
- icon: Icon(Icons.add),
1846
- label: Text('Add Entry'),
1847
1636
  ),
1848
- ],
1849
- ),
1850
- const SizedBox(height: 10),
1851
- if (controller.memoryOverview.coreEntries.isEmpty)
1852
- Text(
1853
- 'No core memory entries yet.',
1854
- style: TextStyle(color: _textSecondary),
1855
- )
1856
- else
1857
- ...controller.memoryOverview.coreEntries.entries.map((entry) {
1858
- return Container(
1859
- width: double.infinity,
1860
- margin: const EdgeInsets.only(bottom: 10),
1861
- padding: const EdgeInsets.all(12),
1862
- decoration: BoxDecoration(
1863
- color: _bgSecondary,
1864
- borderRadius: BorderRadius.circular(12),
1865
- border: Border.all(color: _border),
1637
+ Tab(
1638
+ child: Row(
1639
+ mainAxisSize: MainAxisSize.min,
1640
+ children: <Widget>[
1641
+ Icon(Icons.push_pin_outlined, size: 16),
1642
+ const SizedBox(width: 6),
1643
+ Text('Core'),
1644
+ ],
1866
1645
  ),
1646
+ ),
1647
+ Tab(
1867
1648
  child: Row(
1868
- crossAxisAlignment: CrossAxisAlignment.start,
1649
+ mainAxisSize: MainAxisSize.min,
1869
1650
  children: <Widget>[
1870
- Expanded(
1871
- child: Column(
1872
- crossAxisAlignment: CrossAxisAlignment.start,
1873
- children: <Widget>[
1874
- Text(
1875
- entry.key,
1876
- style: TextStyle(fontWeight: FontWeight.w700),
1877
- ),
1878
- const SizedBox(height: 6),
1879
- Text(entry.value.toString()),
1880
- ],
1881
- ),
1882
- ),
1883
- IconButton(
1884
- onPressed: () => _openCoreMemoryEditor(
1885
- context,
1886
- controller,
1887
- keyValue: entry,
1888
- ),
1889
- icon: Icon(Icons.edit_outlined),
1890
- ),
1891
- IconButton(
1892
- onPressed: () => _confirmDelete(
1893
- context,
1894
- title: 'Delete core memory entry?',
1895
- message:
1896
- 'Remove "${entry.key}" from core memory.',
1897
- onConfirm: () =>
1898
- controller.deleteCoreMemory(entry.key),
1899
- ),
1900
- icon: Icon(Icons.delete_outline),
1901
- ),
1651
+ Icon(Icons.swap_horiz_outlined, size: 16),
1652
+ const SizedBox(width: 6),
1653
+ Text('Transfer'),
1902
1654
  ],
1903
1655
  ),
1904
- );
1905
- }),
1906
- ],
1907
- ),
1908
- ),
1909
- ),
1910
- const SizedBox(height: 16),
1911
- Card(
1912
- child: Padding(
1913
- padding: const EdgeInsets.all(18),
1914
- child: Column(
1915
- crossAxisAlignment: CrossAxisAlignment.start,
1916
- children: <Widget>[
1917
- const _SectionTitle('Memories'),
1918
- const SizedBox(height: 6),
1919
- Text(
1920
- showingSearchResults
1921
- ? 'Showing search results. Select memories to archive or delete them together.'
1922
- : 'Select one or more memories to archive or delete them together.',
1923
- style: TextStyle(color: _textSecondary),
1656
+ ),
1657
+ ],
1924
1658
  ),
1925
- const SizedBox(height: 10),
1926
- if (memoriesToShow.isNotEmpty)
1927
- Wrap(
1928
- spacing: 10,
1929
- runSpacing: 10,
1930
- crossAxisAlignment: WrapCrossAlignment.center,
1931
- children: <Widget>[
1932
- OutlinedButton.icon(
1933
- onPressed: allVisibleSelected || _bulkActionInFlight
1934
- ? null
1935
- : () => _selectAllVisibleMemories(memoriesToShow),
1936
- icon: Icon(Icons.done_all_outlined),
1937
- label: Text(
1938
- allVisibleSelected ? 'All Selected' : 'Select All',
1939
- ),
1940
- ),
1941
- OutlinedButton.icon(
1942
- onPressed: selectedCount == 0 || _bulkActionInFlight
1943
- ? null
1944
- : _clearMemorySelection,
1945
- icon: Icon(Icons.deselect_outlined),
1946
- label: Text('Clear Selection'),
1947
- ),
1948
- if (selectedCount > 0)
1949
- FilledButton.icon(
1950
- onPressed: _bulkActionInFlight
1951
- ? null
1952
- : () => _runBulkMemoryAction(
1953
- title: 'Archive selected memories?',
1954
- message:
1955
- 'Archive $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'}? Archived memories are removed from the main list.',
1956
- confirmLabel: 'Archive',
1957
- onConfirm: controller.archiveMemories,
1659
+ AnimatedBuilder(
1660
+ animation: _tabController,
1661
+ builder: (context, _) {
1662
+ return IndexedStack(
1663
+ index: _tabController.index,
1664
+ children: <Widget>[
1665
+ // --- Memories tab ---
1666
+ Padding(
1667
+ padding: const EdgeInsets.all(18),
1668
+ child: Column(
1669
+ crossAxisAlignment: CrossAxisAlignment.start,
1670
+ children: <Widget>[
1671
+ Row(
1672
+ children: <Widget>[
1673
+ Expanded(
1674
+ child: TextField(
1675
+ controller: _searchController,
1676
+ decoration: const InputDecoration(
1677
+ labelText: 'Search memory',
1678
+ prefixIcon: Icon(
1679
+ Icons.search,
1680
+ size: 18,
1681
+ ),
1682
+ ),
1683
+ onSubmitted: (_) =>
1684
+ _runMemorySearch(controller),
1685
+ ),
1686
+ ),
1687
+ const SizedBox(width: 10),
1688
+ FilledButton(
1689
+ onPressed: () =>
1690
+ _runMemorySearch(controller),
1691
+ child: Text('Search'),
1692
+ ),
1693
+ if (showingSearchResults ||
1694
+ _entityFilter != null) ...<Widget>[
1695
+ const SizedBox(width: 10),
1696
+ OutlinedButton(
1697
+ onPressed: () =>
1698
+ _resetMemorySearch(controller),
1699
+ child: Text('Reset'),
1700
+ ),
1701
+ ],
1702
+ ],
1703
+ ),
1704
+ if (_entityFilter != null) ...<Widget>[
1705
+ const SizedBox(height: 10),
1706
+ Wrap(
1707
+ spacing: 8,
1708
+ children: <Widget>[
1709
+ _MetaPill(
1710
+ label: 'Entity: $_entityFilter',
1711
+ icon: Icons.filter_alt_outlined,
1712
+ color: _accent,
1713
+ ),
1714
+ ],
1958
1715
  ),
1959
- icon: Icon(Icons.archive_outlined),
1960
- label: Text('Archive ($selectedCount)'),
1961
- ),
1962
- if (selectedCount > 0)
1963
- OutlinedButton.icon(
1964
- onPressed: _bulkActionInFlight
1965
- ? null
1966
- : () => _runBulkMemoryAction(
1967
- title: 'Delete selected memories?',
1968
- message:
1969
- 'Delete $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'} permanently?',
1970
- confirmLabel: 'Delete',
1971
- onConfirm: controller.deleteMemories,
1716
+ ],
1717
+ if (memoriesToShow.isNotEmpty) ...<Widget>[
1718
+ const SizedBox(height: 12),
1719
+ Wrap(
1720
+ spacing: 8,
1721
+ runSpacing: 8,
1722
+ crossAxisAlignment:
1723
+ WrapCrossAlignment.center,
1724
+ children: <Widget>[
1725
+ OutlinedButton.icon(
1726
+ onPressed: allVisibleSelected ||
1727
+ _bulkActionInFlight
1728
+ ? null
1729
+ : () => _selectAllVisibleMemories(
1730
+ memoriesToShow),
1731
+ icon: Icon(
1732
+ Icons.done_all_outlined,
1733
+ size: 16,
1734
+ ),
1735
+ label: Text(
1736
+ allVisibleSelected
1737
+ ? 'All Selected'
1738
+ : 'Select All',
1739
+ ),
1740
+ ),
1741
+ if (selectedCount > 0) ...<Widget>[
1742
+ OutlinedButton.icon(
1743
+ onPressed: _bulkActionInFlight
1744
+ ? null
1745
+ : _clearMemorySelection,
1746
+ icon: Icon(
1747
+ Icons.deselect_outlined,
1748
+ size: 16,
1749
+ ),
1750
+ label: Text('Clear'),
1751
+ ),
1752
+ FilledButton.icon(
1753
+ onPressed: _bulkActionInFlight
1754
+ ? null
1755
+ : () => _runBulkMemoryAction(
1756
+ title:
1757
+ 'Archive selected memories?',
1758
+ message:
1759
+ 'Archive $selectedCount ${selectedCount == 1 ? 'memory' : 'memories'}?',
1760
+ confirmLabel: 'Archive',
1761
+ onConfirm:
1762
+ controller.archiveMemories,
1763
+ ),
1764
+ icon: Icon(
1765
+ Icons.archive_outlined,
1766
+ size: 16,
1767
+ ),
1768
+ label: Text(
1769
+ 'Archive ($selectedCount)',
1770
+ ),
1771
+ ),
1772
+ OutlinedButton.icon(
1773
+ onPressed: _bulkActionInFlight
1774
+ ? null
1775
+ : () => _runBulkMemoryAction(
1776
+ title:
1777
+ 'Delete selected memories?',
1778
+ message:
1779
+ 'Delete $selectedCount ${selectedCount == 1 ? 'memory' : 'memories'} permanently?',
1780
+ confirmLabel: 'Delete',
1781
+ onConfirm:
1782
+ controller.deleteMemories,
1783
+ ),
1784
+ icon: Icon(
1785
+ Icons.delete_sweep_outlined,
1786
+ size: 16,
1787
+ ),
1788
+ label: Text(
1789
+ 'Delete ($selectedCount)',
1790
+ ),
1791
+ ),
1792
+ ],
1793
+ ],
1972
1794
  ),
1973
- icon: Icon(Icons.delete_sweep_outlined),
1974
- label: Text('Delete ($selectedCount)'),
1975
- ),
1976
- ],
1977
- ),
1978
- if (selectedCount > 0) ...<Widget>[
1979
- const SizedBox(height: 10),
1980
- Text(
1981
- '$selectedCount selected',
1982
- style: TextStyle(
1983
- color: _textSecondary,
1984
- fontWeight: FontWeight.w600,
1985
- ),
1986
- ),
1987
- ],
1988
- if (memoriesToShow.isNotEmpty) const SizedBox(height: 10),
1989
- if (memoriesToShow.isEmpty)
1990
- Text(
1991
- 'No memory entries found.',
1992
- style: TextStyle(color: _textSecondary),
1993
- )
1994
- else
1995
- ...memoriesToShow.map((memory) {
1996
- final isSelected = selectedMemoryIds.contains(memory.id);
1997
- return Container(
1998
- width: double.infinity,
1999
- margin: const EdgeInsets.only(bottom: 10),
2000
- decoration: BoxDecoration(
2001
- color: isSelected ? _accentMuted : _bgSecondary,
2002
- borderRadius: BorderRadius.circular(12),
2003
- border: Border.all(
2004
- color: isSelected ? _accent : _border,
1795
+ ],
1796
+ const SizedBox(height: 12),
1797
+ if (memoriesToShow.isEmpty)
1798
+ Text(
1799
+ _entityFilter != null
1800
+ ? 'No memories linked to "$_entityFilter".'
1801
+ : 'No memory entries found.',
1802
+ style: TextStyle(color: _textSecondary),
1803
+ )
1804
+ else
1805
+ ...memoriesToShow.map((memory) {
1806
+ final isSelected =
1807
+ selectedIds.contains(memory.id);
1808
+ return _MemoryRow(
1809
+ memory: memory,
1810
+ isSelected: isSelected,
1811
+ onTap: () => _toggleMemorySelection(
1812
+ memory.id,
1813
+ !isSelected,
1814
+ ),
1815
+ onCheck: (value) =>
1816
+ _toggleMemorySelection(
1817
+ memory.id,
1818
+ value ?? false,
1819
+ ),
1820
+ onDelete: _bulkActionInFlight
1821
+ ? null
1822
+ : () => _confirmDelete(
1823
+ context,
1824
+ title: 'Delete memory?',
1825
+ message:
1826
+ 'This memory will be removed permanently.',
1827
+ onConfirm: () =>
1828
+ _deleteSingleMemory(
1829
+ controller,
1830
+ memory.id,
1831
+ ),
1832
+ ),
1833
+ );
1834
+ }),
1835
+ ],
1836
+ ),
2005
1837
  ),
2006
- ),
2007
- child: Material(
2008
- color: Colors.transparent,
2009
- child: InkWell(
2010
- borderRadius: BorderRadius.circular(12),
2011
- onTap: () =>
2012
- _toggleMemorySelection(memory.id, !isSelected),
2013
- child: Padding(
2014
- padding: const EdgeInsets.all(12),
2015
- child: Row(
2016
- crossAxisAlignment: CrossAxisAlignment.start,
2017
- children: <Widget>[
2018
- Checkbox(
2019
- value: isSelected,
2020
- onChanged: (value) => _toggleMemorySelection(
2021
- memory.id,
2022
- value ?? false,
1838
+
1839
+ // --- Core tab ---
1840
+ Padding(
1841
+ padding: const EdgeInsets.all(18),
1842
+ child: Column(
1843
+ crossAxisAlignment: CrossAxisAlignment.start,
1844
+ children: <Widget>[
1845
+ Row(
1846
+ children: <Widget>[
1847
+ Expanded(
1848
+ child: Text(
1849
+ 'Key-value pairs that persist across conversations.',
1850
+ style: TextStyle(
1851
+ color: _textSecondary,
1852
+ ),
1853
+ ),
2023
1854
  ),
2024
- ),
2025
- const SizedBox(width: 8),
2026
- Expanded(
2027
- child: Column(
2028
- crossAxisAlignment:
2029
- CrossAxisAlignment.start,
2030
- children: <Widget>[
2031
- Row(
2032
- crossAxisAlignment:
2033
- CrossAxisAlignment.start,
2034
- children: <Widget>[
2035
- Expanded(
2036
- child: Wrap(
2037
- spacing: 10,
2038
- runSpacing: 10,
2039
- children: <Widget>[
2040
- _MetaPill(
2041
- label: memory.category,
2042
- icon: Icons.label_outline,
2043
- ),
2044
- _MetaPill(
2045
- label:
2046
- 'Importance ${memory.importance}',
2047
- icon: Icons
2048
- .priority_high_outlined,
2049
- ),
2050
- _MetaPill(
2051
- label:
2052
- 'Confidence ${memory.confidencePercent}%',
2053
- icon: Icons.verified_outlined,
1855
+ TextButton.icon(
1856
+ onPressed: () => _openCoreMemoryEditor(
1857
+ context,
1858
+ controller,
1859
+ ),
1860
+ icon: Icon(Icons.add),
1861
+ label: Text('Add Entry'),
1862
+ ),
1863
+ ],
1864
+ ),
1865
+ const SizedBox(height: 10),
1866
+ if (controller
1867
+ .memoryOverview.coreEntries.isEmpty)
1868
+ Text(
1869
+ 'No core memory entries yet.',
1870
+ style: TextStyle(color: _textSecondary),
1871
+ )
1872
+ else
1873
+ ...controller.memoryOverview.coreEntries
1874
+ .entries
1875
+ .map((entry) {
1876
+ return Container(
1877
+ width: double.infinity,
1878
+ margin: const EdgeInsets.only(bottom: 10),
1879
+ padding: const EdgeInsets.all(12),
1880
+ decoration: BoxDecoration(
1881
+ color: _bgSecondary,
1882
+ borderRadius:
1883
+ BorderRadius.circular(12),
1884
+ border: Border.all(color: _border),
1885
+ ),
1886
+ child: Row(
1887
+ crossAxisAlignment:
1888
+ CrossAxisAlignment.start,
1889
+ children: <Widget>[
1890
+ Expanded(
1891
+ child: Column(
1892
+ crossAxisAlignment:
1893
+ CrossAxisAlignment.start,
1894
+ children: <Widget>[
1895
+ Text(
1896
+ entry.key,
1897
+ style: TextStyle(
1898
+ fontWeight: FontWeight.w700,
2054
1899
  ),
2055
- ],
2056
- ),
1900
+ ),
1901
+ const SizedBox(height: 6),
1902
+ Text(
1903
+ entry.value.toString(),
1904
+ ),
1905
+ ],
2057
1906
  ),
2058
- IconButton(
2059
- onPressed: _bulkActionInFlight
2060
- ? null
2061
- : () => _confirmDelete(
2062
- context,
2063
- title: 'Delete memory?',
2064
- message:
2065
- 'This memory entry will be removed permanently.',
2066
- onConfirm: () =>
2067
- _deleteSingleMemory(
2068
- controller,
2069
- memory.id,
2070
- ),
2071
- ),
2072
- icon: Icon(Icons.delete_outline),
1907
+ ),
1908
+ IconButton(
1909
+ onPressed: () =>
1910
+ _openCoreMemoryEditor(
1911
+ context,
1912
+ controller,
1913
+ keyValue: entry,
2073
1914
  ),
2074
- ],
2075
- ),
2076
- const SizedBox(height: 10),
2077
- Text(memory.content),
2078
- if (memory
2079
- .entities
2080
- .isNotEmpty) ...<Widget>[
2081
- const SizedBox(height: 8),
2082
- Wrap(
2083
- spacing: 8,
2084
- runSpacing: 8,
2085
- children: memory.entities.take(6).map(
2086
- (entity) {
2087
- return _MetaPill(
2088
- label: entity.name,
2089
- icon: Icons.hub_outlined,
2090
- );
2091
- },
2092
- ).toList(),
1915
+ icon: Icon(Icons.edit_outlined),
2093
1916
  ),
2094
- ],
2095
- const SizedBox(height: 8),
2096
- Text(
2097
- memory.createdAtLabel,
2098
- style: TextStyle(
2099
- fontSize: 12,
2100
- color: _textSecondary,
1917
+ IconButton(
1918
+ onPressed: () => _confirmDelete(
1919
+ context,
1920
+ title:
1921
+ 'Delete core memory entry?',
1922
+ message:
1923
+ 'Remove "${entry.key}" from core memory.',
1924
+ onConfirm: () => controller
1925
+ .deleteCoreMemory(
1926
+ entry.key,
1927
+ ),
1928
+ ),
1929
+ icon: Icon(Icons.delete_outline),
2101
1930
  ),
2102
- ),
2103
- ],
1931
+ ],
1932
+ ),
1933
+ );
1934
+ }),
1935
+ ],
1936
+ ),
1937
+ ),
1938
+
1939
+ // --- Transfer tab ---
1940
+ Padding(
1941
+ padding: const EdgeInsets.all(18),
1942
+ child: Column(
1943
+ crossAxisAlignment: CrossAxisAlignment.start,
1944
+ children: <Widget>[
1945
+ Text(
1946
+ 'Generate a prompt for another AI, paste the response here to import memories.',
1947
+ style: TextStyle(color: _textSecondary),
1948
+ ),
1949
+ const SizedBox(height: 12),
1950
+ Wrap(
1951
+ spacing: 10,
1952
+ runSpacing: 10,
1953
+ children: <Widget>[
1954
+ FilledButton.icon(
1955
+ onPressed: _llmPromptLoading
1956
+ ? null
1957
+ : () =>
1958
+ _loadLlmPrompt(controller),
1959
+ icon: Icon(
1960
+ Icons.auto_awesome_outlined,
1961
+ ),
1962
+ label: Text(
1963
+ _llmPromptLoading
1964
+ ? 'Generating...'
1965
+ : 'Generate Prompt',
1966
+ ),
2104
1967
  ),
1968
+ OutlinedButton.icon(
1969
+ onPressed:
1970
+ _llmPromptController.text
1971
+ .trim()
1972
+ .isEmpty
1973
+ ? null
1974
+ : _copyLlmPrompt,
1975
+ icon: Icon(Icons.copy_all_outlined),
1976
+ label: Text('Copy Prompt'),
1977
+ ),
1978
+ ],
1979
+ ),
1980
+ const SizedBox(height: 12),
1981
+ TextField(
1982
+ controller: _llmPromptController,
1983
+ minLines: 4,
1984
+ maxLines: 8,
1985
+ readOnly: true,
1986
+ decoration: const InputDecoration(
1987
+ labelText:
1988
+ 'Prompt to paste into another AI',
2105
1989
  ),
2106
- ],
2107
- ),
1990
+ ),
1991
+ const SizedBox(height: 16),
1992
+ SwitchListTile.adaptive(
1993
+ contentPadding: EdgeInsets.zero,
1994
+ value: _llmApplyBehaviorNotes,
1995
+ onChanged: _llmImporting
1996
+ ? null
1997
+ : (value) => setState(
1998
+ () =>
1999
+ _llmApplyBehaviorNotes = value,
2000
+ ),
2001
+ title: Text('Apply behavior notes'),
2002
+ subtitle: Text(
2003
+ 'Overwrite behavior notes from the import.',
2004
+ ),
2005
+ ),
2006
+ SwitchListTile.adaptive(
2007
+ contentPadding: EdgeInsets.zero,
2008
+ value: _llmApplyCoreMemory,
2009
+ onChanged: _llmImporting
2010
+ ? null
2011
+ : (value) => setState(
2012
+ () =>
2013
+ _llmApplyCoreMemory = value,
2014
+ ),
2015
+ title: Text('Apply core memory'),
2016
+ subtitle: Text(
2017
+ 'Update core memory entries from the import.',
2018
+ ),
2019
+ ),
2020
+ const SizedBox(height: 12),
2021
+ TextField(
2022
+ controller: _llmImportController,
2023
+ minLines: 4,
2024
+ maxLines: 10,
2025
+ decoration: const InputDecoration(
2026
+ labelText: 'LLM memory export response',
2027
+ ),
2028
+ ),
2029
+ const SizedBox(height: 12),
2030
+ FilledButton.icon(
2031
+ onPressed: _llmImporting
2032
+ ? null
2033
+ : () =>
2034
+ _importLlmMemories(controller),
2035
+ icon: Icon(Icons.file_download_outlined),
2036
+ label: Text(
2037
+ _llmImporting
2038
+ ? 'Importing...'
2039
+ : 'Import',
2040
+ ),
2041
+ ),
2042
+ ],
2108
2043
  ),
2109
2044
  ),
2110
- ),
2045
+ ],
2111
2046
  );
2112
- }),
2113
- ],
2114
- ),
2115
- ),
2116
- ),
2117
- const SizedBox(height: 16),
2118
- Card(
2119
- child: Padding(
2120
- padding: const EdgeInsets.all(18),
2121
- child: Column(
2122
- crossAxisAlignment: CrossAxisAlignment.start,
2123
- children: <Widget>[
2124
- const _SectionTitle('Recent Conversations'),
2125
- const SizedBox(height: 10),
2126
- if (controller.memoryConversations.isEmpty)
2127
- Text(
2128
- 'No recent conversations found.',
2129
- style: TextStyle(color: _textSecondary),
2130
- )
2131
- else
2132
- ...controller.memoryConversations.map(
2133
- (conversation) => Padding(
2134
- padding: const EdgeInsets.only(bottom: 10),
2135
- child: Container(
2136
- width: double.infinity,
2137
- padding: const EdgeInsets.all(12),
2138
- decoration: BoxDecoration(
2139
- color: _bgSecondary,
2140
- borderRadius: BorderRadius.circular(12),
2141
- border: Border.all(color: _border),
2142
- ),
2143
- child: Column(
2144
- crossAxisAlignment: CrossAxisAlignment.start,
2145
- children: <Widget>[
2146
- Text(
2147
- conversation.title,
2148
- style: TextStyle(fontWeight: FontWeight.w700),
2149
- ),
2150
- const SizedBox(height: 8),
2151
- Text(
2152
- conversation.preview,
2153
- style: TextStyle(color: _textSecondary),
2154
- ),
2155
- ],
2156
- ),
2157
- ),
2158
- ),
2159
- ),
2047
+ },
2048
+ ),
2160
2049
  ],
2161
2050
  ),
2162
2051
  ),
@@ -2206,9 +2095,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2206
2095
  ],
2207
2096
  decoration: const InputDecoration(labelText: 'Category'),
2208
2097
  onChanged: (value) {
2209
- if (value != null) {
2210
- category = value;
2211
- }
2098
+ if (value != null) category = value;
2212
2099
  },
2213
2100
  ),
2214
2101
  const SizedBox(height: 12),
@@ -2239,9 +2126,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2239
2126
  importance:
2240
2127
  int.tryParse(importanceController.text.trim()) ?? 5,
2241
2128
  );
2242
- if (context.mounted) {
2243
- Navigator.of(context).pop();
2244
- }
2129
+ if (context.mounted) Navigator.of(context).pop();
2245
2130
  },
2246
2131
  child: Text('Save'),
2247
2132
  ),
@@ -2285,9 +2170,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2285
2170
  await controller.updateAssistantBehaviorNotes(
2286
2171
  contentController.text,
2287
2172
  );
2288
- if (context.mounted) {
2289
- Navigator.of(context).pop();
2290
- }
2173
+ if (context.mounted) Navigator.of(context).pop();
2291
2174
  },
2292
2175
  child: Text('Save'),
2293
2176
  ),
@@ -2346,9 +2229,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2346
2229
  keyController.text.trim(),
2347
2230
  valueController.text.trim(),
2348
2231
  );
2349
- if (context.mounted) {
2350
- Navigator.of(context).pop();
2351
- }
2232
+ if (context.mounted) Navigator.of(context).pop();
2352
2233
  },
2353
2234
  child: Text('Save'),
2354
2235
  ),
@@ -2359,6 +2240,680 @@ class _MemoryPanelState extends State<MemoryPanel> {
2359
2240
  }
2360
2241
  }
2361
2242
 
2243
+ class _MemoryStatChip extends StatelessWidget {
2244
+ const _MemoryStatChip({
2245
+ required this.label,
2246
+ required this.caption,
2247
+ required this.icon,
2248
+ });
2249
+
2250
+ final String label;
2251
+ final String caption;
2252
+ final IconData icon;
2253
+
2254
+ @override
2255
+ Widget build(BuildContext context) {
2256
+ return _GlassSurface(
2257
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2258
+ borderRadius: BorderRadius.circular(14),
2259
+ blurSigma: 10,
2260
+ fillColor: _bgSecondary.withValues(alpha: 0.7),
2261
+ borderColor: _borderLight,
2262
+ child: Row(
2263
+ mainAxisSize: MainAxisSize.min,
2264
+ children: <Widget>[
2265
+ Icon(icon, size: 15, color: _accentAlt),
2266
+ const SizedBox(width: 8),
2267
+ Column(
2268
+ crossAxisAlignment: CrossAxisAlignment.start,
2269
+ mainAxisSize: MainAxisSize.min,
2270
+ children: <Widget>[
2271
+ Text(
2272
+ label,
2273
+ style: TextStyle(
2274
+ fontWeight: FontWeight.w800,
2275
+ fontSize: 15,
2276
+ letterSpacing: -0.3,
2277
+ color: _textPrimary,
2278
+ ),
2279
+ ),
2280
+ Text(
2281
+ caption,
2282
+ style: TextStyle(
2283
+ fontSize: 10,
2284
+ fontWeight: FontWeight.w600,
2285
+ color: _textSecondary,
2286
+ letterSpacing: 0.2,
2287
+ ),
2288
+ ),
2289
+ ],
2290
+ ),
2291
+ ],
2292
+ ),
2293
+ );
2294
+ }
2295
+ }
2296
+
2297
+ class _MemoryConfidenceGauge extends StatefulWidget {
2298
+ const _MemoryConfidenceGauge({required this.confidence});
2299
+
2300
+ final double confidence;
2301
+
2302
+ @override
2303
+ State<_MemoryConfidenceGauge> createState() => _MemoryConfidenceGaugeState();
2304
+ }
2305
+
2306
+ class _MemoryConfidenceGaugeState extends State<_MemoryConfidenceGauge>
2307
+ with SingleTickerProviderStateMixin {
2308
+ late final AnimationController _controller;
2309
+ late Animation<double> _progress;
2310
+
2311
+ @override
2312
+ void initState() {
2313
+ super.initState();
2314
+ _controller = AnimationController(
2315
+ vsync: this,
2316
+ duration: const Duration(milliseconds: 1200),
2317
+ );
2318
+ _progress = Tween<double>(begin: 0, end: widget.confidence).animate(
2319
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2320
+ );
2321
+ _controller.forward();
2322
+ }
2323
+
2324
+ @override
2325
+ void didUpdateWidget(_MemoryConfidenceGauge oldWidget) {
2326
+ super.didUpdateWidget(oldWidget);
2327
+ if (oldWidget.confidence != widget.confidence) {
2328
+ _progress = Tween<double>(
2329
+ begin: _progress.value,
2330
+ end: widget.confidence,
2331
+ ).animate(
2332
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2333
+ );
2334
+ _controller
2335
+ ..reset()
2336
+ ..forward();
2337
+ }
2338
+ }
2339
+
2340
+ @override
2341
+ void dispose() {
2342
+ _controller.dispose();
2343
+ super.dispose();
2344
+ }
2345
+
2346
+ @override
2347
+ Widget build(BuildContext context) {
2348
+ return AnimatedBuilder(
2349
+ animation: _progress,
2350
+ builder: (context, child) {
2351
+ final value = _progress.value;
2352
+ final percent = (value * 100).round();
2353
+ return SizedBox(
2354
+ width: 64,
2355
+ height: 64,
2356
+ child: CustomPaint(
2357
+ painter: _RadialGaugePainter(
2358
+ progress: value,
2359
+ trackColor: _border,
2360
+ fillColor: _accent,
2361
+ glowColor: _accentHover,
2362
+ ),
2363
+ child: Center(
2364
+ child: Text(
2365
+ '$percent%',
2366
+ style: TextStyle(
2367
+ fontSize: 13,
2368
+ fontWeight: FontWeight.w800,
2369
+ color: _textPrimary,
2370
+ letterSpacing: -0.5,
2371
+ ),
2372
+ ),
2373
+ ),
2374
+ ),
2375
+ );
2376
+ },
2377
+ );
2378
+ }
2379
+ }
2380
+
2381
+ class _RadialGaugePainter extends CustomPainter {
2382
+ const _RadialGaugePainter({
2383
+ required this.progress,
2384
+ required this.trackColor,
2385
+ required this.fillColor,
2386
+ required this.glowColor,
2387
+ });
2388
+
2389
+ final double progress;
2390
+ final Color trackColor;
2391
+ final Color fillColor;
2392
+ final Color glowColor;
2393
+
2394
+ @override
2395
+ void paint(Canvas canvas, Size size) {
2396
+ final center = Offset(size.width / 2, size.height / 2);
2397
+ final radius = size.width / 2 - 5;
2398
+ const startAngle = -math.pi / 2;
2399
+ const totalAngle = 2 * math.pi;
2400
+ final sweepAngle = totalAngle * progress.clamp(0.0, 1.0);
2401
+
2402
+ final trackPaint = Paint()
2403
+ ..color = trackColor
2404
+ ..style = PaintingStyle.stroke
2405
+ ..strokeWidth = 5
2406
+ ..strokeCap = StrokeCap.round;
2407
+ canvas.drawArc(
2408
+ Rect.fromCircle(center: center, radius: radius),
2409
+ startAngle,
2410
+ totalAngle,
2411
+ false,
2412
+ trackPaint,
2413
+ );
2414
+
2415
+ if (sweepAngle > 0) {
2416
+ final glowPaint = Paint()
2417
+ ..color = glowColor.withValues(alpha: 0.25)
2418
+ ..style = PaintingStyle.stroke
2419
+ ..strokeWidth = 10
2420
+ ..strokeCap = StrokeCap.round
2421
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
2422
+ canvas.drawArc(
2423
+ Rect.fromCircle(center: center, radius: radius),
2424
+ startAngle,
2425
+ sweepAngle,
2426
+ false,
2427
+ glowPaint,
2428
+ );
2429
+
2430
+ final fillPaint = Paint()
2431
+ ..shader = SweepGradient(
2432
+ startAngle: startAngle,
2433
+ endAngle: startAngle + sweepAngle,
2434
+ colors: <Color>[fillColor, glowColor],
2435
+ ).createShader(Rect.fromCircle(center: center, radius: radius))
2436
+ ..style = PaintingStyle.stroke
2437
+ ..strokeWidth = 5
2438
+ ..strokeCap = StrokeCap.round;
2439
+ canvas.drawArc(
2440
+ Rect.fromCircle(center: center, radius: radius),
2441
+ startAngle,
2442
+ sweepAngle,
2443
+ false,
2444
+ fillPaint,
2445
+ );
2446
+ }
2447
+ }
2448
+
2449
+ @override
2450
+ bool shouldRepaint(_RadialGaugePainter oldDelegate) =>
2451
+ progress != oldDelegate.progress ||
2452
+ trackColor != oldDelegate.trackColor ||
2453
+ fillColor != oldDelegate.fillColor;
2454
+ }
2455
+
2456
+ class _MemoryRow extends StatelessWidget {
2457
+ const _MemoryRow({
2458
+ required this.memory,
2459
+ required this.isSelected,
2460
+ required this.onTap,
2461
+ required this.onCheck,
2462
+ this.onDelete,
2463
+ });
2464
+
2465
+ final MemoryItem memory;
2466
+ final bool isSelected;
2467
+ final VoidCallback onTap;
2468
+ final ValueChanged<bool?> onCheck;
2469
+ final VoidCallback? onDelete;
2470
+
2471
+ @override
2472
+ Widget build(BuildContext context) {
2473
+ return Container(
2474
+ width: double.infinity,
2475
+ margin: const EdgeInsets.only(bottom: 8),
2476
+ decoration: BoxDecoration(
2477
+ color: isSelected ? _accentMuted : _bgSecondary,
2478
+ borderRadius: BorderRadius.circular(12),
2479
+ border: Border.all(color: isSelected ? _accent : _border),
2480
+ ),
2481
+ child: Material(
2482
+ color: Colors.transparent,
2483
+ child: InkWell(
2484
+ borderRadius: BorderRadius.circular(12),
2485
+ onTap: onTap,
2486
+ child: Padding(
2487
+ padding: const EdgeInsets.all(12),
2488
+ child: Row(
2489
+ crossAxisAlignment: CrossAxisAlignment.start,
2490
+ children: <Widget>[
2491
+ Checkbox(value: isSelected, onChanged: onCheck),
2492
+ const SizedBox(width: 6),
2493
+ Expanded(
2494
+ child: Column(
2495
+ crossAxisAlignment: CrossAxisAlignment.start,
2496
+ children: <Widget>[
2497
+ Row(
2498
+ crossAxisAlignment: CrossAxisAlignment.start,
2499
+ children: <Widget>[
2500
+ Expanded(
2501
+ child: Wrap(
2502
+ spacing: 8,
2503
+ runSpacing: 8,
2504
+ children: <Widget>[
2505
+ _MetaPill(
2506
+ label: memory.category,
2507
+ icon: Icons.label_outline,
2508
+ ),
2509
+ _MetaPill(
2510
+ label: 'Imp ${memory.importance}',
2511
+ icon: Icons.priority_high_outlined,
2512
+ ),
2513
+ _MetaPill(
2514
+ label: '${memory.confidencePercent}%',
2515
+ icon: Icons.verified_outlined,
2516
+ ),
2517
+ ],
2518
+ ),
2519
+ ),
2520
+ if (onDelete != null)
2521
+ IconButton(
2522
+ onPressed: onDelete,
2523
+ icon: Icon(
2524
+ Icons.delete_outline,
2525
+ size: 18,
2526
+ ),
2527
+ ),
2528
+ ],
2529
+ ),
2530
+ const SizedBox(height: 8),
2531
+ Text(memory.content),
2532
+ if (memory.entities.isNotEmpty) ...<Widget>[
2533
+ const SizedBox(height: 6),
2534
+ Wrap(
2535
+ spacing: 6,
2536
+ runSpacing: 6,
2537
+ children: memory.entities
2538
+ .take(5)
2539
+ .map(
2540
+ (e) => _MetaPill(
2541
+ label: e.name,
2542
+ icon: Icons.hub_outlined,
2543
+ ),
2544
+ )
2545
+ .toList(),
2546
+ ),
2547
+ ],
2548
+ const SizedBox(height: 6),
2549
+ Text(
2550
+ memory.createdAtLabel,
2551
+ style: TextStyle(
2552
+ fontSize: 11,
2553
+ color: _textMuted,
2554
+ ),
2555
+ ),
2556
+ ],
2557
+ ),
2558
+ ),
2559
+ ],
2560
+ ),
2561
+ ),
2562
+ ),
2563
+ ),
2564
+ );
2565
+ }
2566
+ }
2567
+
2568
+ // ---------------------------------------------------------------------------
2569
+ // Interactive Entity Knowledge Graph
2570
+ // ---------------------------------------------------------------------------
2571
+
2572
+ class _EntityGraphView extends StatefulWidget {
2573
+ const _EntityGraphView({
2574
+ required this.entities,
2575
+ required this.knowledgeViews,
2576
+ this.selectedEntity,
2577
+ this.onEntityTapped,
2578
+ });
2579
+
2580
+ final List<MemoryEntity> entities;
2581
+ final List<KnowledgeViewItem> knowledgeViews;
2582
+ final String? selectedEntity;
2583
+ final ValueChanged<String>? onEntityTapped;
2584
+
2585
+ @override
2586
+ State<_EntityGraphView> createState() => _EntityGraphViewState();
2587
+ }
2588
+
2589
+ class _EntityGraphViewState extends State<_EntityGraphView>
2590
+ with SingleTickerProviderStateMixin {
2591
+ late final AnimationController _idleController;
2592
+ final List<_GraphNode> _nodes = <_GraphNode>[];
2593
+ String? _hoveredNode;
2594
+ bool _layoutDone = false;
2595
+ Size? _lastLayoutSize;
2596
+
2597
+ @override
2598
+ void initState() {
2599
+ super.initState();
2600
+ _idleController = AnimationController(
2601
+ vsync: this,
2602
+ duration: const Duration(seconds: 6),
2603
+ )..repeat();
2604
+ _buildNodes();
2605
+ }
2606
+
2607
+ @override
2608
+ void didUpdateWidget(_EntityGraphView oldWidget) {
2609
+ super.didUpdateWidget(oldWidget);
2610
+ if (oldWidget.entities != widget.entities ||
2611
+ oldWidget.knowledgeViews != widget.knowledgeViews) {
2612
+ _buildNodes();
2613
+ }
2614
+ }
2615
+
2616
+ @override
2617
+ void dispose() {
2618
+ _idleController.dispose();
2619
+ super.dispose();
2620
+ }
2621
+
2622
+ static const Map<String, Color> _kindColors = <String, Color>{
2623
+ 'person': Color(0xFF9B8AE0),
2624
+ 'project': Color(0xFF5E9B7C),
2625
+ 'file': Color(0xFF7BA5C7),
2626
+ 'concept': Color(0xFFB8A06B),
2627
+ 'tool': Color(0xFFCF8F6B),
2628
+ 'organization': Color(0xFF7DA0B5),
2629
+ };
2630
+
2631
+ void _buildNodes() {
2632
+ _nodes.clear();
2633
+ final entities = widget.entities;
2634
+ final views = widget.knowledgeViews;
2635
+ final maxMention = entities.fold<int>(
2636
+ 1,
2637
+ (max, e) => e.mentionCount > max ? e.mentionCount : max,
2638
+ );
2639
+
2640
+ for (int i = 0; i < entities.length; i++) {
2641
+ final entity = entities[i];
2642
+ final sizeFactor = 0.4 + 0.6 * (entity.mentionCount / maxMention);
2643
+ _nodes.add(_GraphNode(
2644
+ id: entity.name,
2645
+ label: entity.name,
2646
+ radius: 18 + 20 * sizeFactor,
2647
+ color: _kindColors[entity.kind] ?? _kindColors['concept']!,
2648
+ kind: entity.kind,
2649
+ isReflection: false,
2650
+ offsetPhase: i * 0.7,
2651
+ ));
2652
+ }
2653
+
2654
+ for (int i = 0; i < views.length && i < 6; i++) {
2655
+ _nodes.add(_GraphNode(
2656
+ id: 'kv_${views[i].title}',
2657
+ label: views[i].title,
2658
+ radius: 14,
2659
+ color: const Color(0xFF8B7EC8),
2660
+ kind: views[i].viewType,
2661
+ isReflection: true,
2662
+ offsetPhase: (entities.length + i) * 0.9,
2663
+ ));
2664
+ }
2665
+
2666
+ _layoutDone = false;
2667
+ }
2668
+
2669
+ void _layoutNodes(Size size) {
2670
+ if (_nodes.isEmpty) return;
2671
+ if (_layoutDone && _lastLayoutSize == size) return;
2672
+ _layoutDone = true;
2673
+ _lastLayoutSize = size;
2674
+
2675
+ final cx = size.width / 2;
2676
+ final cy = size.height / 2;
2677
+ final radiusX = size.width * 0.35;
2678
+ final radiusY = size.height * 0.35;
2679
+
2680
+ for (int i = 0; i < _nodes.length; i++) {
2681
+ final angle = (2 * math.pi * i / _nodes.length) - math.pi / 2;
2682
+ final jitter = (i.isEven ? 0.85 : 1.0) + (i % 3) * 0.05;
2683
+ _nodes[i].x = cx + radiusX * jitter * math.cos(angle);
2684
+ _nodes[i].y = cy + radiusY * jitter * math.sin(angle);
2685
+ }
2686
+ }
2687
+
2688
+ void _handleTap(Offset localPosition) {
2689
+ for (final node in _nodes.reversed) {
2690
+ final dx = localPosition.dx - node.x;
2691
+ final dy = localPosition.dy - node.y;
2692
+ if (dx * dx + dy * dy <= node.radius * node.radius * 1.8) {
2693
+ if (!node.isReflection) {
2694
+ widget.onEntityTapped?.call(node.label);
2695
+ }
2696
+ return;
2697
+ }
2698
+ }
2699
+ }
2700
+
2701
+ void _handleHover(Offset? localPosition) {
2702
+ if (localPosition == null) {
2703
+ if (_hoveredNode != null) setState(() => _hoveredNode = null);
2704
+ return;
2705
+ }
2706
+ String? found;
2707
+ for (final node in _nodes.reversed) {
2708
+ final dx = localPosition.dx - node.x;
2709
+ final dy = localPosition.dy - node.y;
2710
+ if (dx * dx + dy * dy <= node.radius * node.radius * 1.8) {
2711
+ found = node.id;
2712
+ break;
2713
+ }
2714
+ }
2715
+ if (found != _hoveredNode) setState(() => _hoveredNode = found);
2716
+ }
2717
+
2718
+ @override
2719
+ Widget build(BuildContext context) {
2720
+ return LayoutBuilder(
2721
+ builder: (context, constraints) {
2722
+ final size = Size(constraints.maxWidth, constraints.maxHeight);
2723
+ _layoutNodes(size);
2724
+ return MouseRegion(
2725
+ onHover: (event) => _handleHover(event.localPosition),
2726
+ onExit: (_) => _handleHover(null),
2727
+ child: GestureDetector(
2728
+ onTapDown: (details) => _handleTap(details.localPosition),
2729
+ child: AnimatedBuilder(
2730
+ animation: _idleController,
2731
+ builder: (context, _) {
2732
+ return CustomPaint(
2733
+ size: size,
2734
+ painter: _EntityGraphPainter(
2735
+ nodes: _nodes,
2736
+ selectedEntity: widget.selectedEntity,
2737
+ hoveredNode: _hoveredNode,
2738
+ animationValue: _idleController.value,
2739
+ accentColor: _accent,
2740
+ bgColor: _bgSecondary,
2741
+ textColor: _textPrimary,
2742
+ mutedTextColor: _textSecondary,
2743
+ borderColor: _border,
2744
+ ),
2745
+ );
2746
+ },
2747
+ ),
2748
+ ),
2749
+ );
2750
+ },
2751
+ );
2752
+ }
2753
+ }
2754
+
2755
+ class _GraphNode {
2756
+ _GraphNode({
2757
+ required this.id,
2758
+ required this.label,
2759
+ required this.radius,
2760
+ required this.color,
2761
+ required this.kind,
2762
+ required this.isReflection,
2763
+ required this.offsetPhase,
2764
+ });
2765
+
2766
+ final String id;
2767
+ final String label;
2768
+ final double radius;
2769
+ final Color color;
2770
+ final String kind;
2771
+ final bool isReflection;
2772
+ final double offsetPhase;
2773
+ double x = 0;
2774
+ double y = 0;
2775
+ }
2776
+
2777
+ class _EntityGraphPainter extends CustomPainter {
2778
+ const _EntityGraphPainter({
2779
+ required this.nodes,
2780
+ required this.selectedEntity,
2781
+ required this.hoveredNode,
2782
+ required this.animationValue,
2783
+ required this.accentColor,
2784
+ required this.bgColor,
2785
+ required this.textColor,
2786
+ required this.mutedTextColor,
2787
+ required this.borderColor,
2788
+ });
2789
+
2790
+ final List<_GraphNode> nodes;
2791
+ final String? selectedEntity;
2792
+ final String? hoveredNode;
2793
+ final double animationValue;
2794
+ final Color accentColor;
2795
+ final Color bgColor;
2796
+ final Color textColor;
2797
+ final Color mutedTextColor;
2798
+ final Color borderColor;
2799
+
2800
+ @override
2801
+ void paint(Canvas canvas, Size size) {
2802
+ if (nodes.isEmpty) return;
2803
+
2804
+ final entityNodes =
2805
+ nodes.where((n) => !n.isReflection).toList(growable: false);
2806
+ final reflectionNodes =
2807
+ nodes.where((n) => n.isReflection).toList(growable: false);
2808
+
2809
+ // Draw connections between entity nodes (subtle web)
2810
+ final linePaint = Paint()
2811
+ ..color = borderColor.withValues(alpha: 0.18)
2812
+ ..strokeWidth = 1;
2813
+ for (int i = 0; i < entityNodes.length; i++) {
2814
+ for (int j = i + 1; j < entityNodes.length; j++) {
2815
+ if ((i + j) % 3 != 0) continue;
2816
+ final a = entityNodes[i];
2817
+ final b = entityNodes[j];
2818
+ final drift = math.sin(animationValue * 2 * math.pi + a.offsetPhase);
2819
+ canvas.drawLine(
2820
+ Offset(a.x, a.y + drift * 2),
2821
+ Offset(b.x, b.y + drift * 2),
2822
+ linePaint,
2823
+ );
2824
+ }
2825
+ }
2826
+
2827
+ // Draw connections from reflections to closest entity
2828
+ if (entityNodes.isNotEmpty) {
2829
+ final reflectionLinePaint = Paint()
2830
+ ..color = borderColor.withValues(alpha: 0.14)
2831
+ ..strokeWidth = 1
2832
+ ..style = PaintingStyle.stroke;
2833
+ for (final rn in reflectionNodes) {
2834
+ final drift =
2835
+ math.sin(animationValue * 2 * math.pi + rn.offsetPhase) * 3;
2836
+ var closest = entityNodes.first;
2837
+ var minDist = double.infinity;
2838
+ for (final en in entityNodes) {
2839
+ final d = (en.x - rn.x) * (en.x - rn.x) +
2840
+ (en.y - rn.y) * (en.y - rn.y);
2841
+ if (d < minDist) {
2842
+ minDist = d;
2843
+ closest = en;
2844
+ }
2845
+ }
2846
+ canvas.drawLine(
2847
+ Offset(rn.x, rn.y + drift),
2848
+ Offset(closest.x, closest.y + drift),
2849
+ reflectionLinePaint,
2850
+ );
2851
+ }
2852
+ }
2853
+
2854
+ // Draw nodes
2855
+ for (final node in nodes) {
2856
+ final drift =
2857
+ math.sin(animationValue * 2 * math.pi + node.offsetPhase) * 3;
2858
+ final isSelected = node.label == selectedEntity;
2859
+ final isHovered = node.id == hoveredNode;
2860
+ final cx = node.x;
2861
+ final cy = node.y + drift;
2862
+ final r = node.radius * (isHovered ? 1.15 : 1.0);
2863
+
2864
+ // Glow
2865
+ if (isSelected || isHovered) {
2866
+ final glowPaint = Paint()
2867
+ ..color = (isSelected ? accentColor : node.color)
2868
+ .withValues(alpha: 0.22)
2869
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
2870
+ canvas.drawCircle(Offset(cx, cy), r + 6, glowPaint);
2871
+ }
2872
+
2873
+ // Fill
2874
+ final fillPaint = Paint()
2875
+ ..shader = RadialGradient(
2876
+ colors: <Color>[
2877
+ node.color.withValues(alpha: isSelected ? 0.9 : 0.7),
2878
+ node.color.withValues(alpha: isSelected ? 0.6 : 0.35),
2879
+ ],
2880
+ ).createShader(Rect.fromCircle(center: Offset(cx, cy), radius: r));
2881
+ canvas.drawCircle(Offset(cx, cy), r, fillPaint);
2882
+
2883
+ // Border
2884
+ final borderPaint = Paint()
2885
+ ..color = isSelected
2886
+ ? accentColor
2887
+ : (isHovered
2888
+ ? node.color.withValues(alpha: 0.8)
2889
+ : node.color.withValues(alpha: 0.35))
2890
+ ..style = PaintingStyle.stroke
2891
+ ..strokeWidth = isSelected ? 2.5 : 1.5;
2892
+ canvas.drawCircle(Offset(cx, cy), r, borderPaint);
2893
+
2894
+ // Label
2895
+ final labelStyle = TextStyle(
2896
+ color: isSelected ? textColor : mutedTextColor,
2897
+ fontSize: node.isReflection ? 9 : 11,
2898
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
2899
+ );
2900
+ final tp = TextPainter(
2901
+ text: TextSpan(text: node.label, style: labelStyle),
2902
+ textDirection: TextDirection.ltr,
2903
+ maxLines: 1,
2904
+ ellipsis: '…',
2905
+ )..layout(maxWidth: r * 3);
2906
+ tp.paint(
2907
+ canvas,
2908
+ Offset(cx - tp.width / 2, cy + r + 5),
2909
+ );
2910
+ }
2911
+ }
2912
+
2913
+ @override
2914
+ bool shouldRepaint(_EntityGraphPainter oldDelegate) => true;
2915
+ }
2916
+
2362
2917
  class WidgetsPanel extends StatelessWidget {
2363
2918
  const WidgetsPanel({super.key, required this.controller});
2364
2919