This is page 45 of 47. Use http://codebase.md/doobidoo/mcp-memory-service?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ ├── amp-bridge.md
│ │ ├── amp-pr-automator.md
│ │ ├── code-quality-guard.md
│ │ ├── gemini-pr-automator.md
│ │ └── github-release-manager.md
│ ├── settings.local.json.backup
│ └── settings.local.json.local
├── .commit-message
├── .dockerignore
├── .env.example
├── .env.sqlite.backup
├── .envnn#
├── .gitattributes
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── feature_request.yml
│ │ └── performance_issue.yml
│ ├── pull_request_template.md
│ └── workflows
│ ├── bridge-tests.yml
│ ├── CACHE_FIX.md
│ ├── claude-code-review.yml
│ ├── claude.yml
│ ├── cleanup-images.yml.disabled
│ ├── dev-setup-validation.yml
│ ├── docker-publish.yml
│ ├── LATEST_FIXES.md
│ ├── main-optimized.yml.disabled
│ ├── main.yml
│ ├── publish-and-test.yml
│ ├── README_OPTIMIZATION.md
│ ├── release-tag.yml.disabled
│ ├── release.yml
│ ├── roadmap-review-reminder.yml
│ ├── SECRET_CONDITIONAL_FIX.md
│ └── WORKFLOW_FIXES.md
├── .gitignore
├── .mcp.json.backup
├── .mcp.json.template
├── .pyscn
│ ├── .gitignore
│ └── reports
│ └── analyze_20251123_214224.html
├── AGENTS.md
├── archive
│ ├── deployment
│ │ ├── deploy_fastmcp_fixed.sh
│ │ ├── deploy_http_with_mcp.sh
│ │ └── deploy_mcp_v4.sh
│ ├── deployment-configs
│ │ ├── empty_config.yml
│ │ └── smithery.yaml
│ ├── development
│ │ └── test_fastmcp.py
│ ├── docs-removed-2025-08-23
│ │ ├── authentication.md
│ │ ├── claude_integration.md
│ │ ├── claude-code-compatibility.md
│ │ ├── claude-code-integration.md
│ │ ├── claude-code-quickstart.md
│ │ ├── claude-desktop-setup.md
│ │ ├── complete-setup-guide.md
│ │ ├── database-synchronization.md
│ │ ├── development
│ │ │ ├── autonomous-memory-consolidation.md
│ │ │ ├── CLEANUP_PLAN.md
│ │ │ ├── CLEANUP_README.md
│ │ │ ├── CLEANUP_SUMMARY.md
│ │ │ ├── dream-inspired-memory-consolidation.md
│ │ │ ├── hybrid-slm-memory-consolidation.md
│ │ │ ├── mcp-milestone.md
│ │ │ ├── multi-client-architecture.md
│ │ │ ├── test-results.md
│ │ │ └── TIMESTAMP_FIX_SUMMARY.md
│ │ ├── distributed-sync.md
│ │ ├── invocation_guide.md
│ │ ├── macos-intel.md
│ │ ├── master-guide.md
│ │ ├── mcp-client-configuration.md
│ │ ├── multi-client-server.md
│ │ ├── service-installation.md
│ │ ├── sessions
│ │ │ └── MCP_ENHANCEMENT_SESSION_MEMORY_v4.1.0.md
│ │ ├── UBUNTU_SETUP.md
│ │ ├── ubuntu.md
│ │ ├── windows-setup.md
│ │ └── windows.md
│ ├── docs-root-cleanup-2025-08-23
│ │ ├── AWESOME_LIST_SUBMISSION.md
│ │ ├── CLOUDFLARE_IMPLEMENTATION.md
│ │ ├── DOCUMENTATION_ANALYSIS.md
│ │ ├── DOCUMENTATION_CLEANUP_PLAN.md
│ │ ├── DOCUMENTATION_CONSOLIDATION_COMPLETE.md
│ │ ├── LITESTREAM_SETUP_GUIDE.md
│ │ ├── lm_studio_system_prompt.md
│ │ ├── PYTORCH_DOWNLOAD_FIX.md
│ │ └── README-ORIGINAL-BACKUP.md
│ ├── investigations
│ │ └── MACOS_HOOKS_INVESTIGATION.md
│ ├── litestream-configs-v6.3.0
│ │ ├── install_service.sh
│ │ ├── litestream_master_config_fixed.yml
│ │ ├── litestream_master_config.yml
│ │ ├── litestream_replica_config_fixed.yml
│ │ ├── litestream_replica_config.yml
│ │ ├── litestream_replica_simple.yml
│ │ ├── litestream-http.service
│ │ ├── litestream.service
│ │ └── requirements-cloudflare.txt
│ ├── release-notes
│ │ └── release-notes-v7.1.4.md
│ └── setup-development
│ ├── README.md
│ ├── setup_consolidation_mdns.sh
│ ├── STARTUP_SETUP_GUIDE.md
│ └── test_service.sh
├── CHANGELOG-HISTORIC.md
├── CHANGELOG.md
├── claude_commands
│ ├── memory-context.md
│ ├── memory-health.md
│ ├── memory-ingest-dir.md
│ ├── memory-ingest.md
│ ├── memory-recall.md
│ ├── memory-search.md
│ ├── memory-store.md
│ ├── README.md
│ └── session-start.md
├── claude-hooks
│ ├── config.json
│ ├── config.template.json
│ ├── CONFIGURATION.md
│ ├── core
│ │ ├── memory-retrieval.js
│ │ ├── mid-conversation.js
│ │ ├── session-end.js
│ │ ├── session-start.js
│ │ └── topic-change.js
│ ├── debug-pattern-test.js
│ ├── install_claude_hooks_windows.ps1
│ ├── install_hooks.py
│ ├── memory-mode-controller.js
│ ├── MIGRATION.md
│ ├── README-NATURAL-TRIGGERS.md
│ ├── README-phase2.md
│ ├── README.md
│ ├── simple-test.js
│ ├── statusline.sh
│ ├── test-adaptive-weights.js
│ ├── test-dual-protocol-hook.js
│ ├── test-mcp-hook.js
│ ├── test-natural-triggers.js
│ ├── test-recency-scoring.js
│ ├── tests
│ │ ├── integration-test.js
│ │ ├── phase2-integration-test.js
│ │ ├── test-code-execution.js
│ │ ├── test-cross-session.json
│ │ ├── test-session-tracking.json
│ │ └── test-threading.json
│ ├── utilities
│ │ ├── adaptive-pattern-detector.js
│ │ ├── context-formatter.js
│ │ ├── context-shift-detector.js
│ │ ├── conversation-analyzer.js
│ │ ├── dynamic-context-updater.js
│ │ ├── git-analyzer.js
│ │ ├── mcp-client.js
│ │ ├── memory-client.js
│ │ ├── memory-scorer.js
│ │ ├── performance-manager.js
│ │ ├── project-detector.js
│ │ ├── session-tracker.js
│ │ ├── tiered-conversation-monitor.js
│ │ └── version-checker.js
│ └── WINDOWS-SESSIONSTART-BUG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Development-Sprint-November-2025.md
├── docs
│ ├── amp-cli-bridge.md
│ ├── api
│ │ ├── code-execution-interface.md
│ │ ├── memory-metadata-api.md
│ │ ├── PHASE1_IMPLEMENTATION_SUMMARY.md
│ │ ├── PHASE2_IMPLEMENTATION_SUMMARY.md
│ │ ├── PHASE2_REPORT.md
│ │ └── tag-standardization.md
│ ├── architecture
│ │ ├── search-enhancement-spec.md
│ │ └── search-examples.md
│ ├── architecture.md
│ ├── archive
│ │ └── obsolete-workflows
│ │ ├── load_memory_context.md
│ │ └── README.md
│ ├── assets
│ │ └── images
│ │ ├── dashboard-v3.3.0-preview.png
│ │ ├── memory-awareness-hooks-example.png
│ │ ├── project-infographic.svg
│ │ └── README.md
│ ├── CLAUDE_CODE_QUICK_REFERENCE.md
│ ├── cloudflare-setup.md
│ ├── deployment
│ │ ├── docker.md
│ │ ├── dual-service.md
│ │ ├── production-guide.md
│ │ └── systemd-service.md
│ ├── development
│ │ ├── ai-agent-instructions.md
│ │ ├── code-quality
│ │ │ ├── phase-2a-completion.md
│ │ │ ├── phase-2a-handle-get-prompt.md
│ │ │ ├── phase-2a-index.md
│ │ │ ├── phase-2a-install-package.md
│ │ │ └── phase-2b-session-summary.md
│ │ ├── code-quality-workflow.md
│ │ ├── dashboard-workflow.md
│ │ ├── issue-management.md
│ │ ├── pr-review-guide.md
│ │ ├── refactoring-notes.md
│ │ ├── release-checklist.md
│ │ └── todo-tracker.md
│ ├── docker-optimized-build.md
│ ├── document-ingestion.md
│ ├── DOCUMENTATION_AUDIT.md
│ ├── enhancement-roadmap-issue-14.md
│ ├── examples
│ │ ├── analysis-scripts.js
│ │ ├── maintenance-session-example.md
│ │ ├── memory-distribution-chart.jsx
│ │ └── tag-schema.json
│ ├── first-time-setup.md
│ ├── glama-deployment.md
│ ├── guides
│ │ ├── advanced-command-examples.md
│ │ ├── chromadb-migration.md
│ │ ├── commands-vs-mcp-server.md
│ │ ├── mcp-enhancements.md
│ │ ├── mdns-service-discovery.md
│ │ ├── memory-consolidation-guide.md
│ │ ├── migration.md
│ │ ├── scripts.md
│ │ └── STORAGE_BACKENDS.md
│ ├── HOOK_IMPROVEMENTS.md
│ ├── hooks
│ │ └── phase2-code-execution-migration.md
│ ├── http-server-management.md
│ ├── ide-compatability.md
│ ├── IMAGE_RETENTION_POLICY.md
│ ├── images
│ │ └── dashboard-placeholder.md
│ ├── implementation
│ │ ├── health_checks.md
│ │ └── performance.md
│ ├── IMPLEMENTATION_PLAN_HTTP_SSE.md
│ ├── integration
│ │ ├── homebrew.md
│ │ └── multi-client.md
│ ├── integrations
│ │ ├── gemini.md
│ │ ├── groq-bridge.md
│ │ ├── groq-integration-summary.md
│ │ └── groq-model-comparison.md
│ ├── integrations.md
│ ├── legacy
│ │ └── dual-protocol-hooks.md
│ ├── LM_STUDIO_COMPATIBILITY.md
│ ├── maintenance
│ │ └── memory-maintenance.md
│ ├── mastery
│ │ ├── api-reference.md
│ │ ├── architecture-overview.md
│ │ ├── configuration-guide.md
│ │ ├── local-setup-and-run.md
│ │ ├── testing-guide.md
│ │ └── troubleshooting.md
│ ├── migration
│ │ └── code-execution-api-quick-start.md
│ ├── natural-memory-triggers
│ │ ├── cli-reference.md
│ │ ├── installation-guide.md
│ │ └── performance-optimization.md
│ ├── oauth-setup.md
│ ├── pr-graphql-integration.md
│ ├── quick-setup-cloudflare-dual-environment.md
│ ├── README.md
│ ├── remote-configuration-wiki-section.md
│ ├── research
│ │ ├── code-execution-interface-implementation.md
│ │ └── code-execution-interface-summary.md
│ ├── ROADMAP.md
│ ├── sqlite-vec-backend.md
│ ├── statistics
│ │ ├── charts
│ │ │ ├── activity_patterns.png
│ │ │ ├── contributors.png
│ │ │ ├── growth_trajectory.png
│ │ │ ├── monthly_activity.png
│ │ │ └── october_sprint.png
│ │ ├── data
│ │ │ ├── activity_by_day.csv
│ │ │ ├── activity_by_hour.csv
│ │ │ ├── contributors.csv
│ │ │ └── monthly_activity.csv
│ │ ├── generate_charts.py
│ │ └── REPOSITORY_STATISTICS.md
│ ├── technical
│ │ ├── development.md
│ │ ├── memory-migration.md
│ │ ├── migration-log.md
│ │ ├── sqlite-vec-embedding-fixes.md
│ │ └── tag-storage.md
│ ├── testing
│ │ └── regression-tests.md
│ ├── testing-cloudflare-backend.md
│ ├── troubleshooting
│ │ ├── cloudflare-api-token-setup.md
│ │ ├── cloudflare-authentication.md
│ │ ├── general.md
│ │ ├── hooks-quick-reference.md
│ │ ├── pr162-schema-caching-issue.md
│ │ ├── session-end-hooks.md
│ │ └── sync-issues.md
│ └── tutorials
│ ├── advanced-techniques.md
│ ├── data-analysis.md
│ └── demo-session-walkthrough.md
├── examples
│ ├── claude_desktop_config_template.json
│ ├── claude_desktop_config_windows.json
│ ├── claude-desktop-http-config.json
│ ├── config
│ │ └── claude_desktop_config.json
│ ├── http-mcp-bridge.js
│ ├── memory_export_template.json
│ ├── README.md
│ ├── setup
│ │ └── setup_multi_client_complete.py
│ └── start_https_example.sh
├── install_service.py
├── install.py
├── LICENSE
├── NOTICE
├── pyproject.toml
├── pytest.ini
├── README.md
├── run_server.py
├── scripts
│ ├── .claude
│ │ └── settings.local.json
│ ├── archive
│ │ └── check_missing_timestamps.py
│ ├── backup
│ │ ├── backup_memories.py
│ │ ├── backup_sqlite_vec.sh
│ │ ├── export_distributable_memories.sh
│ │ └── restore_memories.py
│ ├── benchmarks
│ │ ├── benchmark_code_execution_api.py
│ │ ├── benchmark_hybrid_sync.py
│ │ └── benchmark_server_caching.py
│ ├── database
│ │ ├── analyze_sqlite_vec_db.py
│ │ ├── check_sqlite_vec_status.py
│ │ ├── db_health_check.py
│ │ └── simple_timestamp_check.py
│ ├── development
│ │ ├── debug_server_initialization.py
│ │ ├── find_orphaned_files.py
│ │ ├── fix_mdns.sh
│ │ ├── fix_sitecustomize.py
│ │ ├── remote_ingest.sh
│ │ ├── setup-git-merge-drivers.sh
│ │ ├── uv-lock-merge.sh
│ │ └── verify_hybrid_sync.py
│ ├── hooks
│ │ └── pre-commit
│ ├── installation
│ │ ├── install_linux_service.py
│ │ ├── install_macos_service.py
│ │ ├── install_uv.py
│ │ ├── install_windows_service.py
│ │ ├── install.py
│ │ ├── setup_backup_cron.sh
│ │ ├── setup_claude_mcp.sh
│ │ └── setup_cloudflare_resources.py
│ ├── linux
│ │ ├── service_status.sh
│ │ ├── start_service.sh
│ │ ├── stop_service.sh
│ │ ├── uninstall_service.sh
│ │ └── view_logs.sh
│ ├── maintenance
│ │ ├── assign_memory_types.py
│ │ ├── check_memory_types.py
│ │ ├── cleanup_corrupted_encoding.py
│ │ ├── cleanup_memories.py
│ │ ├── cleanup_organize.py
│ │ ├── consolidate_memory_types.py
│ │ ├── consolidation_mappings.json
│ │ ├── delete_orphaned_vectors_fixed.py
│ │ ├── fast_cleanup_duplicates_with_tracking.sh
│ │ ├── find_all_duplicates.py
│ │ ├── find_cloudflare_duplicates.py
│ │ ├── find_duplicates.py
│ │ ├── memory-types.md
│ │ ├── README.md
│ │ ├── recover_timestamps_from_cloudflare.py
│ │ ├── regenerate_embeddings.py
│ │ ├── repair_malformed_tags.py
│ │ ├── repair_memories.py
│ │ ├── repair_sqlite_vec_embeddings.py
│ │ ├── repair_zero_embeddings.py
│ │ ├── restore_from_json_export.py
│ │ └── scan_todos.sh
│ ├── migration
│ │ ├── cleanup_mcp_timestamps.py
│ │ ├── legacy
│ │ │ └── migrate_chroma_to_sqlite.py
│ │ ├── mcp-migration.py
│ │ ├── migrate_sqlite_vec_embeddings.py
│ │ ├── migrate_storage.py
│ │ ├── migrate_tags.py
│ │ ├── migrate_timestamps.py
│ │ ├── migrate_to_cloudflare.py
│ │ ├── migrate_to_sqlite_vec.py
│ │ ├── migrate_v5_enhanced.py
│ │ ├── TIMESTAMP_CLEANUP_README.md
│ │ └── verify_mcp_timestamps.py
│ ├── pr
│ │ ├── amp_collect_results.sh
│ │ ├── amp_detect_breaking_changes.sh
│ │ ├── amp_generate_tests.sh
│ │ ├── amp_pr_review.sh
│ │ ├── amp_quality_gate.sh
│ │ ├── amp_suggest_fixes.sh
│ │ ├── auto_review.sh
│ │ ├── detect_breaking_changes.sh
│ │ ├── generate_tests.sh
│ │ ├── lib
│ │ │ └── graphql_helpers.sh
│ │ ├── quality_gate.sh
│ │ ├── resolve_threads.sh
│ │ ├── run_pyscn_analysis.sh
│ │ ├── run_quality_checks.sh
│ │ ├── thread_status.sh
│ │ └── watch_reviews.sh
│ ├── quality
│ │ ├── fix_dead_code_install.sh
│ │ ├── phase1_dead_code_analysis.md
│ │ ├── phase2_complexity_analysis.md
│ │ ├── README_PHASE1.md
│ │ ├── README_PHASE2.md
│ │ ├── track_pyscn_metrics.sh
│ │ └── weekly_quality_review.sh
│ ├── README.md
│ ├── run
│ │ ├── run_mcp_memory.sh
│ │ ├── run-with-uv.sh
│ │ └── start_sqlite_vec.sh
│ ├── run_memory_server.py
│ ├── server
│ │ ├── check_http_server.py
│ │ ├── check_server_health.py
│ │ ├── memory_offline.py
│ │ ├── preload_models.py
│ │ ├── run_http_server.py
│ │ ├── run_memory_server.py
│ │ ├── start_http_server.bat
│ │ └── start_http_server.sh
│ ├── service
│ │ ├── deploy_dual_services.sh
│ │ ├── install_http_service.sh
│ │ ├── mcp-memory-http.service
│ │ ├── mcp-memory.service
│ │ ├── memory_service_manager.sh
│ │ ├── service_control.sh
│ │ ├── service_utils.py
│ │ └── update_service.sh
│ ├── sync
│ │ ├── check_drift.py
│ │ ├── claude_sync_commands.py
│ │ ├── export_memories.py
│ │ ├── import_memories.py
│ │ ├── litestream
│ │ │ ├── apply_local_changes.sh
│ │ │ ├── enhanced_memory_store.sh
│ │ │ ├── init_staging_db.sh
│ │ │ ├── io.litestream.replication.plist
│ │ │ ├── manual_sync.sh
│ │ │ ├── memory_sync.sh
│ │ │ ├── pull_remote_changes.sh
│ │ │ ├── push_to_remote.sh
│ │ │ ├── README.md
│ │ │ ├── resolve_conflicts.sh
│ │ │ ├── setup_local_litestream.sh
│ │ │ ├── setup_remote_litestream.sh
│ │ │ ├── staging_db_init.sql
│ │ │ ├── stash_local_changes.sh
│ │ │ ├── sync_from_remote_noconfig.sh
│ │ │ └── sync_from_remote.sh
│ │ ├── README.md
│ │ ├── safe_cloudflare_update.sh
│ │ ├── sync_memory_backends.py
│ │ └── sync_now.py
│ ├── testing
│ │ ├── run_complete_test.py
│ │ ├── run_memory_test.sh
│ │ ├── simple_test.py
│ │ ├── test_cleanup_logic.py
│ │ ├── test_cloudflare_backend.py
│ │ ├── test_docker_functionality.py
│ │ ├── test_installation.py
│ │ ├── test_mdns.py
│ │ ├── test_memory_api.py
│ │ ├── test_memory_simple.py
│ │ ├── test_migration.py
│ │ ├── test_search_api.py
│ │ ├── test_sqlite_vec_embeddings.py
│ │ ├── test_sse_events.py
│ │ ├── test-connection.py
│ │ └── test-hook.js
│ ├── utils
│ │ ├── claude_commands_utils.py
│ │ ├── generate_personalized_claude_md.sh
│ │ ├── groq
│ │ ├── groq_agent_bridge.py
│ │ ├── list-collections.py
│ │ ├── memory_wrapper_uv.py
│ │ ├── query_memories.py
│ │ ├── smithery_wrapper.py
│ │ ├── test_groq_bridge.sh
│ │ └── uv_wrapper.py
│ └── validation
│ ├── check_dev_setup.py
│ ├── check_documentation_links.py
│ ├── diagnose_backend_config.py
│ ├── validate_configuration_complete.py
│ ├── validate_memories.py
│ ├── validate_migration.py
│ ├── validate_timestamp_integrity.py
│ ├── verify_environment.py
│ ├── verify_pytorch_windows.py
│ └── verify_torch.py
├── SECURITY.md
├── selective_timestamp_recovery.py
├── SPONSORS.md
├── src
│ └── mcp_memory_service
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── client.py
│ │ ├── operations.py
│ │ ├── sync_wrapper.py
│ │ └── types.py
│ ├── backup
│ │ ├── __init__.py
│ │ └── scheduler.py
│ ├── cli
│ │ ├── __init__.py
│ │ ├── ingestion.py
│ │ ├── main.py
│ │ └── utils.py
│ ├── config.py
│ ├── consolidation
│ │ ├── __init__.py
│ │ ├── associations.py
│ │ ├── base.py
│ │ ├── clustering.py
│ │ ├── compression.py
│ │ ├── consolidator.py
│ │ ├── decay.py
│ │ ├── forgetting.py
│ │ ├── health.py
│ │ └── scheduler.py
│ ├── dependency_check.py
│ ├── discovery
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── mdns_service.py
│ ├── embeddings
│ │ ├── __init__.py
│ │ └── onnx_embeddings.py
│ ├── ingestion
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chunker.py
│ │ ├── csv_loader.py
│ │ ├── json_loader.py
│ │ ├── pdf_loader.py
│ │ ├── registry.py
│ │ ├── semtools_loader.py
│ │ └── text_loader.py
│ ├── lm_studio_compat.py
│ ├── mcp_server.py
│ ├── models
│ │ ├── __init__.py
│ │ └── memory.py
│ ├── server.py
│ ├── services
│ │ ├── __init__.py
│ │ └── memory_service.py
│ ├── storage
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── cloudflare.py
│ │ ├── factory.py
│ │ ├── http_client.py
│ │ ├── hybrid.py
│ │ └── sqlite_vec.py
│ ├── sync
│ │ ├── __init__.py
│ │ ├── exporter.py
│ │ ├── importer.py
│ │ └── litestream_config.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── cache_manager.py
│ │ ├── content_splitter.py
│ │ ├── db_utils.py
│ │ ├── debug.py
│ │ ├── document_processing.py
│ │ ├── gpu_detection.py
│ │ ├── hashing.py
│ │ ├── http_server_manager.py
│ │ ├── port_detection.py
│ │ ├── system_detection.py
│ │ └── time_parser.py
│ └── web
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── analytics.py
│ │ ├── backup.py
│ │ ├── consolidation.py
│ │ ├── documents.py
│ │ ├── events.py
│ │ ├── health.py
│ │ ├── manage.py
│ │ ├── mcp.py
│ │ ├── memories.py
│ │ ├── search.py
│ │ └── sync.py
│ ├── app.py
│ ├── dependencies.py
│ ├── oauth
│ │ ├── __init__.py
│ │ ├── authorization.py
│ │ ├── discovery.py
│ │ ├── middleware.py
│ │ ├── models.py
│ │ ├── registration.py
│ │ └── storage.py
│ ├── sse.py
│ └── static
│ ├── app.js
│ ├── index.html
│ ├── README.md
│ ├── sse_test.html
│ └── style.css
├── start_http_debug.bat
├── start_http_server.sh
├── test_document.txt
├── test_version_checker.js
├── tests
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── test_compact_types.py
│ │ └── test_operations.py
│ ├── bridge
│ │ ├── mock_responses.js
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ └── test_http_mcp_bridge.js
│ ├── conftest.py
│ ├── consolidation
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── test_associations.py
│ │ ├── test_clustering.py
│ │ ├── test_compression.py
│ │ ├── test_consolidator.py
│ │ ├── test_decay.py
│ │ └── test_forgetting.py
│ ├── contracts
│ │ └── api-specification.yml
│ ├── integration
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── test_api_key_fallback.py
│ │ ├── test_api_memories_chronological.py
│ │ ├── test_api_tag_time_search.py
│ │ ├── test_api_with_memory_service.py
│ │ ├── test_bridge_integration.js
│ │ ├── test_cli_interfaces.py
│ │ ├── test_cloudflare_connection.py
│ │ ├── test_concurrent_clients.py
│ │ ├── test_data_serialization_consistency.py
│ │ ├── test_http_server_startup.py
│ │ ├── test_mcp_memory.py
│ │ ├── test_mdns_integration.py
│ │ ├── test_oauth_basic_auth.py
│ │ ├── test_oauth_flow.py
│ │ ├── test_server_handlers.py
│ │ └── test_store_memory.py
│ ├── performance
│ │ ├── test_background_sync.py
│ │ └── test_hybrid_live.py
│ ├── README.md
│ ├── smithery
│ │ └── test_smithery.py
│ ├── sqlite
│ │ └── simple_sqlite_vec_test.py
│ ├── test_client.py
│ ├── test_content_splitting.py
│ ├── test_database.py
│ ├── test_hybrid_cloudflare_limits.py
│ ├── test_hybrid_storage.py
│ ├── test_memory_ops.py
│ ├── test_semantic_search.py
│ ├── test_sqlite_vec_storage.py
│ ├── test_time_parser.py
│ ├── test_timestamp_preservation.py
│ ├── timestamp
│ │ ├── test_hook_vs_manual_storage.py
│ │ ├── test_issue99_final_validation.py
│ │ ├── test_search_retrieval_inconsistency.py
│ │ ├── test_timestamp_issue.py
│ │ └── test_timestamp_simple.py
│ └── unit
│ ├── conftest.py
│ ├── test_cloudflare_storage.py
│ ├── test_csv_loader.py
│ ├── test_fastapi_dependencies.py
│ ├── test_import.py
│ ├── test_json_loader.py
│ ├── test_mdns_simple.py
│ ├── test_mdns.py
│ ├── test_memory_service.py
│ ├── test_memory.py
│ ├── test_semtools_loader.py
│ ├── test_storage_interface_compatibility.py
│ └── test_tag_time_filtering.py
├── tools
│ ├── docker
│ │ ├── DEPRECATED.md
│ │ ├── docker-compose.http.yml
│ │ ├── docker-compose.pythonpath.yml
│ │ ├── docker-compose.standalone.yml
│ │ ├── docker-compose.uv.yml
│ │ ├── docker-compose.yml
│ │ ├── docker-entrypoint-persistent.sh
│ │ ├── docker-entrypoint-unified.sh
│ │ ├── docker-entrypoint.sh
│ │ ├── Dockerfile
│ │ ├── Dockerfile.glama
│ │ ├── Dockerfile.slim
│ │ ├── README.md
│ │ └── test-docker-modes.sh
│ └── README.md
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/src/mcp_memory_service/web/static/app.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * MCP Memory Service Dashboard - Main Application
3 | * Interactive frontend for memory management with real-time updates
4 | */
5 |
6 | console.log('⚡ app.js loading - TOP OF FILE');
7 |
8 | class MemoryDashboard {
9 | // Delay between individual file uploads to avoid overwhelming the server (ms)
10 | static INDIVIDUAL_UPLOAD_DELAY = 500;
11 |
12 | // Static configuration for settings modal system information
13 | static SYSTEM_INFO_CONFIG = {
14 | settingsVersion: {
15 | sources: [{ path: 'version', api: 'health' }],
16 | formatter: (value) => value || 'N/A'
17 | },
18 | settingsBackend: {
19 | sources: [
20 | { path: 'storage.storage_backend', api: 'detailedHealth' },
21 | { path: 'storage.backend', api: 'detailedHealth' }
22 | ],
23 | formatter: (value) => value || 'N/A'
24 | },
25 | settingsPrimaryBackend: {
26 | sources: [
27 | { path: 'storage.primary_backend', api: 'detailedHealth' },
28 | { path: 'storage.backend', api: 'detailedHealth' }
29 | ],
30 | formatter: (value) => value || 'N/A'
31 | },
32 | settingsEmbeddingModel: {
33 | sources: [
34 | { path: 'storage.primary_stats.embedding_model', api: 'detailedHealth' },
35 | { path: 'storage.embedding_model', api: 'detailedHealth' }
36 | ],
37 | formatter: (value) => value || 'N/A'
38 | },
39 | settingsEmbeddingDim: {
40 | sources: [
41 | { path: 'storage.primary_stats.embedding_dimension', api: 'detailedHealth' },
42 | { path: 'storage.embedding_dimension', api: 'detailedHealth' }
43 | ],
44 | formatter: (value) => value || 'N/A'
45 | },
46 | settingsDbSize: {
47 | sources: [
48 | { path: 'storage.primary_stats.database_size_mb', api: 'detailedHealth' },
49 | { path: 'storage.database_size_mb', api: 'detailedHealth' }
50 | ],
51 | formatter: (value) => (value != null) ? `${value.toFixed(2)} MB` : 'N/A'
52 | },
53 | settingsTotalMemories: {
54 | sources: [{ path: 'storage.total_memories', api: 'detailedHealth' }],
55 | formatter: (value) => (value != null) ? value.toLocaleString() : 'N/A'
56 | },
57 | settingsUptime: {
58 | sources: [{ path: 'uptime_seconds', api: 'detailedHealth' }],
59 | formatter: (value) => (value != null) ? MemoryDashboard.formatUptime(value) : 'N/A'
60 | }
61 | };
62 |
63 | constructor() {
64 | this.apiBase = '/api';
65 | this.eventSource = null;
66 | this.memories = [];
67 | this.currentView = 'dashboard';
68 | this.searchResults = [];
69 | this.isLoading = false;
70 | this.liveSearchEnabled = true;
71 | this.debounceTimer = null;
72 |
73 | // Settings with defaults
74 | this.settings = {
75 | theme: 'light',
76 | viewDensity: 'comfortable',
77 | previewLines: 3
78 | };
79 |
80 | // Documents upload state
81 | this.selectedFiles = [];
82 | this.documentsListenersSetup = false;
83 | this.processingMode = 'batch'; // 'batch' or 'individual'
84 |
85 | // Bind methods
86 | this.handleSearch = this.handleSearch.bind(this);
87 | this.handleQuickSearch = this.handleQuickSearch.bind(this);
88 | this.handleNavigation = this.handleNavigation.bind(this);
89 | this.handleAddMemory = this.handleAddMemory.bind(this);
90 | this.handleMemoryClick = this.handleMemoryClick.bind(this);
91 |
92 | this.init();
93 | }
94 |
95 | /**
96 | * Initialize the application
97 | */
98 | async init() {
99 | this.loadSettings();
100 | this.applyTheme();
101 | this.setupEventListeners();
102 | this.setupSSE();
103 | await this.loadVersion();
104 | await this.loadDashboardData();
105 | this.updateConnectionStatus('connected');
106 |
107 | // Initialize sync status monitoring for hybrid mode
108 | await this.checkSyncStatus();
109 | this.startSyncStatusMonitoring();
110 | }
111 |
112 | /**
113 | * Set up event listeners for UI interactions
114 | */
115 | setupEventListeners() {
116 | console.log('⚡ setupEventListeners() called');
117 | // Navigation
118 | document.querySelectorAll('.nav-item').forEach(item => {
119 | item.addEventListener('click', this.handleNavigation);
120 | });
121 |
122 | // Search functionality
123 | const quickSearch = document.getElementById('quickSearch');
124 | const searchBtn = document.querySelector('.search-btn');
125 |
126 | if (quickSearch) {
127 | quickSearch.addEventListener('input', this.debounce(this.handleQuickSearch, 300));
128 | quickSearch.addEventListener('keypress', (e) => {
129 | if (e.key === 'Enter') {
130 | this.handleSearch(e.target.value);
131 | }
132 | });
133 | }
134 |
135 | if (searchBtn && quickSearch) {
136 | searchBtn.addEventListener('click', () => {
137 | this.handleSearch(quickSearch.value);
138 | });
139 | }
140 |
141 | // Add memory functionality
142 | const addMemoryBtn = document.getElementById('addMemoryBtn');
143 | if (addMemoryBtn) {
144 | addMemoryBtn.addEventListener('click', this.handleAddMemory);
145 | }
146 | document.querySelectorAll('[data-action="add-memory"]').forEach(btn => {
147 | btn.addEventListener('click', this.handleAddMemory);
148 | });
149 |
150 | // Modal close handlers
151 | document.querySelectorAll('.modal-close').forEach(btn => {
152 | btn.addEventListener('click', (e) => {
153 | this.closeModal(e.target.closest('.modal-overlay'));
154 | });
155 | });
156 |
157 | // Modal overlay click to close
158 | document.querySelectorAll('.modal-overlay').forEach(overlay => {
159 | overlay.addEventListener('click', (e) => {
160 | if (e.target === overlay) {
161 | this.closeModal(overlay);
162 | }
163 | });
164 | });
165 |
166 | // Add memory form submission
167 | const saveMemoryBtn = document.getElementById('saveMemoryBtn');
168 | if (saveMemoryBtn) {
169 | saveMemoryBtn.addEventListener('click', this.handleSaveMemory.bind(this));
170 | }
171 |
172 | const cancelAddBtn = document.getElementById('cancelAddBtn');
173 | if (cancelAddBtn) {
174 | cancelAddBtn.addEventListener('click', () => {
175 | this.closeModal(document.getElementById('addMemoryModal'));
176 | });
177 | }
178 |
179 | // Quick action handlers
180 | document.querySelectorAll('.action-card').forEach(card => {
181 | card.addEventListener('click', (e) => {
182 | const action = e.currentTarget.dataset.action;
183 | this.handleQuickAction(action);
184 | });
185 | });
186 |
187 | // Live search toggle handler
188 | const liveSearchToggle = document.getElementById('liveSearchToggle');
189 | liveSearchToggle?.addEventListener('change', this.handleLiveSearchToggle.bind(this));
190 |
191 | // Filter handlers for search view
192 | const tagFilterInput = document.getElementById('tagFilter');
193 | tagFilterInput?.addEventListener('input', this.handleDebouncedFilterChange.bind(this));
194 | tagFilterInput?.addEventListener('keypress', (e) => {
195 | if (e.key === 'Enter') {
196 | this.handleFilterChange();
197 | }
198 | });
199 | document.getElementById('dateFilter')?.addEventListener('change', this.handleFilterChange.bind(this));
200 | document.getElementById('typeFilter')?.addEventListener('change', this.handleFilterChange.bind(this));
201 |
202 | // View option handlers
203 | document.querySelectorAll('.view-btn').forEach(btn => {
204 | btn.addEventListener('click', (e) => {
205 | this.handleViewModeChange(e.target.dataset.view);
206 | });
207 | });
208 |
209 | // New filter action handlers
210 | document.getElementById('applyFiltersBtn')?.addEventListener('click', this.handleFilterChange.bind(this));
211 | document.getElementById('clearFiltersBtn')?.addEventListener('click', this.clearAllFilters.bind(this));
212 |
213 | // Theme toggle button
214 | document.getElementById('themeToggleBtn')?.addEventListener('click', () => {
215 | this.toggleTheme();
216 | });
217 |
218 | // Settings button
219 | document.getElementById('settingsBtn')?.addEventListener('click', () => {
220 | this.openSettingsModal();
221 | });
222 |
223 | // Settings modal handlers
224 | document.getElementById('saveSettingsBtn')?.addEventListener('click', () => {
225 | this.saveSettings();
226 | });
227 |
228 | document.getElementById('cancelSettingsBtn')?.addEventListener('click', () => {
229 | this.closeModal(document.getElementById('settingsModal'));
230 | });
231 |
232 | // Tag cloud event delegation
233 | document.getElementById('tagsCloudContainer')?.addEventListener('click', (e) => {
234 | if (e.target.classList.contains('tag-bubble') || e.target.closest('.tag-bubble')) {
235 | const tagButton = e.target.classList.contains('tag-bubble') ? e.target : e.target.closest('.tag-bubble');
236 | const tag = tagButton.dataset.tag;
237 | if (tag) {
238 | this.filterByTag(tag);
239 | }
240 | }
241 | });
242 |
243 | // Manage tab event listeners
244 | document.getElementById('deleteByTagBtn')?.addEventListener('click', this.handleBulkDeleteByTag.bind(this));
245 | document.getElementById('cleanupDuplicatesBtn')?.addEventListener('click', this.handleCleanupDuplicates.bind(this));
246 | document.getElementById('deleteByDateBtn')?.addEventListener('click', this.handleBulkDeleteByDate.bind(this));
247 | document.getElementById('optimizeDbBtn')?.addEventListener('click', this.handleOptimizeDatabase.bind(this));
248 | document.getElementById('rebuildIndexBtn')?.addEventListener('click', this.handleRebuildIndex.bind(this));
249 |
250 | // Analytics tab event listeners
251 | document.getElementById('growthPeriodSelect')?.addEventListener('change', this.handleGrowthPeriodChange.bind(this));
252 | document.getElementById('heatmapPeriodSelect')?.addEventListener('change', this.handleHeatmapPeriodChange.bind(this));
253 | document.getElementById('topTagsPeriodSelect')?.addEventListener('change', this.handleTopTagsPeriodChange.bind(this));
254 | document.getElementById('activityGranularitySelect')?.addEventListener('change', this.handleActivityGranularityChange.bind(this));
255 |
256 | // Keyboard shortcuts
257 | document.addEventListener('keydown', (e) => {
258 | if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
259 | e.preventDefault();
260 | document.getElementById('searchInput').focus();
261 | }
262 | if ((e.ctrlKey || e.metaKey) && e.key === 'm') {
263 | e.preventDefault();
264 | this.handleAddMemory();
265 | }
266 | });
267 | }
268 |
269 | /**
270 | * Set up Server-Sent Events for real-time updates
271 | */
272 | setupSSE() {
273 | try {
274 | this.eventSource = new EventSource(`${this.apiBase}/events`);
275 |
276 | this.eventSource.onopen = () => {
277 | this.updateConnectionStatus('connected');
278 | };
279 |
280 | this.eventSource.onmessage = (event) => {
281 | try {
282 | const data = JSON.parse(event.data);
283 | this.handleRealtimeUpdate(data);
284 | } catch (error) {
285 | console.error('Error parsing SSE data:', error);
286 | }
287 | };
288 |
289 | // Add specific event listeners for sync progress
290 | this.eventSource.addEventListener('sync_progress', (event) => {
291 | try {
292 | const data = JSON.parse(event.data);
293 | this.handleSyncProgress(data);
294 | } catch (error) {
295 | console.error('Error parsing sync_progress event:', error);
296 | }
297 | });
298 |
299 | this.eventSource.addEventListener('sync_completed', (event) => {
300 | try {
301 | const data = JSON.parse(event.data);
302 | this.handleSyncCompleted(data);
303 | } catch (error) {
304 | console.error('Error parsing sync_completed event:', error);
305 | }
306 | });
307 |
308 | this.eventSource.onerror = (error) => {
309 | console.error('SSE connection error:', error);
310 | this.updateConnectionStatus('disconnected');
311 |
312 | // Attempt to reconnect after 5 seconds
313 | setTimeout(() => {
314 | if (this.eventSource.readyState === EventSource.CLOSED) {
315 | this.setupSSE();
316 | }
317 | }, 5000);
318 | };
319 |
320 | } catch (error) {
321 | console.error('Failed to establish SSE connection:', error);
322 | this.updateConnectionStatus('disconnected');
323 | }
324 | }
325 |
326 | /**
327 | * Handle real-time updates from SSE
328 | */
329 | handleRealtimeUpdate(data) {
330 | switch (data.type) {
331 | case 'memory_added':
332 | this.handleMemoryAdded(data.memory);
333 | this.showToast('Memory added successfully', 'success');
334 | break;
335 | case 'memory_deleted':
336 | this.handleMemoryDeleted(data.memory_id);
337 | this.showToast('Memory deleted', 'success');
338 | break;
339 | case 'memory_updated':
340 | this.handleMemoryUpdated(data.memory);
341 | this.showToast('Memory updated', 'success');
342 | break;
343 | case 'stats_updated':
344 | this.updateDashboardStats(data.stats);
345 | break;
346 | default:
347 | // Unknown event type - ignore silently
348 | }
349 | }
350 |
351 | /**
352 | * Handle sync progress updates from SSE
353 | */
354 | handleSyncProgress(data) {
355 | console.log('Sync progress:', data);
356 |
357 | // Update sync status display if visible
358 | const syncStatus = document.getElementById('syncStatus');
359 | if (syncStatus) {
360 | const progressText = `Syncing: ${data.synced_count}/${data.total_count} (${data.progress_percentage}%)`;
361 | syncStatus.textContent = progressText;
362 | syncStatus.className = 'sync-status syncing';
363 | }
364 |
365 | // Update memory count in real-time if on dashboard
366 | if (this.currentView === 'dashboard') {
367 | const memoryCountElement = document.getElementById('totalMemories');
368 | if (memoryCountElement && data.synced_count) {
369 | // Refresh the detailed health to get accurate count
370 | this.loadDashboardData().catch(err => console.error('Error refreshing dashboard:', err));
371 | }
372 | }
373 |
374 | // Show toast notification for manual sync
375 | if (data.sync_type === 'manual') {
376 | this.showToast(data.message || `Syncing: ${data.synced_count}/${data.total_count}`, 'info');
377 | }
378 | }
379 |
380 | /**
381 | * Handle sync completion from SSE
382 | */
383 | handleSyncCompleted(data) {
384 | console.log('Sync completed:', data);
385 |
386 | // Update sync status display
387 | const syncStatus = document.getElementById('syncStatus');
388 | if (syncStatus) {
389 | syncStatus.textContent = 'Synced';
390 | syncStatus.className = 'sync-status synced';
391 | }
392 |
393 | // Refresh dashboard data to show updated counts
394 | if (this.currentView === 'dashboard') {
395 | this.loadDashboardData().catch(err => console.error('Error refreshing dashboard:', err));
396 | }
397 |
398 | // Also refresh sync status for hybrid mode
399 | this.checkSyncStatus().catch(err => console.error('Error checking sync status:', err));
400 |
401 | // Show completion notification
402 | const message = data.message || `Sync completed: ${data.synced_count} memories synced`;
403 | this.showToast(message, 'success');
404 | }
405 |
406 | /**
407 | * Load application version from health endpoint
408 | */
409 | async loadVersion() {
410 | try {
411 | const healthResponse = await this.apiCall('/health');
412 | const versionBadge = document.getElementById('versionBadge');
413 | if (versionBadge && healthResponse.version) {
414 | versionBadge.textContent = `v${healthResponse.version}`;
415 | }
416 | } catch (error) {
417 | console.error('Error loading version:', error);
418 | const versionBadge = document.getElementById('versionBadge');
419 | if (versionBadge) {
420 | versionBadge.textContent = 'v?.?.?';
421 | }
422 | }
423 | }
424 |
425 | /**
426 | * Load initial dashboard data
427 | */
428 | async loadDashboardData() {
429 | this.setLoading(true);
430 |
431 | try {
432 | // Load recent memories for dashboard display
433 | const memoriesResponse = await this.apiCall('/memories?page=1&page_size=100');
434 | if (memoriesResponse.memories) {
435 | this.memories = memoriesResponse.memories;
436 | this.renderRecentMemories(memoriesResponse.memories);
437 | }
438 |
439 | // Load basic statistics
440 | const statsResponse = await this.apiCall('/health/detailed');
441 | if (statsResponse.storage) {
442 | this.updateDashboardStats(statsResponse.storage);
443 | }
444 |
445 |
446 | } catch (error) {
447 | console.error('Error loading dashboard data:', error);
448 | this.showToast('Failed to load dashboard data', 'error');
449 | } finally {
450 | this.setLoading(false);
451 | }
452 | }
453 |
454 | /**
455 | * Load browse view data (tags)
456 | */
457 | async loadBrowseData() {
458 | this.setLoading(true);
459 | try {
460 | // Load tags with counts from the dedicated endpoint
461 | const tagsResponse = await this.apiCall('/tags');
462 | if (tagsResponse.tags) {
463 | this.tags = tagsResponse.tags;
464 | this.renderTagsCloud();
465 | }
466 | } catch (error) {
467 | console.error('Error loading browse data:', error);
468 | this.showToast('Failed to load browse data', 'error');
469 | } finally {
470 | this.setLoading(false);
471 | }
472 | }
473 |
474 | /**
475 | * Load documents view data
476 | */
477 | async loadDocumentsData() {
478 | this.setLoading(true);
479 | try {
480 | // Load upload history
481 | await this.loadUploadHistory();
482 | // Setup document upload event listeners
483 | this.setupDocumentsEventListeners();
484 | } catch (error) {
485 | console.error('Error loading documents data:', error);
486 | this.showToast('Failed to load documents data', 'error');
487 | } finally {
488 | this.setLoading(false);
489 | }
490 | }
491 |
492 | /**
493 | * Load upload history from API
494 | */
495 | async loadUploadHistory() {
496 | console.log('Loading upload history...');
497 | try {
498 | const historyResponse = await this.apiCall('/documents/history');
499 | console.log('Upload history response:', historyResponse);
500 | if (historyResponse.uploads) {
501 | this.renderUploadHistory(historyResponse.uploads);
502 | } else {
503 | console.warn('No uploads property in response');
504 | this.renderUploadHistory([]);
505 | }
506 | } catch (error) {
507 | console.error('Error loading upload history:', error);
508 | // Show a message in the history container instead of just logging
509 | const historyContainer = document.getElementById('uploadHistory');
510 | if (historyContainer) {
511 | historyContainer.innerHTML = '<p style="text-align: center; color: var(--error);">Failed to load upload history. Please check the console for details.</p>';
512 | }
513 | }
514 | }
515 |
516 | /**
517 | * Render upload history
518 | */
519 | renderUploadHistory(uploads) {
520 | const historyContainer = document.getElementById('uploadHistory');
521 | if (!historyContainer) return;
522 |
523 | if (uploads.length === 0) {
524 | historyContainer.innerHTML = '<p style="text-align: center; color: var(--neutral-500);">No uploads yet. Start by uploading some documents!</p>';
525 | return;
526 | }
527 |
528 | const historyHtml = uploads.map(upload => {
529 | const statusClass = upload.status.toLowerCase();
530 | const statusText = upload.status.charAt(0).toUpperCase() + upload.status.slice(1);
531 | const progressPercent = upload.progress || 0;
532 | const hasMemories = upload.chunks_stored > 0;
533 |
534 | return `
535 | <div class="upload-item ${statusClass}" data-upload-id="${upload.upload_id}" data-filename="${this.escapeHtml(upload.filename)}">
536 | <div class="upload-info">
537 | <div class="upload-filename">${this.escapeHtml(upload.filename)}</div>
538 | <div class="upload-meta">
539 | ${upload.chunks_stored || 0} chunks stored •
540 | ${(upload.file_size / 1024).toFixed(1)} KB •
541 | ${new Date(upload.created_at).toLocaleString()}
542 | </div>
543 | ${upload.status === 'processing' ? `
544 | <div class="progress-bar">
545 | <div class="progress-fill" style="width: ${progressPercent}%"></div>
546 | </div>
547 | ` : ''}
548 | </div>
549 | <div class="upload-actions-container">
550 | <div class="upload-status ${statusClass}">
551 | <span>${statusText}</span>
552 | ${upload.errors && upload.errors.length > 0 ? `
553 | <span title="${this.escapeHtml(upload.errors.join('; '))}">⚠️</span>
554 | ` : ''}
555 | </div>
556 | ${upload.status === 'completed' && hasMemories ? `
557 | <div class="upload-actions">
558 | <button class="btn-icon btn-view-memory"
559 | title="View memory chunks">
560 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
561 | <path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
562 | </svg>
563 | <span>View</span>
564 | </button>
565 | <button class="btn-icon btn-remove"
566 | title="Remove document and memories">
567 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
568 | <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
569 | </svg>
570 | <span>Remove</span>
571 | </button>
572 | </div>
573 | ` : ''}
574 | </div>
575 | </div>
576 | `;
577 | }).join('');
578 |
579 | historyContainer.innerHTML = historyHtml;
580 | }
581 |
582 | /**
583 | * Setup event listeners for documents view
584 | */
585 | setupDocumentsEventListeners() {
586 | // Prevent duplicate event listener setup
587 | if (this.documentsListenersSetup) {
588 | console.log('Document listeners already set up, skipping...');
589 | return;
590 | }
591 |
592 | // File selection buttons
593 | const fileSelectBtn = document.getElementById('fileSelectBtn');
594 | const fileInput = document.getElementById('fileInput');
595 |
596 | if (fileSelectBtn && fileInput) {
597 | fileSelectBtn.addEventListener('click', () => {
598 | fileInput.click();
599 | });
600 |
601 | fileInput.addEventListener('change', (e) => {
602 | this.handleFileSelection(e.target.files);
603 | });
604 | }
605 |
606 | // Drag and drop
607 | const dropZone = document.getElementById('dropZone');
608 | if (dropZone) {
609 | dropZone.addEventListener('dragover', (e) => {
610 | e.preventDefault();
611 | dropZone.classList.add('drag-over');
612 | });
613 |
614 | dropZone.addEventListener('dragleave', (e) => {
615 | e.preventDefault();
616 | dropZone.classList.remove('drag-over');
617 | });
618 |
619 | dropZone.addEventListener('drop', (e) => {
620 | e.preventDefault();
621 | dropZone.classList.remove('drag-over');
622 | const files = e.dataTransfer.files;
623 | this.handleFileSelection(files);
624 | });
625 | }
626 |
627 | // Configuration controls - cache for performance
628 | this.chunkSizeInput = document.getElementById('chunkSize');
629 | this.chunkOverlapInput = document.getElementById('chunkOverlap');
630 | this.memoryTypeInput = document.getElementById('memoryType');
631 | const chunkSizeValue = document.getElementById('chunkSizeValue');
632 | const chunkOverlapValue = document.getElementById('chunkOverlapValue');
633 |
634 | if (this.chunkSizeInput && chunkSizeValue) {
635 | this.chunkSizeInput.addEventListener('input', (e) => {
636 | chunkSizeValue.textContent = e.target.value;
637 | this.updateUploadButton();
638 | });
639 | }
640 |
641 | // Chunking help info icon
642 | const infoIcon = document.querySelector('.info-icon');
643 | if (infoIcon) {
644 | infoIcon.addEventListener('click', () => {
645 | this.toggleChunkingHelp();
646 | });
647 | }
648 |
649 | // Overlap help info icon
650 | const overlapInfoIcon = document.querySelector('.info-icon-overlap');
651 | if (overlapInfoIcon) {
652 | overlapInfoIcon.addEventListener('click', () => {
653 | this.toggleOverlapHelp();
654 | });
655 | }
656 |
657 | // Processing mode help info icon
658 | const processingModeInfoIcon = document.querySelector('.info-icon-processing');
659 | if (processingModeInfoIcon) {
660 | processingModeInfoIcon.addEventListener('click', () => {
661 | this.toggleProcessingModeHelp();
662 | });
663 | }
664 |
665 | if (this.chunkOverlapInput && chunkOverlapValue) {
666 | this.chunkOverlapInput.addEventListener('input', (e) => {
667 | chunkOverlapValue.textContent = e.target.value;
668 | this.updateUploadButton();
669 | });
670 | }
671 |
672 | // Processing mode toggle buttons - cache for performance
673 | this.batchModeBtn = document.getElementById('batchModeBtn');
674 | this.individualModeBtn = document.getElementById('individualModeBtn');
675 | this.modeDescription = document.getElementById('modeDescription');
676 |
677 | if (this.batchModeBtn) {
678 | this.batchModeBtn.addEventListener('click', () => {
679 | this.setProcessingMode('batch');
680 | });
681 | }
682 |
683 | if (this.individualModeBtn) {
684 | this.individualModeBtn.addEventListener('click', () => {
685 | this.setProcessingMode('individual');
686 | });
687 | }
688 |
689 | // Upload button
690 | const uploadBtn = document.getElementById('uploadBtn');
691 | if (uploadBtn) {
692 | uploadBtn.addEventListener('click', () => {
693 | this.handleDocumentUpload();
694 | });
695 | }
696 |
697 | // Add event listeners for buttons with data-action attribute
698 | document.querySelectorAll('[data-action]').forEach(button => {
699 | button.addEventListener('click', (e) => {
700 | const action = e.currentTarget.dataset.action;
701 | if (this[action] && typeof this[action] === 'function') {
702 | this[action]();
703 | }
704 | });
705 | });
706 |
707 | // Sync buttons event listeners are attached in checkSyncStatus()
708 | // after buttons are confirmed to be accessible in the DOM
709 |
710 | // Backup now button
711 | const backupNowButton = document.getElementById('backupNowButton');
712 | if (backupNowButton) {
713 | backupNowButton.addEventListener('click', () => {
714 | this.createBackup();
715 | });
716 | }
717 |
718 | // Document search button
719 | const docSearchBtn = document.getElementById('docSearchBtn');
720 | const docSearchInput = document.getElementById('docSearchInput');
721 | if (docSearchBtn && docSearchInput) {
722 | docSearchBtn.addEventListener('click', () => {
723 | const query = docSearchInput.value.trim();
724 | if (query) {
725 | this.searchDocumentContent(query);
726 | } else {
727 | this.showToast('Please enter a search query', 'warning');
728 | }
729 | });
730 |
731 | // Enter key to search
732 | docSearchInput.addEventListener('keypress', (e) => {
733 | if (e.key === 'Enter') {
734 | const query = docSearchInput.value.trim();
735 | if (query) {
736 | this.searchDocumentContent(query);
737 | }
738 | }
739 | });
740 | }
741 |
742 | // Upload history action buttons (event delegation)
743 | const uploadHistory = document.getElementById('uploadHistory');
744 | if (uploadHistory) {
745 | uploadHistory.addEventListener('click', (e) => {
746 | const button = e.target.closest('.btn-view-memory, .btn-remove');
747 | if (!button) return;
748 |
749 | const uploadItem = button.closest('.upload-item');
750 | const uploadId = uploadItem?.dataset.uploadId;
751 | const filename = uploadItem?.dataset.filename;
752 |
753 | if (!uploadId) return;
754 |
755 | if (button.classList.contains('btn-view-memory')) {
756 | this.viewDocumentMemory(uploadId);
757 | } else if (button.classList.contains('btn-remove')) {
758 | this.removeDocument(uploadId, filename);
759 | }
760 | });
761 | }
762 |
763 | // Close modal when clicking outside
764 | const memoryViewerModal = document.getElementById('memoryViewerModal');
765 | if (memoryViewerModal) {
766 | memoryViewerModal.addEventListener('click', (e) => {
767 | if (e.target === memoryViewerModal) {
768 | this.closeMemoryViewer();
769 | }
770 | });
771 | }
772 |
773 | // Mark listeners as set up to prevent duplicates
774 | this.documentsListenersSetup = true;
775 | console.log('Document listeners setup complete');
776 | }
777 |
778 | /**
779 | * Handle file selection from input or drag-drop
780 | */
781 | handleFileSelection(files) {
782 | if (!files || files.length === 0) return;
783 |
784 | this.selectedFiles = Array.from(files);
785 | this.updateUploadButton();
786 |
787 | // Show file preview in drop zone
788 | const dropZone = document.getElementById('dropZone');
789 | if (dropZone) {
790 | const fileNames = this.selectedFiles.map(f => this.escapeHtml(f.name)).join(', ');
791 | const content = dropZone.querySelector('.drop-zone-content');
792 | if (content) {
793 | content.innerHTML = `
794 | <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24">
795 | <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
796 | </svg>
797 | <h3>${files.length} file${files.length > 1 ? 's' : ''} selected</h3>
798 | <p>${fileNames}</p>
799 | <input type="file" id="fileInput" multiple accept=".pdf,.docx,.pptx,.txt,.md,.json" style="display: none;">
800 | `;
801 | }
802 | }
803 | }
804 |
805 | /**
806 | * Update upload button state based on selections
807 | */
808 | updateUploadButton() {
809 | const uploadBtn = document.getElementById('uploadBtn');
810 | const hasFiles = this.selectedFiles && this.selectedFiles.length > 0;
811 |
812 | if (uploadBtn) {
813 | uploadBtn.disabled = !hasFiles;
814 | uploadBtn.textContent = hasFiles ?
815 | `Upload & Ingest ${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''}` :
816 | 'Upload & Ingest';
817 | }
818 |
819 | // Show/hide processing mode section based on file count
820 | const processingModeSection = document.getElementById('processingModeSection');
821 | if (processingModeSection) {
822 | processingModeSection.style.display = (hasFiles && this.selectedFiles.length > 1) ? 'block' : 'none';
823 | }
824 | }
825 |
826 | /**
827 | * Set processing mode (batch or individual)
828 | */
829 | setProcessingMode(mode) {
830 | this.processingMode = mode;
831 |
832 | // Update button states (using cached DOM elements)
833 | if (this.batchModeBtn) {
834 | this.batchModeBtn.classList.toggle('active', mode === 'batch');
835 | }
836 | if (this.individualModeBtn) {
837 | this.individualModeBtn.classList.toggle('active', mode === 'individual');
838 | }
839 | if (this.modeDescription) {
840 | this.modeDescription.innerHTML = mode === 'batch'
841 | ? '<small>All selected files will be processed together with the same tags.</small>'
842 | : '<small>Each file will be processed individually with the same tags.</small>';
843 | }
844 |
845 | console.log(`Processing mode set to: ${mode}`);
846 | }
847 |
848 | /**
849 | * Handle document upload
850 | */
851 | async handleDocumentUpload() {
852 | if (!this.selectedFiles || this.selectedFiles.length === 0) {
853 | this.showToast('No files selected', 'error');
854 | return;
855 | }
856 |
857 | const tags = document.getElementById('docTags')?.value || '';
858 | const chunkSize = this.chunkSizeInput?.value || 1000;
859 | const chunkOverlap = this.chunkOverlapInput?.value || 200;
860 | const memoryType = this.memoryTypeInput?.value || 'document';
861 |
862 | try {
863 | this.setLoading(true);
864 |
865 | if (this.selectedFiles.length === 1 || this.processingMode === 'individual') {
866 | // Individual file processing (single file or individual mode for multiple files)
867 | for (let i = 0; i < this.selectedFiles.length; i++) {
868 | const file = this.selectedFiles[i];
869 | try {
870 | await this.uploadSingleDocument(file, {
871 | tags,
872 | chunk_size: parseInt(chunkSize),
873 | chunk_overlap: parseInt(chunkOverlap),
874 | memory_type: memoryType
875 | });
876 |
877 | // Small delay between individual uploads to avoid overwhelming the server
878 | if (i < this.selectedFiles.length - 1) {
879 | await new Promise(resolve => setTimeout(resolve, this.constructor.INDIVIDUAL_UPLOAD_DELAY));
880 | }
881 | } catch (error) {
882 | console.error(`Failed to upload ${file.name}:`, error);
883 | this.showToast(`Failed to upload ${file.name}: ${error.message}`, 'error');
884 | // Continue with remaining files
885 | }
886 | }
887 | } else {
888 | // Batch upload
889 | await this.uploadBatchDocuments(this.selectedFiles, {
890 | tags,
891 | chunk_size: parseInt(chunkSize),
892 | chunk_overlap: parseInt(chunkOverlap),
893 | memory_type: memoryType
894 | });
895 | }
896 |
897 | // Clear selection and reload history
898 | this.selectedFiles = [];
899 | this.updateUploadButton();
900 | await this.loadUploadHistory();
901 |
902 | // Reset drop zone
903 | const dropZone = document.getElementById('dropZone');
904 | if (dropZone) {
905 | const content = dropZone.querySelector('.drop-zone-content');
906 | if (content) {
907 | content.innerHTML = `
908 | <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24">
909 | <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
910 | </svg>
911 | <h3>Drag & drop files here</h3>
912 | <p>or <button id="fileSelectBtn" class="link-button">browse to select files</button></p>
913 | <p class="supported-formats">Supported formats: PDF, DOCX, PPTX, TXT, MD, JSON</p>
914 | <input type="file" id="fileInput" multiple accept=".pdf,.docx,.pptx,.txt,.md,.json" style="display: none;">
915 | `;
916 | }
917 | // Re-setup event listeners for the new elements
918 | this.setupDocumentsEventListeners();
919 | }
920 |
921 | } catch (error) {
922 | console.error('Upload error:', error);
923 | this.showToast('Upload failed: ' + error.message, 'error');
924 | } finally {
925 | this.setLoading(false);
926 | }
927 | }
928 |
929 | /**
930 | * Upload single document
931 | */
932 | async uploadSingleDocument(file, config) {
933 | console.log(`Uploading file: ${file.name}, size: ${file.size} bytes`);
934 | const formData = new FormData();
935 | formData.append('file', file);
936 | formData.append('tags', config.tags);
937 | formData.append('chunk_size', config.chunk_size.toString());
938 | formData.append('chunk_overlap', config.chunk_overlap.toString());
939 | formData.append('memory_type', config.memory_type);
940 |
941 | const response = await fetch(`${this.apiBase}/documents/upload`, {
942 | method: 'POST',
943 | body: formData
944 | });
945 |
946 | console.log(`Upload response status: ${response.status}`);
947 |
948 | if (!response.ok) {
949 | let errorMessage = `Upload failed with status ${response.status}`;
950 | try {
951 | const error = await response.json();
952 | console.error('Upload error details:', error);
953 | errorMessage = error.detail || error.message || errorMessage;
954 | } catch (e) {
955 | console.error('Could not parse error response:', e);
956 | try {
957 | const errorText = await response.text();
958 | console.error('Error response text:', errorText);
959 | errorMessage = errorText || errorMessage;
960 | } catch (e2) {
961 | console.error('Could not read error response:', e2);
962 | }
963 | }
964 | throw new Error(errorMessage);
965 | }
966 |
967 | const result = await response.json();
968 | console.log('Upload result:', result);
969 | this.showToast(`Upload started for ${file.name}`, 'success');
970 |
971 | // Monitor progress if we have an upload ID
972 | if (result.upload_id) {
973 | this.monitorUploadProgress(result.upload_id);
974 | }
975 |
976 | return result;
977 | }
978 |
979 | /**
980 | * Upload batch documents
981 | */
982 | async uploadBatchDocuments(files, config) {
983 | const formData = new FormData();
984 | files.forEach(file => {
985 | formData.append('files', file);
986 | });
987 | formData.append('tags', config.tags);
988 | formData.append('chunk_size', config.chunk_size.toString());
989 | formData.append('chunk_overlap', config.chunk_overlap.toString());
990 | formData.append('memory_type', config.memory_type);
991 |
992 | const response = await fetch(`${this.apiBase}/documents/batch-upload`, {
993 | method: 'POST',
994 | body: formData
995 | });
996 |
997 | if (!response.ok) {
998 | const error = await response.json();
999 | throw new Error(error.detail || 'Batch upload failed');
1000 | }
1001 |
1002 | const result = await response.json();
1003 | this.showToast(`Batch upload started for ${files.length} files`, 'success');
1004 |
1005 | // Monitor progress if we have an upload ID
1006 | if (result.upload_id) {
1007 | this.monitorUploadProgress(result.upload_id);
1008 | }
1009 |
1010 | return result;
1011 | }
1012 |
1013 | /**
1014 | * Monitor upload progress by polling status endpoint
1015 | */
1016 | monitorUploadProgress(uploadId) {
1017 | const pollStatus = async () => {
1018 | try {
1019 | const statusResponse = await this.apiCall(`/documents/status/${uploadId}`);
1020 | this.updateUploadProgress(uploadId, statusResponse);
1021 |
1022 | if (statusResponse.progress >= 100 || statusResponse.status === 'completed' || statusResponse.status === 'failed') {
1023 | // Upload completed, refresh history
1024 | this.loadUploadHistory();
1025 | } else {
1026 | // Continue polling
1027 | setTimeout(pollStatus, 2000); // Poll every 2 seconds
1028 | }
1029 | } catch (error) {
1030 | // If polling fails, try again with longer interval
1031 | setTimeout(pollStatus, 5000);
1032 | }
1033 | };
1034 |
1035 | // Start polling after a short delay
1036 | setTimeout(pollStatus, 1000);
1037 | }
1038 |
1039 | /**
1040 | * Update upload progress display
1041 | */
1042 | updateUploadProgress(uploadId, statusData) {
1043 | // Find the upload item in history and update it
1044 | const historyContainer = document.getElementById('uploadHistory');
1045 | if (!historyContainer) return;
1046 |
1047 | const uploadItems = historyContainer.querySelectorAll('.upload-item');
1048 | uploadItems.forEach(item => {
1049 | const filename = item.querySelector('.upload-filename');
1050 | if (filename && filename.textContent.includes(uploadId)) {
1051 | // This is a simplified update - in practice you'd match by upload ID
1052 | this.loadUploadHistory(); // For now, just refresh the entire history
1053 | }
1054 | });
1055 | }
1056 |
1057 | /**
1058 | * Check hybrid backend sync status
1059 | */
1060 | async checkSyncStatus() {
1061 | // Skip UI updates during force sync to prevent periodic polling from overwriting the syncing state
1062 | if (this._isForceSyncing) {
1063 | return;
1064 | }
1065 |
1066 | try {
1067 | const syncStatus = await this.apiCall('/sync/status');
1068 |
1069 | // Get compact sync control element
1070 | const syncControl = document.getElementById('syncControl');
1071 | if (!syncControl) {
1072 | console.warn('Sync control element not found');
1073 | return;
1074 | }
1075 |
1076 | if (!syncStatus.is_hybrid) {
1077 | syncControl.style.display = 'none';
1078 | return;
1079 | }
1080 |
1081 | // Show sync control for hybrid mode
1082 | syncControl.style.display = 'block';
1083 |
1084 | // Update sync status UI elements
1085 | const statusText = document.getElementById('syncStatusText');
1086 | const syncProgress = document.getElementById('syncProgress');
1087 | const pauseButton = document.getElementById('pauseSyncButton');
1088 | const resumeButton = document.getElementById('resumeSyncButton');
1089 | const syncButton = document.getElementById('forceSyncButton');
1090 |
1091 | // Update pause/resume button visibility based on running state
1092 | const isPaused = syncStatus.is_paused || !syncStatus.is_running;
1093 |
1094 | // Attach event listeners if not already attached
1095 | if (pauseButton && !pauseButton._listenerAttached) {
1096 | pauseButton.addEventListener('click', () => {
1097 | this.pauseSync();
1098 | });
1099 | pauseButton._listenerAttached = true;
1100 | }
1101 | if (resumeButton && !resumeButton._listenerAttached) {
1102 | resumeButton.addEventListener('click', () => {
1103 | this.resumeSync();
1104 | });
1105 | resumeButton._listenerAttached = true;
1106 | }
1107 | if (syncButton && !syncButton._listenerAttached) {
1108 | syncButton.addEventListener('click', () => {
1109 | this.forceSync();
1110 | });
1111 | syncButton._listenerAttached = true;
1112 | }
1113 |
1114 | if (pauseButton) {
1115 | pauseButton.style.display = isPaused ? 'none' : 'flex';
1116 | }
1117 | if (resumeButton) {
1118 | resumeButton.style.display = isPaused ? 'flex' : 'none';
1119 | }
1120 |
1121 | // Determine status and update UI (dot color is handled by CSS classes)
1122 | if (isPaused) {
1123 | statusText.textContent = 'Paused';
1124 | syncProgress.textContent = '';
1125 | syncControl.className = 'sync-control-compact paused';
1126 | if (syncButton) syncButton.disabled = true;
1127 | } else if (syncStatus.status === 'syncing') {
1128 | statusText.textContent = 'Syncing';
1129 | syncProgress.textContent = syncStatus.operations_pending > 0 ? `${syncStatus.operations_pending} pending` : '';
1130 | syncControl.className = 'sync-control-compact syncing';
1131 | if (syncButton) syncButton.disabled = true;
1132 | } else if (syncStatus.status === 'pending') {
1133 | statusText.textContent = 'Pending';
1134 | syncProgress.textContent = `${syncStatus.operations_pending} ops`;
1135 | syncControl.className = 'sync-control-compact pending';
1136 | if (syncButton) syncButton.disabled = false;
1137 | } else if (syncStatus.status === 'error') {
1138 | statusText.textContent = 'Error';
1139 | syncProgress.textContent = `${syncStatus.operations_failed} failed`;
1140 | syncControl.className = 'sync-control-compact error';
1141 | if (syncButton) syncButton.disabled = false;
1142 | } else {
1143 | // synced status
1144 | statusText.textContent = 'Synced';
1145 | syncProgress.textContent = '';
1146 | syncControl.className = 'sync-control-compact synced';
1147 | if (syncButton) syncButton.disabled = false;
1148 | }
1149 |
1150 | } catch (error) {
1151 | console.error('Error checking sync status:', error);
1152 | // Hide sync control on error (likely not hybrid mode)
1153 | const syncControl = document.getElementById('syncControl');
1154 | if (syncControl) syncControl.style.display = 'none';
1155 | }
1156 | }
1157 |
1158 | /**
1159 | * Format time delta in human readable format
1160 | */
1161 | formatTimeDelta(seconds) {
1162 | if (seconds < 60) return `${seconds}s ago`;
1163 | if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1164 | if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1165 | return `${Math.floor(seconds / 86400)}d ago`;
1166 | }
1167 |
1168 | /**
1169 | * Pause background sync
1170 | */
1171 | async pauseSync() {
1172 | try {
1173 | const result = await this.apiCall('/sync/pause', 'POST');
1174 | if (result.success) {
1175 | this.showToast('Sync paused', 'success');
1176 |
1177 | // Update UI immediately using API response data
1178 | const pauseButton = document.getElementById('pauseSyncButton');
1179 | const resumeButton = document.getElementById('resumeSyncButton');
1180 | const statusText = document.getElementById('syncStatusText');
1181 | const syncControl = document.getElementById('syncControl');
1182 |
1183 | if (pauseButton) pauseButton.style.display = 'none';
1184 | if (resumeButton) resumeButton.style.display = 'flex';
1185 | if (statusText) statusText.textContent = 'Paused';
1186 | if (syncControl) syncControl.className = 'sync-control-compact paused';
1187 |
1188 | // Small delay to allow backend state to propagate before checking status
1189 | await new Promise(resolve => setTimeout(resolve, 200));
1190 | } else {
1191 | this.showToast('Failed to pause sync: ' + result.message, 'error');
1192 | }
1193 | await this.checkSyncStatus();
1194 | } catch (error) {
1195 | console.error('Error pausing sync:', error);
1196 | this.showToast('Failed to pause sync', 'error');
1197 | }
1198 | }
1199 |
1200 | /**
1201 | * Resume background sync
1202 | */
1203 | async resumeSync() {
1204 | try {
1205 | const result = await this.apiCall('/sync/resume', 'POST');
1206 | if (result.success) {
1207 | this.showToast('Sync resumed', 'success');
1208 |
1209 | // Update UI immediately using API response data
1210 | const pauseButton = document.getElementById('pauseSyncButton');
1211 | const resumeButton = document.getElementById('resumeSyncButton');
1212 | const statusText = document.getElementById('syncStatusText');
1213 | const syncControl = document.getElementById('syncControl');
1214 |
1215 | if (pauseButton) pauseButton.style.display = 'flex';
1216 | if (resumeButton) resumeButton.style.display = 'none';
1217 | if (statusText) statusText.textContent = 'Synced';
1218 | if (syncControl) syncControl.className = 'sync-control-compact synced';
1219 |
1220 | // Small delay to allow backend state to propagate before checking status
1221 | await new Promise(resolve => setTimeout(resolve, 200));
1222 | } else {
1223 | this.showToast('Failed to resume sync: ' + result.message, 'error');
1224 | }
1225 | await this.checkSyncStatus();
1226 | } catch (error) {
1227 | console.error('Error resuming sync:', error);
1228 | this.showToast('Failed to resume sync', 'error');
1229 | }
1230 | }
1231 |
1232 | /**
1233 | * Check backup status and update Settings modal
1234 | */
1235 | async checkBackupStatus() {
1236 | try {
1237 | const backupStatus = await this.apiCall('/backup/status');
1238 |
1239 | // Update backup elements in Settings modal
1240 | const lastBackup = document.getElementById('settingsLastBackup');
1241 | const backupCount = document.getElementById('settingsBackupCount');
1242 | const nextBackup = document.getElementById('settingsNextBackup');
1243 |
1244 | if (!backupStatus.enabled) {
1245 | if (lastBackup) lastBackup.textContent = 'Backups disabled';
1246 | if (backupCount) backupCount.textContent = '-';
1247 | if (nextBackup) nextBackup.textContent = '-';
1248 | return;
1249 | }
1250 |
1251 | // Update last backup time
1252 | if (lastBackup) {
1253 | if (backupStatus.time_since_last_seconds) {
1254 | lastBackup.textContent = this.formatTimeDelta(Math.floor(backupStatus.time_since_last_seconds)) + ' ago';
1255 | } else {
1256 | lastBackup.textContent = 'Never';
1257 | }
1258 | }
1259 |
1260 | // Update backup count with size
1261 | if (backupCount) {
1262 | const sizeMB = (backupStatus.total_size_bytes / 1024 / 1024).toFixed(1);
1263 | backupCount.textContent = `${backupStatus.backup_count} (${sizeMB} MB)`;
1264 | }
1265 |
1266 | // Update next scheduled backup
1267 | if (nextBackup && backupStatus.next_backup_at) {
1268 | const nextDate = new Date(backupStatus.next_backup_at);
1269 | nextBackup.textContent = nextDate.toLocaleString();
1270 | } else if (nextBackup) {
1271 | nextBackup.textContent = backupStatus.scheduler_running ? 'Scheduled' : 'Not scheduled';
1272 | }
1273 |
1274 | } catch (error) {
1275 | console.error('Error checking backup status:', error);
1276 | }
1277 | }
1278 |
1279 | /**
1280 | * Create a backup manually
1281 | */
1282 | async createBackup() {
1283 | const backupButton = document.getElementById('backupNowButton');
1284 | if (backupButton) backupButton.disabled = true;
1285 |
1286 | try {
1287 | this.showToast('Creating backup...', 'info');
1288 | const result = await this.apiCall('/backup/now', 'POST');
1289 |
1290 | if (result.success) {
1291 | const sizeMB = (result.size_bytes / 1024 / 1024).toFixed(2);
1292 | this.showToast(`Backup created: ${result.filename} (${sizeMB} MB)`, 'success');
1293 | } else {
1294 | this.showToast('Backup failed: ' + result.error, 'error');
1295 | }
1296 |
1297 | await this.checkBackupStatus();
1298 |
1299 | } catch (error) {
1300 | console.error('Error creating backup:', error);
1301 | this.showToast('Failed to create backup', 'error');
1302 | } finally {
1303 | if (backupButton) backupButton.disabled = false;
1304 | }
1305 | }
1306 |
1307 | /**
1308 | * Start periodic sync status monitoring
1309 | */
1310 | startSyncStatusMonitoring() {
1311 | // Check sync status every 10 seconds
1312 | setInterval(() => {
1313 | this.checkSyncStatus();
1314 | }, 10000);
1315 | }
1316 |
1317 | /**
1318 | * Manually force sync to Cloudflare
1319 | */
1320 | async forceSync() {
1321 | const syncButton = document.getElementById('forceSyncButton');
1322 | const originalText = syncButton.innerHTML;
1323 |
1324 | try {
1325 | // Check if sync was paused before force sync
1326 | const statusBefore = await this.apiCall('/sync/status');
1327 | const wasPaused = statusBefore.is_paused;
1328 |
1329 | // Set flag to prevent periodic polling from overwriting UI during force sync
1330 | this._isForceSyncing = true;
1331 |
1332 | // Disable button and show loading state (just egg timer, no text - widget shows "Syncing")
1333 | syncButton.disabled = true;
1334 | syncButton.innerHTML = '<span class="sync-button-icon">⏳</span>';
1335 |
1336 | // IMMEDIATELY update sync control widget to show syncing state
1337 | const statusText = document.getElementById('syncStatusText');
1338 | const syncProgress = document.getElementById('syncProgress');
1339 | const syncControl = document.getElementById('syncControl');
1340 |
1341 | if (statusText) statusText.textContent = 'Syncing';
1342 | if (syncProgress) syncProgress.textContent = statusBefore.operations_pending > 0 ? `${statusBefore.operations_pending} pending` : '';
1343 | if (syncControl) syncControl.className = 'sync-control-compact syncing';
1344 |
1345 | // Show toast when sync starts
1346 | this.showToast('Starting sync...', 'info');
1347 |
1348 | const result = await this.apiCall('/sync/force', 'POST');
1349 |
1350 | if (result.success) {
1351 | this.showToast(`Synced ${result.operations_synced} operations in ${result.time_taken_seconds}s`, 'success');
1352 |
1353 | // Refresh dashboard data to show newly synced memories
1354 | if (this.currentView === 'dashboard') {
1355 | await this.loadDashboardData();
1356 | }
1357 |
1358 | // If sync was paused before, pause it again after force sync
1359 | if (wasPaused) {
1360 | await this.apiCall('/sync/pause', 'POST');
1361 | }
1362 | } else {
1363 | this.showToast('Sync failed: ' + result.message, 'error');
1364 | }
1365 |
1366 | } catch (error) {
1367 | console.error('Error forcing sync:', error);
1368 | this.showToast('Failed to force sync: ' + error.message, 'error');
1369 | } finally {
1370 | // Clear flag to allow periodic polling to resume
1371 | this._isForceSyncing = false;
1372 |
1373 | // Re-enable button
1374 | syncButton.disabled = false;
1375 | syncButton.innerHTML = originalText;
1376 |
1377 | // Refresh sync status immediately
1378 | await this.checkSyncStatus();
1379 | }
1380 | }
1381 |
1382 | /**
1383 | * Render tags cloud from API data
1384 | */
1385 | renderTagsCloud() {
1386 | const container = document.getElementById('tagsCloudContainer');
1387 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1388 |
1389 | // Hide the tagged memories view initially
1390 | taggedContainer.style.display = 'none';
1391 |
1392 | if (!this.tags || this.tags.length === 0) {
1393 | container.innerHTML = '<p class="text-neutral-600">No tags found. Start adding tags to your memories to see them here.</p>';
1394 | return;
1395 | }
1396 |
1397 | // Render tag bubbles (tags are already sorted by count from backend)
1398 | container.innerHTML = this.tags.map(tagData => `
1399 | <button class="tag-bubble" data-tag="${this.escapeHtml(tagData.tag)}">
1400 | ${this.escapeHtml(tagData.tag)}
1401 | <span class="count">${tagData.count}</span>
1402 | </button>
1403 | `).join('');
1404 | }
1405 |
1406 | /**
1407 | * Filter memories by selected tag
1408 | */
1409 | async filterByTag(tag) {
1410 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1411 | const tagNameSpan = document.getElementById('selectedTagName');
1412 | const memoriesList = document.getElementById('taggedMemoriesList');
1413 |
1414 | try {
1415 | // Fetch memories for this specific tag
1416 | const memoriesResponse = await this.apiCall(`/memories?tag=${encodeURIComponent(tag)}&limit=100`);
1417 | const filteredMemories = memoriesResponse.memories || [];
1418 |
1419 | // Show the tagged memories section
1420 | tagNameSpan.textContent = tag;
1421 | taggedContainer.style.display = 'block';
1422 |
1423 | // Smooth scroll to results section for better UX
1424 | taggedContainer.scrollIntoView({
1425 | behavior: 'smooth',
1426 | block: 'start'
1427 | });
1428 |
1429 | // Render filtered memories
1430 | this.renderMemoriesInContainer(filteredMemories, memoriesList);
1431 |
1432 | // Add event listener for clear filter button
1433 | const clearBtn = document.getElementById('clearTagFilter');
1434 | clearBtn.onclick = () => this.clearTagFilter();
1435 | } catch (error) {
1436 | console.error('Error filtering by tag:', error);
1437 | this.showToast('Failed to load memories for tag', 'error');
1438 | }
1439 | }
1440 |
1441 | /**
1442 | * Clear tag filter and show all tags
1443 | */
1444 | clearTagFilter() {
1445 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1446 | taggedContainer.style.display = 'none';
1447 | }
1448 |
1449 | /**
1450 | * Escape HTML to prevent XSS
1451 | */
1452 | escapeHtml(text) {
1453 | const div = document.createElement('div');
1454 | div.textContent = text;
1455 | return div.innerHTML;
1456 | }
1457 |
1458 | /**
1459 | * Toggle chunking help section visibility
1460 | */
1461 | toggleChunkingHelp() {
1462 | const helpSection = document.getElementById('chunkingHelpSection');
1463 | if (helpSection) {
1464 | if (helpSection.style.display === 'none') {
1465 | helpSection.style.display = 'block';
1466 | } else {
1467 | helpSection.style.display = 'none';
1468 | }
1469 | }
1470 | }
1471 |
1472 | /**
1473 | * Hide chunking help section
1474 | */
1475 | hideChunkingHelp() {
1476 | const helpSection = document.getElementById('chunkingHelpSection');
1477 | if (helpSection) {
1478 | helpSection.style.display = 'none';
1479 | }
1480 | }
1481 |
1482 | /**
1483 | * Toggle overlap help section visibility
1484 | */
1485 | toggleOverlapHelp() {
1486 | const helpSection = document.getElementById('overlapHelpSection');
1487 | if (helpSection) {
1488 | if (helpSection.style.display === 'none') {
1489 | helpSection.style.display = 'block';
1490 | } else {
1491 | helpSection.style.display = 'none';
1492 | }
1493 | }
1494 | }
1495 |
1496 | /**
1497 | * Hide overlap help section
1498 | */
1499 | hideOverlapHelp() {
1500 | const helpSection = document.getElementById('overlapHelpSection');
1501 | if (helpSection) {
1502 | helpSection.style.display = 'none';
1503 | }
1504 | }
1505 |
1506 | /**
1507 | * Toggle processing mode help section visibility
1508 | */
1509 | toggleProcessingModeHelp() {
1510 | const helpSection = document.getElementById('processingModeHelpSection');
1511 | if (helpSection) {
1512 | helpSection.style.display = helpSection.style.display === 'block' ? 'none' : 'block';
1513 | }
1514 | }
1515 |
1516 | /**
1517 | * Hide processing mode help section
1518 | */
1519 | hideProcessingModeHelp() {
1520 | const helpSection = document.getElementById('processingModeHelpSection');
1521 | if (helpSection) {
1522 | helpSection.style.display = 'none';
1523 | }
1524 | }
1525 |
1526 | /**
1527 | * Render memories in a specific container
1528 | */
1529 | renderMemoriesInContainer(memories, container) {
1530 | if (!memories || memories.length === 0) {
1531 | container.innerHTML = '<p class="empty-state">No memories found with this tag.</p>';
1532 | return;
1533 | }
1534 |
1535 | container.innerHTML = memories.map(memory => this.renderMemoryCard(memory)).join('');
1536 |
1537 | // Add click handlers
1538 | container.querySelectorAll('.memory-card').forEach((card, index) => {
1539 | card.addEventListener('click', () => this.handleMemoryClick(memories[index]));
1540 | });
1541 | }
1542 |
1543 | /**
1544 | * Handle navigation between views
1545 | */
1546 | handleNavigation(e) {
1547 | const viewName = e.currentTarget.dataset.view;
1548 | this.switchView(viewName);
1549 | }
1550 |
1551 | /**
1552 | * Switch between different views
1553 | */
1554 | switchView(viewName) {
1555 | // Update navigation active state (if navigation exists)
1556 | document.querySelectorAll('.nav-item').forEach(item => {
1557 | item.classList.remove('active');
1558 | });
1559 | const navItem = document.querySelector(`[data-view="${viewName}"]`);
1560 | if (navItem) {
1561 | navItem.classList.add('active');
1562 | }
1563 |
1564 | // Hide all views (if view containers exist)
1565 | document.querySelectorAll('.view-container').forEach(view => {
1566 | view.classList.remove('active');
1567 | });
1568 |
1569 | // Show target view (if it exists)
1570 | const targetView = document.getElementById(`${viewName}View`);
1571 | if (targetView) {
1572 | targetView.classList.add('active');
1573 | this.currentView = viewName;
1574 |
1575 | // Load view-specific data
1576 | this.loadViewData(viewName);
1577 | }
1578 | }
1579 |
1580 | /**
1581 | * Load data specific to the current view
1582 | */
1583 | async loadViewData(viewName) {
1584 | switch (viewName) {
1585 | case 'search':
1586 | // Initialize search view with recent search or empty state
1587 | break;
1588 | case 'browse':
1589 | await this.loadBrowseData();
1590 | break;
1591 | case 'documents':
1592 | await this.loadDocumentsData();
1593 | break;
1594 | case 'manage':
1595 | await this.loadManageData();
1596 | break;
1597 | case 'analytics':
1598 | await this.loadAnalyticsData();
1599 | break;
1600 | case 'apiDocs':
1601 | // API docs view - static content, no additional loading needed
1602 | break;
1603 | default:
1604 | // Dashboard view is loaded in loadDashboardData
1605 | break;
1606 | }
1607 | }
1608 |
1609 | /**
1610 | * Handle quick search input
1611 | */
1612 | async handleQuickSearch(e) {
1613 | const query = e.target.value.trim();
1614 | if (query.length >= 2) {
1615 | try {
1616 | const results = await this.searchMemories(query);
1617 | // Could show dropdown suggestions here
1618 | } catch (error) {
1619 | console.error('Quick search error:', error);
1620 | }
1621 | }
1622 | }
1623 |
1624 | /**
1625 | * Handle full search
1626 | */
1627 | async handleSearch(query) {
1628 | if (!query.trim()) return;
1629 |
1630 | this.switchView('search');
1631 | this.setLoading(true);
1632 |
1633 | try {
1634 | const results = await this.searchMemories(query);
1635 | this.searchResults = results;
1636 | this.renderSearchResults(results);
1637 | this.updateResultsCount(results.length);
1638 | } catch (error) {
1639 | console.error('Search error:', error);
1640 | this.showToast('Search failed', 'error');
1641 | } finally {
1642 | this.setLoading(false);
1643 | }
1644 | }
1645 |
1646 | /**
1647 | * Search memories using the API
1648 | */
1649 | async searchMemories(query, filters = {}) {
1650 | // Detect tag search patterns: #tag, tag:value, or "tag:value"
1651 | const tagPattern = /^(#|tag:)(.+)$/i;
1652 | const tagMatch = query.match(tagPattern);
1653 |
1654 | if (tagMatch) {
1655 | // Use tag search endpoint
1656 | const tagValue = tagMatch[2].trim();
1657 | const payload = {
1658 | tags: [tagValue],
1659 | match_all: false // ANY match by default
1660 | };
1661 |
1662 | const response = await this.apiCall('/search/by-tag', 'POST', payload);
1663 | return response.results || [];
1664 | } else {
1665 | // Use semantic search endpoint
1666 | const payload = {
1667 | query: query,
1668 | n_results: filters.limit || 20,
1669 | ...filters
1670 | };
1671 |
1672 | // Only add similarity_threshold if explicitly set in filters
1673 | if (filters.threshold !== undefined) {
1674 | payload.similarity_threshold = filters.threshold;
1675 | }
1676 |
1677 | const response = await this.apiCall('/search', 'POST', payload);
1678 | return response.results || [];
1679 | }
1680 | }
1681 |
1682 | /**
1683 | * Handle filter changes in search view
1684 | */
1685 | async handleFilterChange() {
1686 | const tagFilter = document.getElementById('tagFilter')?.value;
1687 | const dateFilter = document.getElementById('dateFilter')?.value;
1688 | const typeFilter = document.getElementById('typeFilter')?.value;
1689 | const query = document.getElementById('quickSearch')?.value?.trim() || '';
1690 |
1691 | // Add loading state
1692 | const applyBtn = document.getElementById('applyFiltersBtn');
1693 | if (applyBtn) {
1694 | applyBtn.classList.add('loading');
1695 | applyBtn.disabled = true;
1696 | }
1697 |
1698 | try {
1699 | let results = [];
1700 |
1701 | // Priority 1: If we have a semantic query, start with semantic search
1702 | if (query) {
1703 | const filters = {};
1704 | if (typeFilter) filters.type = typeFilter;
1705 | results = await this.searchMemories(query, filters);
1706 |
1707 | // Apply tag filtering to semantic search results if tags are specified
1708 | if (tagFilter && tagFilter.trim()) {
1709 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
1710 | if (tags.length > 0) {
1711 | results = results.filter(result => {
1712 | const memoryTags = result.memory.tags || [];
1713 | // Check if any of the specified tags match memory tags (case-insensitive)
1714 | return tags.some(filterTag =>
1715 | memoryTags.some(memoryTag =>
1716 | memoryTag.toLowerCase().includes(filterTag.toLowerCase())
1717 | )
1718 | );
1719 | });
1720 | }
1721 | }
1722 | }
1723 | // Priority 2: Tag-only search (when no semantic query)
1724 | else if (tagFilter && tagFilter.trim()) {
1725 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
1726 |
1727 | if (tags.length > 0) {
1728 | const payload = {
1729 | tags: tags,
1730 | match_all: false // ANY match by default
1731 | };
1732 |
1733 | const response = await this.apiCall('/search/by-tag', 'POST', payload);
1734 | results = response.results || [];
1735 |
1736 | // Apply type filter if present
1737 | if (typeFilter && typeFilter.trim()) {
1738 | results = results.filter(result => {
1739 | const memoryType = result.memory.memory_type || 'note';
1740 | return memoryType === typeFilter;
1741 | });
1742 | }
1743 | }
1744 | }
1745 | // Priority 3: Date-based search
1746 | else if (dateFilter && dateFilter.trim()) {
1747 | const payload = {
1748 | query: dateFilter,
1749 | n_results: 100
1750 | };
1751 | const response = await this.apiCall('/search/by-time', 'POST', payload);
1752 | results = response.results || [];
1753 |
1754 | // Apply type filter if present
1755 | if (typeFilter && typeFilter.trim()) {
1756 | results = results.filter(result => {
1757 | const memoryType = result.memory.memory_type || 'note';
1758 | return memoryType === typeFilter;
1759 | });
1760 | }
1761 | }
1762 | // Priority 4: Type-only filter
1763 | else if (typeFilter && typeFilter.trim()) {
1764 | const allMemoriesResponse = await this.apiCall('/memories?page=1&page_size=1000');
1765 | if (allMemoriesResponse.memories) {
1766 | results = allMemoriesResponse.memories
1767 | .filter(memory => (memory.memory_type || 'note') === typeFilter)
1768 | .map(memory => ({ memory, similarity: 1.0 }));
1769 | }
1770 | } else {
1771 | // No filters, clear results
1772 | results = [];
1773 | }
1774 |
1775 | this.searchResults = results;
1776 | this.renderSearchResults(results);
1777 | this.updateResultsCount(results.length);
1778 | this.updateActiveFilters();
1779 |
1780 | } catch (error) {
1781 | console.error('Filter search error:', error);
1782 | this.showToast('Filter search failed', 'error');
1783 | } finally {
1784 | // Remove loading state
1785 | const applyBtn = document.getElementById('applyFiltersBtn');
1786 | if (applyBtn) {
1787 | applyBtn.classList.remove('loading');
1788 | applyBtn.disabled = false;
1789 | }
1790 | }
1791 | }
1792 |
1793 | /**
1794 | * Handle view mode changes (grid/list)
1795 | */
1796 | handleViewModeChange(mode) {
1797 | document.querySelectorAll('.view-btn').forEach(btn => {
1798 | btn.classList.remove('active');
1799 | });
1800 | document.querySelector(`[data-view="${mode}"]`).classList.add('active');
1801 |
1802 | const resultsContainer = document.getElementById('searchResultsList');
1803 | resultsContainer.className = mode === 'grid' ? 'memory-grid' : 'memory-list';
1804 | }
1805 |
1806 | /**
1807 | * Handle quick actions
1808 | */
1809 | handleQuickAction(action) {
1810 | switch (action) {
1811 | case 'quick-search':
1812 | this.switchView('search');
1813 | const searchInput = document.getElementById('quickSearch');
1814 | if (searchInput) {
1815 | searchInput.focus();
1816 | }
1817 | break;
1818 | case 'add-memory':
1819 | this.handleAddMemory();
1820 | break;
1821 | case 'browse-tags':
1822 | this.switchView('browse');
1823 | break;
1824 | case 'export-data':
1825 | this.handleExportData();
1826 | break;
1827 | }
1828 | }
1829 |
1830 | /**
1831 | * Handle add memory action
1832 | */
1833 | handleAddMemory() {
1834 | const modal = document.getElementById('addMemoryModal');
1835 |
1836 | // Reset modal for adding new memory
1837 | this.resetAddMemoryModal();
1838 |
1839 | this.openModal(modal);
1840 | document.getElementById('memoryContent').focus();
1841 | }
1842 |
1843 | /**
1844 | * Reset add memory modal to default state
1845 | */
1846 | resetAddMemoryModal() {
1847 | const modal = document.getElementById('addMemoryModal');
1848 | const title = modal.querySelector('.modal-header h3');
1849 | const saveBtn = document.getElementById('saveMemoryBtn');
1850 |
1851 | // Reset modal title and button text
1852 | title.textContent = 'Add New Memory';
1853 | saveBtn.textContent = 'Save Memory';
1854 |
1855 | // Clear form
1856 | document.getElementById('addMemoryForm').reset();
1857 |
1858 | // Clear editing state
1859 | this.editingMemory = null;
1860 | }
1861 |
1862 | /**
1863 | * Handle save memory
1864 | */
1865 | async handleSaveMemory() {
1866 | const content = document.getElementById('memoryContent').value.trim();
1867 | const tags = document.getElementById('memoryTags').value.trim();
1868 | const type = document.getElementById('memoryType').value;
1869 |
1870 | if (!content) {
1871 | this.showToast('Please enter memory content', 'warning');
1872 | return;
1873 | }
1874 |
1875 | const payload = {
1876 | content: content,
1877 | tags: tags ? tags.split(',').map(t => t.trim()) : [],
1878 | memory_type: type,
1879 | metadata: {
1880 | created_via: 'dashboard',
1881 | user_agent: navigator.userAgent,
1882 | updated_via: this.editingMemory ? 'dashboard_edit' : 'dashboard_create'
1883 | }
1884 | };
1885 |
1886 |
1887 | try {
1888 | let response;
1889 | let successMessage;
1890 |
1891 | if (this.editingMemory) {
1892 | // Smart update: check if only metadata changed vs content changes
1893 | const originalContentHash = this.editingMemory.content_hash;
1894 | const contentChanged = this.editingMemory.content !== payload.content;
1895 |
1896 |
1897 | if (!contentChanged) {
1898 | // Only metadata (tags, type, metadata) changed - use PUT endpoint
1899 | const updatePayload = {
1900 | tags: payload.tags,
1901 | memory_type: payload.memory_type,
1902 | metadata: payload.metadata
1903 | };
1904 |
1905 | response = await this.apiCall(`/memories/${originalContentHash}`, 'PUT', updatePayload);
1906 | successMessage = 'Memory updated successfully';
1907 | } else {
1908 | // Content changed - use create-delete approach (but with proper error handling)
1909 |
1910 | try {
1911 | // Step 1: Create updated memory first
1912 | response = await this.apiCall('/memories', 'POST', payload);
1913 |
1914 | // CRITICAL: Only proceed with deletion if creation actually succeeded
1915 | if (response.success) {
1916 | successMessage = 'Memory updated successfully';
1917 |
1918 | try {
1919 | // Step 2: Delete original memory (only after successful creation)
1920 | const deleteResponse = await this.apiCall(`/memories/${originalContentHash}`, 'DELETE');
1921 | } catch (deleteError) {
1922 | console.error('Failed to delete original memory after creating new version:', deleteError);
1923 | this.showToast('Memory updated, but original version still exists. You may need to manually delete the duplicate.', 'warning');
1924 | }
1925 | } else {
1926 | // Creation failed - do NOT delete original memory
1927 | console.error('Creation failed:', response.message);
1928 | throw new Error(`Failed to create updated memory: ${response.message}`);
1929 | }
1930 | } catch (createError) {
1931 | // CREATE failed - original memory intact, no cleanup needed
1932 | console.error('Failed to create updated memory:', createError);
1933 | throw new Error(`Failed to update memory: ${createError.message}`);
1934 | }
1935 | }
1936 | } else {
1937 | // Create new memory
1938 | response = await this.apiCall('/memories', 'POST', payload);
1939 | successMessage = 'Memory saved successfully';
1940 | }
1941 |
1942 | this.closeModal(document.getElementById('addMemoryModal'));
1943 | this.showToast(successMessage, 'success');
1944 |
1945 | // Reset editing state
1946 | this.editingMemory = null;
1947 | this.resetAddMemoryModal();
1948 |
1949 | // Refresh current view if needed
1950 | if (this.currentView === 'dashboard') {
1951 | this.loadDashboardData();
1952 | } else if (this.currentView === 'search') {
1953 | // Refresh search results
1954 | const query = document.getElementById('searchInput').value.trim();
1955 | if (query) {
1956 | this.handleSearch(query);
1957 | }
1958 | } else if (this.currentView === 'browse') {
1959 | // Refresh browse view (tags cloud)
1960 | this.loadBrowseData();
1961 | }
1962 | } catch (error) {
1963 | console.error('Error saving memory:', error);
1964 | this.showToast(error.message || 'Failed to save memory', 'error');
1965 | }
1966 | }
1967 |
1968 | /**
1969 | * Handle memory click to show details
1970 | */
1971 | handleMemoryClick(memory) {
1972 | this.showMemoryDetails(memory);
1973 | }
1974 |
1975 | /**
1976 | * Show memory details in modal
1977 | */
1978 | showMemoryDetails(memory) {
1979 | const modal = document.getElementById('memoryModal');
1980 | const title = document.getElementById('modalTitle');
1981 | const content = document.getElementById('modalContent');
1982 |
1983 | title.textContent = 'Memory Details';
1984 | content.innerHTML = this.renderMemoryDetails(memory);
1985 |
1986 | // Set up action buttons
1987 | document.getElementById('editMemoryBtn').onclick = () => this.editMemory(memory);
1988 | document.getElementById('deleteMemoryBtn').onclick = () => this.deleteMemory(memory);
1989 | document.getElementById('shareMemoryBtn').onclick = () => this.shareMemory(memory);
1990 |
1991 | this.openModal(modal);
1992 | }
1993 |
1994 | /**
1995 | * Render memory details HTML
1996 | */
1997 | renderMemoryDetails(memory) {
1998 | const createdDate = new Date(memory.created_at * 1000).toLocaleString();
1999 | const updatedDate = memory.updated_at ? new Date(memory.updated_at * 1000).toLocaleString() : null;
2000 |
2001 | return `
2002 | <div class="memory-detail">
2003 | <div class="memory-meta">
2004 | <p><strong>Created:</strong> ${createdDate}</p>
2005 | ${updatedDate ? `<p><strong>Updated:</strong> ${updatedDate}</p>` : ''}
2006 | <p><strong>Type:</strong> ${memory.memory_type || 'note'}</p>
2007 | <p><strong>ID:</strong> ${memory.content_hash}</p>
2008 | </div>
2009 |
2010 | <div class="memory-content">
2011 | <h4>Content</h4>
2012 | <div class="content-text">${this.escapeHtml(memory.content)}</div>
2013 | </div>
2014 |
2015 | ${memory.tags && memory.tags.length > 0 ? `
2016 | <div class="memory-tags-section">
2017 | <h4>Tags</h4>
2018 | <div class="memory-tags">
2019 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2020 | </div>
2021 | </div>
2022 | ` : ''}
2023 |
2024 | ${memory.metadata ? `
2025 | <div class="memory-metadata">
2026 | <h4 class="metadata-toggle" onclick="this.parentElement.classList.toggle('expanded')" style="cursor: pointer; user-select: none;">
2027 | <span class="toggle-icon">▶</span> Metadata
2028 | </h4>
2029 | <div class="metadata-content">
2030 | ${this.renderMetadata(memory.metadata)}
2031 | </div>
2032 | </div>
2033 | ` : ''}
2034 | </div>
2035 | `;
2036 | }
2037 |
2038 | /**
2039 | * Render metadata in a prettier format
2040 | */
2041 | renderMetadata(metadata) {
2042 | if (!metadata || typeof metadata !== 'object') {
2043 | return '<p class="metadata-empty">No metadata available</p>';
2044 | }
2045 |
2046 | let html = '<div class="metadata-items">';
2047 |
2048 | for (const [key, value] of Object.entries(metadata)) {
2049 | let displayValue;
2050 |
2051 | if (typeof value === 'string') {
2052 | displayValue = `<span class="metadata-string">"${this.escapeHtml(value)}"</span>`;
2053 | } else if (typeof value === 'number') {
2054 | displayValue = `<span class="metadata-number">${value}</span>`;
2055 | } else if (typeof value === 'boolean') {
2056 | displayValue = `<span class="metadata-boolean">${value}</span>`;
2057 | } else if (Array.isArray(value)) {
2058 | displayValue = `<span class="metadata-array">[${value.map(v =>
2059 | typeof v === 'string' ? `"${this.escapeHtml(v)}"` : v
2060 | ).join(', ')}]</span>`;
2061 | } else {
2062 | displayValue = `<span class="metadata-object">${JSON.stringify(value)}</span>`;
2063 | }
2064 |
2065 | html += `
2066 | <div class="metadata-item">
2067 | <span class="metadata-key">${this.escapeHtml(key)}:</span>
2068 | <span class="metadata-value">${displayValue}</span>
2069 | </div>
2070 | `;
2071 | }
2072 |
2073 | html += '</div>';
2074 | return html;
2075 | }
2076 |
2077 | /**
2078 | * Delete memory
2079 | */
2080 | async deleteMemory(memory) {
2081 | if (!confirm('Are you sure you want to delete this memory? This action cannot be undone.')) {
2082 | return;
2083 | }
2084 |
2085 | try {
2086 | await this.apiCall(`/memories/${memory.content_hash}`, 'DELETE');
2087 | this.closeModal(document.getElementById('memoryModal'));
2088 | this.showToast('Memory deleted successfully', 'success');
2089 |
2090 | // Refresh current view
2091 | if (this.currentView === 'dashboard') {
2092 | this.loadDashboardData();
2093 | } else if (this.currentView === 'search') {
2094 | this.searchResults = this.searchResults.filter(m => m.memory.content_hash !== memory.content_hash);
2095 | this.renderSearchResults(this.searchResults);
2096 | } else if (this.currentView === 'browse') {
2097 | // Refresh browse view (tags cloud)
2098 | this.loadBrowseData();
2099 | }
2100 | } catch (error) {
2101 | console.error('Error deleting memory:', error);
2102 | this.showToast('Failed to delete memory', 'error');
2103 | }
2104 | }
2105 |
2106 | /**
2107 | * Edit memory
2108 | */
2109 | editMemory(memory) {
2110 | // Close the memory details modal first
2111 | this.closeModal(document.getElementById('memoryModal'));
2112 |
2113 | // Open the add memory modal with pre-filled data
2114 | const modal = document.getElementById('addMemoryModal');
2115 | const title = modal.querySelector('.modal-header h3');
2116 | const saveBtn = document.getElementById('saveMemoryBtn');
2117 |
2118 | // Update modal for editing
2119 | title.textContent = 'Edit Memory';
2120 | saveBtn.textContent = 'Update Memory';
2121 |
2122 | // Pre-fill the form with existing data
2123 | document.getElementById('memoryContent').value = memory.content || '';
2124 |
2125 | // Handle tags - ensure they're displayed correctly
2126 | const tagsValue = memory.tags && Array.isArray(memory.tags) ? memory.tags.join(', ') : '';
2127 | document.getElementById('memoryTags').value = tagsValue;
2128 |
2129 | document.getElementById('memoryType').value = memory.memory_type || 'note';
2130 |
2131 |
2132 | // Store the memory being edited
2133 | this.editingMemory = memory;
2134 |
2135 | this.openModal(modal);
2136 |
2137 | // Use setTimeout to ensure modal is fully rendered before setting values
2138 | setTimeout(() => {
2139 | document.getElementById('memoryContent').focus();
2140 | }, 100);
2141 | }
2142 |
2143 | /**
2144 | * Share memory
2145 | */
2146 | shareMemory(memory) {
2147 | // Create shareable data
2148 | const shareData = {
2149 | content: memory.content,
2150 | tags: memory.tags || [],
2151 | type: memory.memory_type || 'note',
2152 | created: new Date(memory.created_at * 1000).toISOString(),
2153 | id: memory.content_hash
2154 | };
2155 |
2156 | // Try to use Web Share API if available
2157 | if (navigator.share) {
2158 | navigator.share({
2159 | title: 'Memory from MCP Memory Service',
2160 | text: memory.content,
2161 | url: window.location.href
2162 | }).catch(err => {
2163 | // Share API failed, fall back to clipboard
2164 | this.fallbackShare(shareData);
2165 | });
2166 | } else {
2167 | this.fallbackShare(shareData);
2168 | }
2169 | }
2170 |
2171 | /**
2172 | * Fallback share method (copy to clipboard)
2173 | */
2174 | fallbackShare(shareData) {
2175 | const shareText = `Memory Content:\n${shareData.content}\n\nTags: ${shareData.tags.join(', ')}\nType: ${shareData.type}\nCreated: ${shareData.created}`;
2176 |
2177 | navigator.clipboard.writeText(shareText).then(() => {
2178 | this.showToast('Memory copied to clipboard', 'success');
2179 | }).catch(err => {
2180 | console.error('Could not copy text: ', err);
2181 | this.showToast('Failed to copy to clipboard', 'error');
2182 | });
2183 | }
2184 |
2185 | /**
2186 | * Handle data export
2187 | */
2188 | async handleExportData() {
2189 | try {
2190 | this.showToast('Preparing export...', 'info');
2191 |
2192 | // Fetch all memories using pagination
2193 | const allMemories = [];
2194 | const pageSize = 100; // Reasonable batch size
2195 | let page = 1;
2196 | let hasMore = true;
2197 | let totalMemories = 0;
2198 |
2199 | while (hasMore) {
2200 | const response = await this.apiCall(`/memories?page=${page}&page_size=${pageSize}`);
2201 |
2202 | if (page === 1) {
2203 | totalMemories = response.total;
2204 | }
2205 |
2206 | if (response.memories && response.memories.length > 0) {
2207 | allMemories.push(...response.memories);
2208 | hasMore = response.has_more;
2209 | page++;
2210 |
2211 | // Update progress
2212 | this.showToast(`Fetching memories... (${allMemories.length}/${totalMemories})`, 'info');
2213 | } else {
2214 | hasMore = false;
2215 | }
2216 | }
2217 |
2218 | const data = {
2219 | export_date: new Date().toISOString(),
2220 | total_memories: totalMemories,
2221 | exported_memories: allMemories.length,
2222 | memories: allMemories
2223 | };
2224 |
2225 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
2226 | const url = URL.createObjectURL(blob);
2227 | const a = document.createElement('a');
2228 | a.href = url;
2229 | a.download = `mcp-memories-export-${new Date().toISOString().split('T')[0]}.json`;
2230 | document.body.appendChild(a);
2231 | a.click();
2232 | document.body.removeChild(a);
2233 | URL.revokeObjectURL(url);
2234 |
2235 | this.showToast(`Successfully exported ${allMemories.length} memories`, 'success');
2236 | } catch (error) {
2237 | console.error('Export error:', error);
2238 | this.showToast('Failed to export data', 'error');
2239 | }
2240 | }
2241 |
2242 | /**
2243 | * Render recent memories
2244 | */
2245 | renderRecentMemories(memories) {
2246 | const container = document.getElementById('recentMemoriesList');
2247 |
2248 | if (!container) {
2249 | console.error('recentMemoriesList container not found');
2250 | return;
2251 | }
2252 |
2253 | if (!memories || memories.length === 0) {
2254 | container.innerHTML = '<p class="empty-state">No memories found. <a href="#" onclick="app.handleAddMemory()">Add your first memory</a></p>';
2255 | return;
2256 | }
2257 |
2258 | // Group document chunks by upload_id
2259 | const groupedMemories = this.groupMemoriesByUpload(memories);
2260 |
2261 | container.innerHTML = groupedMemories.map(group => {
2262 | if (group.type === 'document') {
2263 | return this.renderDocumentGroup(group);
2264 | } else {
2265 | return this.renderMemoryCard(group.memory);
2266 | }
2267 | }).join('');
2268 |
2269 | // Add click handlers for individual memories
2270 | container.querySelectorAll('.memory-card').forEach((card, index) => {
2271 | const group = groupedMemories[index];
2272 | if (group.type === 'single') {
2273 | card.addEventListener('click', () => this.handleMemoryClick(group.memory));
2274 | }
2275 | });
2276 |
2277 | // Add click handlers for document groups
2278 | container.querySelectorAll('.document-group').forEach((groupEl, index) => {
2279 | const group = groupedMemories.filter(g => g.type === 'document')[index];
2280 | if (group) {
2281 | groupEl.addEventListener('click', (e) => {
2282 | // Don't trigger if clicking on action buttons
2283 | if (e.target.closest('.document-actions')) return;
2284 | this.showDocumentChunks(group);
2285 | });
2286 | }
2287 | });
2288 |
2289 | // Add click handlers for document action buttons
2290 | container.querySelectorAll('.document-group').forEach((groupEl, index) => {
2291 | const group = groupedMemories.filter(g => g.type === 'document')[index];
2292 | if (group) {
2293 | // View chunks button
2294 | const viewBtn = groupEl.querySelector('.btn-view-chunks');
2295 | if (viewBtn) {
2296 | viewBtn.addEventListener('click', (e) => {
2297 | e.stopPropagation();
2298 | this.showDocumentChunks(group);
2299 | });
2300 | }
2301 |
2302 | // Remove button
2303 | const removeBtn = groupEl.querySelector('.btn-remove');
2304 | if (removeBtn) {
2305 | removeBtn.addEventListener('click', async (e) => {
2306 | e.stopPropagation();
2307 | await this.removeDocument(group.upload_id, group.source_file);
2308 | // removeDocument() already handles view refresh
2309 | });
2310 | }
2311 | }
2312 | });
2313 | }
2314 |
2315 | /**
2316 | * Group memories by upload_id for document chunks
2317 | */
2318 | groupMemoriesByUpload(memories) {
2319 | const groups = [];
2320 | const documentGroups = new Map();
2321 | const processedHashes = new Set();
2322 |
2323 | for (const memory of memories) {
2324 | // Check if this is a document chunk
2325 | const isDocumentChunk = memory.metadata && memory.metadata.upload_id;
2326 |
2327 | if (isDocumentChunk && !processedHashes.has(memory.content_hash)) {
2328 | const uploadId = memory.metadata.upload_id;
2329 | const sourceFile = memory.metadata.source_file || 'Unknown file';
2330 |
2331 | if (!documentGroups.has(uploadId)) {
2332 | documentGroups.set(uploadId, {
2333 | upload_id: uploadId,
2334 | source_file: sourceFile,
2335 | memories: [],
2336 | created_at: memory.created_at,
2337 | tags: new Set()
2338 | });
2339 | }
2340 |
2341 | const group = documentGroups.get(uploadId);
2342 | group.memories.push(memory);
2343 | group.tags.add(...(memory.tags || []));
2344 | processedHashes.add(memory.content_hash);
2345 | } else if (!processedHashes.has(memory.content_hash)) {
2346 | // Regular memory
2347 | groups.push({
2348 | type: 'single',
2349 | memory: memory
2350 | });
2351 | processedHashes.add(memory.content_hash);
2352 | }
2353 | }
2354 |
2355 | // Convert document groups to array format
2356 | for (const group of documentGroups.values()) {
2357 | groups.push({
2358 | type: 'document',
2359 | upload_id: group.upload_id,
2360 | source_file: group.source_file,
2361 | memories: group.memories,
2362 | created_at: group.created_at,
2363 | tags: Array.from(group.tags)
2364 | });
2365 | }
2366 |
2367 | // Sort by creation time (most recent first)
2368 | groups.sort((a, b) => {
2369 | const timeA = a.type === 'document' ? a.created_at : a.memory.created_at;
2370 | const timeB = b.type === 'document' ? b.created_at : b.memory.created_at;
2371 | return timeB - timeA;
2372 | });
2373 |
2374 | return groups;
2375 | }
2376 |
2377 | /**
2378 | * Render a document group card
2379 | */
2380 | renderDocumentGroup(group) {
2381 | const createdDate = new Date(group.created_at * 1000).toLocaleDateString();
2382 | const fileName = this.escapeHtml(group.source_file);
2383 | const chunkCount = group.memories.length;
2384 | // Filter out metadata tags AND tags that are too long (likely corrupted/malformed)
2385 | const uniqueTags = [...new Set(group.tags.filter(tag =>
2386 | !tag.startsWith('upload_id:') &&
2387 | !tag.startsWith('source_file:') &&
2388 | !tag.startsWith('file_type:') &&
2389 | tag.length < 100 // Reject tags longer than 100 chars (likely corrupted metadata)
2390 | ))];
2391 |
2392 | return `
2393 | <div class="document-group" data-upload-id="${this.escapeHtml(group.upload_id)}">
2394 | <div class="document-header">
2395 | <div class="document-icon">📄</div>
2396 | <div class="document-info">
2397 | <div class="document-title">${fileName}</div>
2398 | <div class="document-meta">
2399 | ${chunkCount} chunks • ${createdDate}
2400 | </div>
2401 | </div>
2402 | </div>
2403 | <div class="document-preview">
2404 | ${group.memories[0] ? this.escapeHtml(group.memories[0].content.substring(0, 150)) + (group.memories[0].content.length > 150 ? '...' : '') : 'No content preview available'}
2405 | </div>
2406 | ${uniqueTags.length > 0 ? `
2407 | <div class="document-tags">
2408 | ${uniqueTags.slice(0, 3).map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2409 | ${uniqueTags.length > 3 ? `<span class="tag more">+${uniqueTags.length - 3} more</span>` : ''}
2410 | </div>
2411 | ` : ''}
2412 | <div class="document-actions">
2413 | <button class="btn-icon btn-view-chunks" title="View all chunks">
2414 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
2415 | <path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
2416 | </svg>
2417 | <span>View Chunks</span>
2418 | </button>
2419 | <button class="btn-icon btn-remove" title="Remove document">
2420 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
2421 | <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
2422 | </svg>
2423 | <span>Remove</span>
2424 | </button>
2425 | </div>
2426 | </div>
2427 | `;
2428 | }
2429 |
2430 | /**
2431 | * Show document chunks in a modal
2432 | */
2433 | showDocumentChunks(group) {
2434 | const modal = document.createElement('div');
2435 | modal.className = 'modal-overlay';
2436 | modal.innerHTML = `
2437 | <div class="modal-content large-modal">
2438 | <div class="modal-header">
2439 | <h3>📄 ${this.escapeHtml(group.source_file)}</h3>
2440 | <button class="modal-close">×</button>
2441 | </div>
2442 | <div class="modal-body">
2443 | <div class="document-chunks">
2444 | ${group.memories.map((memory, index) => `
2445 | <div class="chunk-item">
2446 | <div class="chunk-header">
2447 | <span class="chunk-number">Chunk ${index + 1}</span>
2448 | <div class="chunk-meta">
2449 | ${memory.metadata && memory.metadata.page ? `Page ${memory.metadata.page} • ` : ''}
2450 | ${memory.content.length} chars
2451 | </div>
2452 | </div>
2453 | <div class="chunk-content">
2454 | ${this.escapeHtml(memory.content)}
2455 | </div>
2456 | ${memory.tags && memory.tags.length > 0 ? `
2457 | <div class="chunk-tags">
2458 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2459 | </div>
2460 | ` : ''}
2461 | </div>
2462 | `).join('')}
2463 | </div>
2464 | </div>
2465 | </div>
2466 | `;
2467 |
2468 | document.body.appendChild(modal);
2469 |
2470 | // Add active class to show modal (required for display: flex)
2471 | setTimeout(() => modal.classList.add('active'), 10);
2472 |
2473 | // Add close handlers
2474 | const closeModal = () => {
2475 | modal.classList.remove('active');
2476 | setTimeout(() => document.body.removeChild(modal), 300);
2477 | };
2478 | modal.querySelector('.modal-close').addEventListener('click', closeModal);
2479 | modal.addEventListener('click', (e) => {
2480 | if (e.target === modal) closeModal();
2481 | });
2482 | }
2483 |
2484 | /**
2485 | * Render tags in sidebar
2486 | */
2487 | renderTagsSidebar(tags) {
2488 | const container = document.getElementById('tagsCloudContainer');
2489 |
2490 | if (!container) {
2491 | console.warn('tagsCloudContainer element not found - skipping tags sidebar rendering');
2492 | return;
2493 | }
2494 |
2495 | if (!tags || tags.length === 0) {
2496 | container.innerHTML = '<div class="no-tags">No tags found.</div>';
2497 | return;
2498 | }
2499 |
2500 | // Take top tags for sidebar display
2501 | const topTags = tags.slice(0, 10);
2502 | container.innerHTML = topTags.map(tagData => `
2503 | <div class="tag-item" data-tag="${this.escapeHtml(tagData.tag)}">
2504 | <span class="tag-name">${this.escapeHtml(tagData.tag)}</span>
2505 | <span class="tag-count">${tagData.count}</span>
2506 | </div>
2507 | `).join('');
2508 |
2509 | // Add click handlers
2510 | container.querySelectorAll('.tag-item').forEach(item => {
2511 | item.addEventListener('click', () => {
2512 | const tagName = item.dataset.tag;
2513 | const searchInput = document.getElementById('searchInput');
2514 | searchInput.value = `#${tagName}`;
2515 | this.handleSearch(`#${tagName}`);
2516 | });
2517 | });
2518 | }
2519 |
2520 | /**
2521 | * Render search results
2522 | */
2523 | renderSearchResults(results) {
2524 | const container = document.getElementById('searchResultsList');
2525 |
2526 | if (!results || results.length === 0) {
2527 | container.innerHTML = '<p class="empty-state">No results found. Try a different search term.</p>';
2528 | return;
2529 | }
2530 |
2531 | container.innerHTML = results.map(result => this.renderMemoryCard(result.memory, result)).join('');
2532 |
2533 | // Add click handlers
2534 | container.querySelectorAll('.memory-card').forEach((card, index) => {
2535 | card.addEventListener('click', () => this.handleMemoryClick(results[index].memory));
2536 | });
2537 | }
2538 |
2539 | /**
2540 | * Render a memory card
2541 | */
2542 | renderMemoryCard(memory, searchResult = null) {
2543 | const createdDate = new Date(memory.created_at * 1000).toLocaleDateString();
2544 | const relevanceScore = searchResult &&
2545 | searchResult.similarity_score !== null &&
2546 | searchResult.similarity_score !== undefined &&
2547 | !isNaN(searchResult.similarity_score) &&
2548 | searchResult.similarity_score > 0
2549 | ? (searchResult.similarity_score * 100).toFixed(1)
2550 | : null;
2551 |
2552 | // Truncate content to 150 characters for preview
2553 | const truncatedContent = memory.content.length > 150
2554 | ? memory.content.substring(0, 150) + '...'
2555 | : memory.content;
2556 |
2557 | return `
2558 | <div class="memory-card" data-memory-id="${memory.content_hash}">
2559 | <div class="memory-header">
2560 | <div class="memory-meta">
2561 | <span>${createdDate}</span>
2562 | ${memory.memory_type ? `<span> • ${memory.memory_type}</span>` : ''}
2563 | ${relevanceScore ? `<span> • ${relevanceScore}% match</span>` : ''}
2564 | </div>
2565 | </div>
2566 |
2567 | <div class="memory-content">
2568 | ${this.escapeHtml(truncatedContent)}
2569 | </div>
2570 |
2571 | ${memory.tags && memory.tags.length > 0 ? `
2572 | <div class="memory-tags">
2573 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2574 | </div>
2575 | ` : ''}
2576 | </div>
2577 | `;
2578 | }
2579 |
2580 | /**
2581 | * Update dashboard statistics
2582 | */
2583 | updateDashboardStats(stats) {
2584 | const totalMemoriesEl = document.getElementById('totalMemories');
2585 | if (totalMemoriesEl) {
2586 | totalMemoriesEl.textContent = stats.total_memories || '0';
2587 | }
2588 |
2589 | const recentMemoriesEl = document.getElementById('recentMemories');
2590 | if (recentMemoriesEl) {
2591 | recentMemoriesEl.textContent = stats.memories_this_week || '0';
2592 | }
2593 |
2594 | const uniqueTagsEl = document.getElementById('uniqueTags');
2595 | if (uniqueTagsEl) {
2596 | uniqueTagsEl.textContent = stats.unique_tags || '0';
2597 | }
2598 |
2599 | const storageBackendEl = document.getElementById('storageBackend');
2600 | if (storageBackendEl) {
2601 | storageBackendEl.textContent = stats.backend || 'unknown';
2602 | }
2603 | }
2604 |
2605 | /**
2606 | * Update search results count
2607 | */
2608 | updateResultsCount(count) {
2609 | const element = document.getElementById('resultsCount');
2610 | if (element) {
2611 | element.textContent = `${count} result${count !== 1 ? 's' : ''}`;
2612 | }
2613 | }
2614 |
2615 | /**
2616 | * Update active filters display
2617 | */
2618 | updateActiveFilters() {
2619 | const activeFiltersContainer = document.getElementById('activeFilters');
2620 | const filtersList = document.getElementById('activeFiltersList');
2621 |
2622 | if (!activeFiltersContainer || !filtersList) return;
2623 |
2624 | const tagFilter = document.getElementById('tagFilter')?.value?.trim();
2625 | const dateFilter = document.getElementById('dateFilter')?.value;
2626 | const typeFilter = document.getElementById('typeFilter')?.value;
2627 |
2628 | const filters = [];
2629 |
2630 | if (tagFilter) {
2631 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
2632 | tags.forEach(tag => {
2633 | filters.push({
2634 | type: 'tag',
2635 | value: tag,
2636 | label: `Tag: ${tag}`
2637 | });
2638 | });
2639 | }
2640 |
2641 | if (dateFilter) {
2642 | const dateLabels = {
2643 | 'today': 'Today',
2644 | 'week': 'This week',
2645 | 'month': 'This month',
2646 | 'year': 'This year'
2647 | };
2648 | filters.push({
2649 | type: 'date',
2650 | value: dateFilter,
2651 | label: `Date: ${dateLabels[dateFilter] || dateFilter}`
2652 | });
2653 | }
2654 |
2655 | if (typeFilter) {
2656 | const typeLabels = {
2657 | 'note': 'Notes',
2658 | 'code': 'Code',
2659 | 'reference': 'References',
2660 | 'idea': 'Ideas'
2661 | };
2662 | filters.push({
2663 | type: 'type',
2664 | value: typeFilter,
2665 | label: `Type: ${typeLabels[typeFilter] || typeFilter}`
2666 | });
2667 | }
2668 |
2669 | if (filters.length === 0) {
2670 | activeFiltersContainer.style.display = 'none';
2671 | return;
2672 | }
2673 |
2674 | activeFiltersContainer.style.display = 'block';
2675 | filtersList.innerHTML = filters.map(filter => `
2676 | <div class="filter-pill">
2677 | ${this.escapeHtml(filter.label)}
2678 | <button class="remove-filter" data-filter-type="${this.escapeHtml(filter.type)}" data-filter-value="${this.escapeHtml(filter.value)}">
2679 | ×
2680 | </button>
2681 | </div>
2682 | `).join('');
2683 |
2684 | // Add event listeners for filter removal
2685 | filtersList.addEventListener('click', (e) => {
2686 | const button = e.target.closest('.remove-filter');
2687 | if (!button) return;
2688 |
2689 | const type = button.dataset.filterType;
2690 | const value = button.dataset.filterValue;
2691 | this.removeFilter(type, value);
2692 | });
2693 | }
2694 |
2695 | /**
2696 | * Remove a specific filter
2697 | */
2698 | removeFilter(type, value) {
2699 | switch (type) {
2700 | case 'tag':
2701 | const tagInput = document.getElementById('tagFilter');
2702 | if (tagInput) {
2703 | const tags = tagInput.value.split(',').map(t => t.trim()).filter(t => t && t !== value);
2704 | tagInput.value = tags.join(', ');
2705 | }
2706 | break;
2707 | case 'date':
2708 | const dateSelect = document.getElementById('dateFilter');
2709 | if (dateSelect) {
2710 | dateSelect.value = '';
2711 | }
2712 | break;
2713 | case 'type':
2714 | const typeSelect = document.getElementById('typeFilter');
2715 | if (typeSelect) {
2716 | typeSelect.value = '';
2717 | }
2718 | break;
2719 | }
2720 | this.handleFilterChange();
2721 | }
2722 |
2723 | /**
2724 | * Clear all filters
2725 | */
2726 | clearAllFilters() {
2727 | const tagFilter = document.getElementById('tagFilter');
2728 | const dateFilter = document.getElementById('dateFilter');
2729 | const typeFilter = document.getElementById('typeFilter');
2730 |
2731 | if (tagFilter) tagFilter.value = '';
2732 | if (dateFilter) dateFilter.value = '';
2733 | if (typeFilter) typeFilter.value = '';
2734 |
2735 | this.searchResults = [];
2736 | this.renderSearchResults([]);
2737 | this.updateResultsCount(0);
2738 | this.updateActiveFilters();
2739 |
2740 | this.showToast('All filters cleared', 'info');
2741 | }
2742 |
2743 | /**
2744 | * Handle live search toggle
2745 | */
2746 | handleLiveSearchToggle(event) {
2747 | this.liveSearchEnabled = event.target.checked;
2748 | const modeText = document.getElementById('searchModeText');
2749 | if (modeText) {
2750 | modeText.textContent = this.liveSearchEnabled ? 'Live Search' : 'Manual Search';
2751 | }
2752 |
2753 | // Show a toast to indicate the mode change
2754 | this.showToast(
2755 | `Search mode: ${this.liveSearchEnabled ? 'Live (searches as you type)' : 'Manual (click Search button)'}`,
2756 | 'info'
2757 | );
2758 | }
2759 |
2760 | /**
2761 | * Handle debounced filter changes for live search
2762 | */
2763 | handleDebouncedFilterChange() {
2764 | // Clear any existing timer
2765 | if (this.debounceTimer) {
2766 | clearTimeout(this.debounceTimer);
2767 | }
2768 |
2769 | // Only trigger search if live search is enabled
2770 | if (this.liveSearchEnabled) {
2771 | this.debounceTimer = setTimeout(() => {
2772 | this.handleFilterChange();
2773 | }, 300); // 300ms debounce
2774 | }
2775 | }
2776 |
2777 | /**
2778 | * Handle memory added via SSE
2779 | */
2780 | handleMemoryAdded(memory) {
2781 | if (this.currentView === 'dashboard') {
2782 | this.loadDashboardData();
2783 | }
2784 | }
2785 |
2786 | /**
2787 | * Handle memory deleted via SSE
2788 | */
2789 | handleMemoryDeleted(memoryId) {
2790 | // Remove from current view
2791 | const cards = document.querySelectorAll(`[data-memory-id="${memoryId}"]`);
2792 | cards.forEach(card => card.remove());
2793 |
2794 | // Update search results if in search view
2795 | if (this.currentView === 'search') {
2796 | this.searchResults = this.searchResults.filter(r => r.memory.content_hash !== memoryId);
2797 | this.updateResultsCount(this.searchResults.length);
2798 | }
2799 | }
2800 |
2801 | /**
2802 | * Handle memory updated via SSE
2803 | */
2804 | handleMemoryUpdated(memory) {
2805 | // Refresh relevant views
2806 | if (this.currentView === 'dashboard') {
2807 | this.loadDashboardData();
2808 | }
2809 | }
2810 |
2811 | /**
2812 | * Update connection status indicator
2813 | */
2814 | updateConnectionStatus(status) {
2815 | const statusElement = document.getElementById('connectionStatus');
2816 | if (statusElement) {
2817 | const indicator = statusElement.querySelector('.status-indicator');
2818 | const text = statusElement.querySelector('.status-text');
2819 | if (!indicator || !text) return;
2820 |
2821 | // Reset indicator classes
2822 | indicator.className = 'status-indicator';
2823 |
2824 | switch (status) {
2825 | case 'connected':
2826 | text.textContent = 'Connected';
2827 | // Connected uses default green color (no additional class needed)
2828 | break;
2829 | case 'connecting':
2830 | text.textContent = 'Connecting...';
2831 | indicator.classList.add('connecting');
2832 | break;
2833 | case 'disconnected':
2834 | text.textContent = 'Disconnected';
2835 | indicator.classList.add('disconnected');
2836 | break;
2837 | default:
2838 | text.textContent = 'Unknown';
2839 | indicator.classList.add('disconnected');
2840 | }
2841 | }
2842 | }
2843 |
2844 | /**
2845 | * Generic API call wrapper
2846 | */
2847 | async apiCall(endpoint, method = 'GET', data = null) {
2848 | const options = {
2849 | method: method,
2850 | headers: {
2851 | 'Content-Type': 'application/json',
2852 | }
2853 | };
2854 |
2855 | if (data) {
2856 | options.body = JSON.stringify(data);
2857 | }
2858 |
2859 | const response = await fetch(`${this.apiBase}${endpoint}`, options);
2860 |
2861 | if (!response.ok) {
2862 | const errorData = await response.json().catch(() => ({}));
2863 | throw new Error(errorData.detail || `HTTP ${response.status}`);
2864 | }
2865 |
2866 | return await response.json();
2867 | }
2868 |
2869 | /**
2870 | * Modal management
2871 | */
2872 | openModal(modal) {
2873 | modal.classList.add('active');
2874 | document.body.style.overflow = 'hidden';
2875 |
2876 | // Focus first input
2877 | const firstInput = modal.querySelector('input, textarea');
2878 | if (firstInput) {
2879 | firstInput.focus();
2880 | }
2881 | }
2882 |
2883 | closeModal(modal) {
2884 | modal.classList.remove('active');
2885 | document.body.style.overflow = '';
2886 | }
2887 |
2888 | /**
2889 | * Loading state management
2890 | */
2891 | setLoading(loading) {
2892 | this.isLoading = loading;
2893 | const indicator = document.getElementById('loadingOverlay');
2894 | if (indicator) {
2895 | if (loading) {
2896 | indicator.classList.remove('hidden');
2897 | } else {
2898 | indicator.classList.add('hidden');
2899 | }
2900 | }
2901 | }
2902 |
2903 | /**
2904 | * View document memory chunks
2905 | */
2906 | async viewDocumentMemory(uploadId) {
2907 | try {
2908 | this.setLoading(true);
2909 | const response = await this.apiCall(`/documents/search-content/${uploadId}`);
2910 |
2911 | if (response.status === 'success' || response.status === 'partial') {
2912 | // Show modal
2913 | const modal = document.getElementById('memoryViewerModal');
2914 | const filename = document.getElementById('memoryViewerFilename');
2915 | const stats = document.getElementById('memoryViewerStats');
2916 | const chunksList = document.getElementById('memoryChunksList');
2917 |
2918 | filename.textContent = response.filename || 'Document';
2919 | stats.textContent = `${response.total_found} chunk${response.total_found !== 1 ? 's' : ''} found`;
2920 |
2921 | // Render chunks
2922 | if (response.memories && response.memories.length > 0) {
2923 | const chunksHtml = response.memories.map((memory, index) => {
2924 | const chunkIndex = memory.chunk_index !== undefined ? memory.chunk_index : index;
2925 | const page = memory.page ? ` • Page ${memory.page}` : '';
2926 | const contentPreview = memory.content.length > 300
2927 | ? memory.content.substring(0, 300) + '...'
2928 | : memory.content;
2929 |
2930 | return `
2931 | <div class="memory-chunk-item">
2932 | <div class="chunk-header">
2933 | <span class="chunk-number">Chunk ${chunkIndex + 1}${page}</span>
2934 | <span class="chunk-hash" title="${memory.content_hash}">${memory.content_hash.substring(0, 12)}...</span>
2935 | </div>
2936 | <div class="chunk-content">${this.escapeHtml(contentPreview)}</div>
2937 | <div class="chunk-tags">
2938 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2939 | </div>
2940 | </div>
2941 | `;
2942 | }).join('');
2943 |
2944 | chunksList.innerHTML = chunksHtml;
2945 | } else {
2946 | chunksList.innerHTML = '<p class="text-muted">No memory chunks found for this document.</p>';
2947 | }
2948 |
2949 | modal.style.display = 'flex';
2950 |
2951 | if (response.status === 'partial') {
2952 | this.showToast(`Found ${response.total_found} chunks (partial results)`, 'warning');
2953 | }
2954 | } else {
2955 | this.showToast('Failed to load document memories', 'error');
2956 | }
2957 | } catch (error) {
2958 | console.error('Error viewing document memory:', error);
2959 | this.showToast('Error loading document memories', 'error');
2960 | } finally {
2961 | this.setLoading(false);
2962 | }
2963 | }
2964 |
2965 | /**
2966 | * Close memory viewer modal
2967 | */
2968 | closeMemoryViewer() {
2969 | const modal = document.getElementById('memoryViewerModal');
2970 | if (modal) {
2971 | modal.style.display = 'none';
2972 | }
2973 | }
2974 |
2975 | /**
2976 | * Remove document and its memories
2977 | */
2978 | async removeDocument(uploadId, filename) {
2979 | console.log('removeDocument called with:', { uploadId, filename, currentView: this.currentView });
2980 |
2981 | if (!confirm(`Remove "${filename}" and all its memory chunks?\n\nThis action cannot be undone.`)) {
2982 | console.log('User cancelled removal');
2983 | return;
2984 | }
2985 |
2986 | try {
2987 | this.setLoading(true);
2988 | console.log('Making DELETE request to:', `/documents/remove/${uploadId}`);
2989 |
2990 | const response = await this.apiCall(`/documents/remove/${uploadId}`, 'DELETE');
2991 |
2992 | console.log('Delete response:', response);
2993 |
2994 | if (response.status === 'success') {
2995 | this.showToast(`Removed "${filename}" (${response.memories_deleted} memories deleted)`, 'success');
2996 | // Refresh the current view (Dashboard or Documents tab)
2997 | console.log('Refreshing view:', this.currentView);
2998 | if (this.currentView === 'dashboard') {
2999 | await this.loadDashboardData();
3000 | } else if (this.currentView === 'documents') {
3001 | await this.loadUploadHistory();
3002 | }
3003 | } else {
3004 | console.error('Removal failed with response:', response);
3005 | this.showToast('Failed to remove document', 'error');
3006 | }
3007 | } catch (error) {
3008 | console.error('Error removing document:', error);
3009 | console.error('Error stack:', error.stack);
3010 | this.showToast('Error removing document', 'error');
3011 | } finally {
3012 | this.setLoading(false);
3013 | }
3014 | }
3015 |
3016 | /**
3017 | * Search document content
3018 | */
3019 | async searchDocumentContent(query) {
3020 | try {
3021 | this.setLoading(true);
3022 | const resultsContainer = document.getElementById('docSearchResults');
3023 | const resultsList = document.getElementById('docSearchResultsList');
3024 | const resultsCount = document.getElementById('docSearchCount');
3025 |
3026 | // Use the regular search endpoint but filter for document memories
3027 | // Higher n_results to ensure we get enough document results after filtering
3028 | const response = await this.apiCall('/search', 'POST', {
3029 | query: query,
3030 | n_results: 100
3031 | });
3032 |
3033 | if (response.results) {
3034 | // Filter results to only show document-type memories
3035 | const documentResults = response.results.filter(r =>
3036 | r.memory?.memory_type === 'document' || (r.memory?.tags && r.memory.tags.some(tag => tag.startsWith('upload_id:')))
3037 | );
3038 |
3039 | // Limit display to top 20 most relevant document results
3040 | const displayResults = documentResults.slice(0, 20);
3041 |
3042 | resultsCount.textContent = `${documentResults.length} result${documentResults.length !== 1 ? 's' : ''}${documentResults.length > 20 ? ' (showing top 20)' : ''}`;
3043 |
3044 | if (displayResults.length > 0) {
3045 | const resultsHtml = displayResults.map(result => {
3046 | const mem = result.memory;
3047 | const uploadIdTag = mem.tags?.find(tag => tag.startsWith('upload_id:'));
3048 | const sourceFile = mem.metadata?.source_file || 'Unknown file';
3049 | const contentPreview = mem.content.length > 200
3050 | ? mem.content.substring(0, 200) + '...'
3051 | : mem.content;
3052 |
3053 | return `
3054 | <div class="search-result-item">
3055 | <div class="result-header">
3056 | <strong>${this.escapeHtml(sourceFile)}</strong>
3057 | <span class="similarity-score">${Math.round((result.similarity_score || 0) * 100)}% match</span>
3058 | </div>
3059 | <div class="result-content">${this.escapeHtml(contentPreview)}</div>
3060 | <div class="result-tags">
3061 | ${(mem.tags || []).slice(0, 5).map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
3062 | </div>
3063 | </div>
3064 | `;
3065 | }).join('');
3066 |
3067 | resultsList.innerHTML = resultsHtml;
3068 | } else {
3069 | resultsList.innerHTML = '<p class="text-muted">No matching document content found. Try different search terms.</p>';
3070 | }
3071 |
3072 | resultsContainer.style.display = 'block';
3073 | } else {
3074 | this.showToast('Search failed', 'error');
3075 | }
3076 | } catch (error) {
3077 | console.error('Error searching documents:', error);
3078 | this.showToast('Error performing search', 'error');
3079 | } finally {
3080 | this.setLoading(false);
3081 | }
3082 | }
3083 |
3084 | /**
3085 | * Toast notification system
3086 | */
3087 | showToast(message, type = 'info', duration = 5000) {
3088 | const container = document.getElementById('toastContainer');
3089 | const toast = document.createElement('div');
3090 | toast.className = `toast ${type}`;
3091 | toast.textContent = message;
3092 |
3093 | container.appendChild(toast);
3094 |
3095 | // Auto-remove after duration
3096 | setTimeout(() => {
3097 | toast.remove();
3098 | }, duration);
3099 |
3100 | // Click to remove
3101 | toast.addEventListener('click', () => {
3102 | toast.remove();
3103 | });
3104 | }
3105 |
3106 | /**
3107 | * Utility: Debounce function
3108 | */
3109 | debounce(func, wait) {
3110 | let timeout;
3111 | return function executedFunction(...args) {
3112 | const later = () => {
3113 | clearTimeout(timeout);
3114 | func(...args);
3115 | };
3116 | clearTimeout(timeout);
3117 | timeout = setTimeout(later, wait);
3118 | };
3119 | }
3120 |
3121 | /**
3122 | * Load settings from localStorage
3123 | */
3124 | loadSettings() {
3125 | try {
3126 | const saved = localStorage.getItem('memoryDashboardSettings');
3127 | if (saved) {
3128 | this.settings = { ...this.settings, ...JSON.parse(saved) };
3129 | }
3130 | } catch (error) {
3131 | console.warn('Failed to load settings:', error);
3132 | }
3133 | }
3134 |
3135 | /**
3136 | * Save settings to localStorage
3137 | */
3138 | saveSettingsToStorage() {
3139 | try {
3140 | localStorage.setItem('memoryDashboardSettings', JSON.stringify(this.settings));
3141 | } catch (error) {
3142 | console.error('Failed to save settings:', error);
3143 | this.showToast('Failed to save settings. Your preferences will not be persisted.', 'error');
3144 | }
3145 | }
3146 |
3147 | /**
3148 | * Apply theme to the page
3149 | */
3150 | applyTheme(theme = this.settings.theme) {
3151 | const isDark = theme === 'dark';
3152 | document.body.classList.toggle('dark-mode', isDark);
3153 |
3154 | // Toggle icon visibility using CSS classes
3155 | const sunIcon = document.getElementById('sunIcon');
3156 | const moonIcon = document.getElementById('moonIcon');
3157 | if (sunIcon && moonIcon) {
3158 | sunIcon.classList.toggle('hidden', isDark);
3159 | moonIcon.classList.toggle('hidden', !isDark);
3160 | }
3161 | }
3162 |
3163 | /**
3164 | * Toggle between light and dark theme
3165 | */
3166 | toggleTheme() {
3167 | const newTheme = this.settings.theme === 'dark' ? 'light' : 'dark';
3168 | this.settings.theme = newTheme;
3169 | this.applyTheme(newTheme);
3170 | this.saveSettingsToStorage();
3171 | this.showToast(`Switched to ${newTheme} mode`, 'success');
3172 | }
3173 |
3174 | /**
3175 | * Open settings modal
3176 | */
3177 | async openSettingsModal() {
3178 | const modal = document.getElementById('settingsModal');
3179 |
3180 | // Populate form with current settings
3181 | document.getElementById('themeSelect').value = this.settings.theme;
3182 | document.getElementById('viewDensity').value = this.settings.viewDensity;
3183 | document.getElementById('previewLines').value = this.settings.previewLines;
3184 |
3185 | // Reset system info to loading state
3186 | this.resetSystemInfoLoadingState();
3187 |
3188 | // Load system information and backup status
3189 | await Promise.all([
3190 | this.loadSystemInfo(),
3191 | this.checkBackupStatus()
3192 | ]);
3193 |
3194 | this.openModal(modal);
3195 | }
3196 |
3197 | /**
3198 | * Reset system info fields to loading state
3199 | */
3200 | resetSystemInfoLoadingState() {
3201 | Object.keys(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(id => {
3202 | const element = document.getElementById(id);
3203 | if (element) {
3204 | element.textContent = 'Loading...';
3205 | }
3206 | });
3207 | }
3208 |
3209 | /**
3210 | * Load system information for settings modal
3211 | */
3212 | async loadSystemInfo() {
3213 | try {
3214 | // Use Promise.allSettled for robust error handling
3215 | const [healthResult, detailedHealthResult] = await Promise.allSettled([
3216 | this.apiCall('/health'),
3217 | this.apiCall('/health/detailed')
3218 | ]);
3219 |
3220 | const apiData = {
3221 | health: healthResult.status === 'fulfilled' ? healthResult.value : null,
3222 | detailedHealth: detailedHealthResult.status === 'fulfilled' ? detailedHealthResult.value : null
3223 | };
3224 |
3225 | // Update fields using configuration
3226 | Object.entries(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(([fieldId, config]) => {
3227 | const element = document.getElementById(fieldId);
3228 | if (!element) return;
3229 |
3230 | let value = null;
3231 | for (const source of config.sources) {
3232 | const apiResponse = apiData[source.api];
3233 | if (apiResponse) {
3234 | value = this.getNestedValue(apiResponse, source.path);
3235 | if (value !== undefined && value !== null) break;
3236 | }
3237 | }
3238 |
3239 | element.textContent = config.formatter(value);
3240 | });
3241 |
3242 | // Log warnings for failed API calls
3243 | if (healthResult.status === 'rejected') {
3244 | console.warn('Failed to load health endpoint:', healthResult.reason);
3245 | }
3246 | if (detailedHealthResult.status === 'rejected') {
3247 | console.warn('Failed to load detailed health endpoint:', detailedHealthResult.reason);
3248 | }
3249 | } catch (error) {
3250 | console.error('Unexpected error loading system info:', error);
3251 | // Set all system info fields that are still in loading state to error
3252 | Object.keys(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(id => {
3253 | const element = document.getElementById(id);
3254 | if (element && element.textContent === 'Loading...') {
3255 | element.textContent = 'Error';
3256 | }
3257 | });
3258 | }
3259 | }
3260 |
3261 | /**
3262 | * Get nested object value by path string
3263 | * @param {Object} obj - Object to traverse
3264 | * @param {string} path - Dot-separated path (e.g., 'storage.primary_stats.embedding_model')
3265 | * @returns {*} Value at path or undefined
3266 | */
3267 | getNestedValue(obj, path) {
3268 | return path.split('.').reduce((current, key) => current?.[key], obj);
3269 | }
3270 |
3271 | /**
3272 | * Format uptime seconds into human readable string
3273 | * @param {number} seconds - Uptime in seconds
3274 | * @returns {string} Formatted uptime string
3275 | */
3276 | static formatUptime(seconds) {
3277 | const days = Math.floor(seconds / 86400);
3278 | const hours = Math.floor((seconds % 86400) / 3600);
3279 | const minutes = Math.floor((seconds % 3600) / 60);
3280 |
3281 | const parts = [];
3282 | if (days > 0) parts.push(`${days}d`);
3283 | if (hours > 0) parts.push(`${hours}h`);
3284 | if (minutes > 0) parts.push(`${minutes}m`);
3285 |
3286 | return parts.length > 0 ? parts.join(' ') : '< 1m';
3287 | }
3288 |
3289 | /**
3290 | * Save settings from modal
3291 | */
3292 | saveSettings() {
3293 | // Get values from form
3294 | const theme = document.getElementById('themeSelect').value;
3295 | const viewDensity = document.getElementById('viewDensity').value;
3296 | const previewLines = parseInt(document.getElementById('previewLines').value, 10);
3297 |
3298 | // Update settings
3299 | this.settings.theme = theme;
3300 | this.settings.viewDensity = viewDensity;
3301 | this.settings.previewLines = previewLines;
3302 |
3303 | // Apply changes
3304 | this.applyTheme(theme);
3305 | this.saveSettingsToStorage();
3306 |
3307 | // Close modal and show confirmation
3308 | this.closeModal(document.getElementById('settingsModal'));
3309 | this.showToast('Settings saved successfully', 'success');
3310 | }
3311 |
3312 | // ===== MANAGE TAB METHODS =====
3313 |
3314 | /**
3315 | * Load manage tab data
3316 | */
3317 | async loadManageData() {
3318 | try {
3319 | // Load tag statistics for bulk operations
3320 | await this.loadTagSelectOptions();
3321 | await this.loadTagManagementStats();
3322 | } catch (error) {
3323 | console.error('Failed to load manage data:', error);
3324 | this.showToast('Failed to load management data', 'error');
3325 | }
3326 | }
3327 |
3328 | /**
3329 | * Load tag options for bulk delete select
3330 | */
3331 | async loadTagSelectOptions() {
3332 | try {
3333 | const response = await fetch(`${this.apiBase}/manage/tags/stats`);
3334 | if (!response.ok) throw new Error('Failed to load tags');
3335 |
3336 | const data = await response.json();
3337 | const select = document.getElementById('deleteTagSelect');
3338 | if (!select) return;
3339 |
3340 | // Clear existing options except the first
3341 | while (select.children.length > 1) {
3342 | select.removeChild(select.lastChild);
3343 | }
3344 |
3345 | // Add tag options
3346 | data.tags.forEach(tagStat => {
3347 | const option = document.createElement('option');
3348 | option.value = tagStat.tag;
3349 | option.textContent = `${tagStat.tag} (${tagStat.count} memories)`;
3350 | option.dataset.count = tagStat.count; // Store count in data attribute
3351 | select.appendChild(option);
3352 | });
3353 | } catch (error) {
3354 | console.error('Failed to load tag options:', error);
3355 | }
3356 | }
3357 |
3358 | /**
3359 | * Load tag management statistics
3360 | */
3361 | async loadTagManagementStats() {
3362 | const container = document.getElementById('tagManagementContainer');
3363 | if (!container) return;
3364 |
3365 | try {
3366 | const response = await fetch(`${this.apiBase}/manage/tags/stats`);
3367 | if (!response.ok) throw new Error('Failed to load tag stats');
3368 |
3369 | const data = await response.json();
3370 | this.renderTagManagementTable(data);
3371 | } catch (error) {
3372 | console.error('Failed to load tag management stats:', error);
3373 | container.innerHTML = '<p class="error">Failed to load tag statistics</p>';
3374 | }
3375 | }
3376 |
3377 | /**
3378 | * Render tag management table
3379 | */
3380 | renderTagManagementTable(data) {
3381 | const container = document.getElementById('tagManagementContainer');
3382 | if (!container) return;
3383 |
3384 | let html = '<table class="tag-stats-table">';
3385 | html += '<thead><tr>';
3386 | html += '<th>Tag</th>';
3387 | html += '<th>Count</th>';
3388 | html += '<th>Actions</th>';
3389 | html += '</tr></thead><tbody>';
3390 |
3391 | data.tags.forEach(tagStat => {
3392 | html += '<tr>';
3393 | html += `<td class="tag-name">${tagStat.tag}</td>`;
3394 | html += `<td class="tag-count">${tagStat.count}</td>`;
3395 | html += '<td class="tag-actions">';
3396 | html += `<button class="tag-action-btn" data-action="rename-tag" data-tag="${this.escapeHtml(tagStat.tag)}">Rename</button>`;
3397 | html += `<button class="tag-action-btn danger" data-action="delete-tag" data-tag="${this.escapeHtml(tagStat.tag)}" data-count="${tagStat.count}">Delete</button>`;
3398 | html += '</td></tr>';
3399 | });
3400 |
3401 | html += '</tbody></table>';
3402 | container.innerHTML = html;
3403 |
3404 | // Add event listeners for tag actions
3405 | container.addEventListener('click', (e) => {
3406 | const button = e.target.closest('[data-action]');
3407 | if (!button) return;
3408 |
3409 | const action = button.dataset.action;
3410 | const tag = button.dataset.tag;
3411 |
3412 | if (action === 'rename-tag') {
3413 | this.renameTag(tag);
3414 | } else if (action === 'delete-tag') {
3415 | const count = parseInt(button.dataset.count, 10);
3416 | this.deleteTag(tag, count);
3417 | }
3418 | });
3419 | }
3420 |
3421 | /**
3422 | * Handle bulk delete by tag
3423 | */
3424 | async handleBulkDeleteByTag() {
3425 | const select = document.getElementById('deleteTagSelect');
3426 | const tag = select.value;
3427 |
3428 | if (!tag) {
3429 | this.showToast('Please select a tag to delete', 'warning');
3430 | return;
3431 | }
3432 |
3433 | // Extract count from data attribute
3434 | const option = select.querySelector(`option[value="${tag}"]`);
3435 | const count = parseInt(option.dataset.count, 10) || 0;
3436 |
3437 | if (!await this.confirmBulkOperation(`Delete ${count} memories with tag "${tag}"?`)) {
3438 | return;
3439 | }
3440 |
3441 | this.setLoading(true);
3442 | try {
3443 | const response = await fetch(`${this.apiBase}/manage/bulk-delete`, {
3444 | method: 'POST',
3445 | headers: { 'Content-Type': 'application/json' },
3446 | body: JSON.stringify({
3447 | tag: tag,
3448 | confirm_count: count
3449 | })
3450 | });
3451 |
3452 | const result = await response.json();
3453 | if (result.success) {
3454 | this.showToast(result.message, 'success');
3455 | await this.loadManageData(); // Refresh data
3456 | await this.loadDashboardData(); // Refresh dashboard stats
3457 | } else {
3458 | this.showToast(result.message, 'error');
3459 | }
3460 | } catch (error) {
3461 | console.error('Bulk delete failed:', error);
3462 | this.showToast('Bulk delete operation failed', 'error');
3463 | } finally {
3464 | this.setLoading(false);
3465 | }
3466 | }
3467 |
3468 | /**
3469 | * Handle cleanup duplicates
3470 | */
3471 | async handleCleanupDuplicates() {
3472 | if (!await this.confirmBulkOperation('Remove all duplicate memories?')) {
3473 | return;
3474 | }
3475 |
3476 | this.setLoading(true);
3477 | try {
3478 | const response = await fetch(`${this.apiBase}/manage/cleanup-duplicates`, {
3479 | method: 'POST'
3480 | });
3481 |
3482 | const result = await response.json();
3483 | if (result.success) {
3484 | this.showToast(result.message, 'success');
3485 | await this.loadManageData();
3486 | await this.loadDashboardData();
3487 | } else {
3488 | this.showToast(result.message, 'error');
3489 | }
3490 | } catch (error) {
3491 | console.error('Cleanup duplicates failed:', error);
3492 | this.showToast('Cleanup operation failed', 'error');
3493 | } finally {
3494 | this.setLoading(false);
3495 | }
3496 | }
3497 |
3498 | /**
3499 | * Handle bulk delete by date
3500 | */
3501 | async handleBulkDeleteByDate() {
3502 | const dateInput = document.getElementById('deleteDateInput');
3503 | const date = dateInput.value;
3504 |
3505 | if (!date) {
3506 | this.showToast('Please select a date', 'warning');
3507 | return;
3508 | }
3509 |
3510 | if (!await this.confirmBulkOperation(`Delete all memories before ${date}?`)) {
3511 | return;
3512 | }
3513 |
3514 | this.setLoading(true);
3515 | try {
3516 | const response = await fetch(`${this.apiBase}/manage/bulk-delete`, {
3517 | method: 'POST',
3518 | headers: { 'Content-Type': 'application/json' },
3519 | body: JSON.stringify({
3520 | before_date: date
3521 | })
3522 | });
3523 |
3524 | const result = await response.json();
3525 | if (result.success) {
3526 | this.showToast(result.message, 'success');
3527 | await this.loadManageData();
3528 | await this.loadDashboardData();
3529 | } else {
3530 | this.showToast(result.message, 'error');
3531 | }
3532 | } catch (error) {
3533 | console.error('Bulk delete by date failed:', error);
3534 | this.showToast('Bulk delete operation failed', 'error');
3535 | } finally {
3536 | this.setLoading(false);
3537 | }
3538 | }
3539 |
3540 | /**
3541 | * Handle database optimization
3542 | */
3543 | async handleOptimizeDatabase() {
3544 | this.showToast('Database optimization not yet implemented', 'warning');
3545 | }
3546 |
3547 | /**
3548 | * Handle index rebuild
3549 | */
3550 | async handleRebuildIndex() {
3551 | this.showToast('Index rebuild not yet implemented', 'warning');
3552 | }
3553 |
3554 | /**
3555 | * Rename a tag
3556 | */
3557 | async renameTag(oldTag) {
3558 | const newTag = prompt(`Rename tag "${oldTag}" to:`, oldTag);
3559 | if (!newTag || newTag === oldTag) return;
3560 |
3561 | this.showToast('Tag renaming not yet implemented', 'warning');
3562 | }
3563 |
3564 | /**
3565 | * Delete a tag
3566 | */
3567 | async deleteTag(tag, count) {
3568 | if (!await this.confirmBulkOperation(`Delete tag "${tag}" from ${count} memories?`)) {
3569 | return;
3570 | }
3571 |
3572 | this.showToast('Tag deletion not yet implemented', 'warning');
3573 | }
3574 |
3575 | // ===== ANALYTICS TAB METHODS =====
3576 |
3577 | /**
3578 | * Load analytics tab data
3579 | */
3580 | async loadAnalyticsData() {
3581 | try {
3582 | await Promise.all([
3583 | this.loadAnalyticsOverview(),
3584 | this.loadMemoryGrowthChart(),
3585 | this.loadTagUsageChart(),
3586 | this.loadMemoryTypesChart(),
3587 | this.loadActivityHeatmapChart(),
3588 | this.loadTopTagsReport(),
3589 | this.loadRecentActivityReport(),
3590 | this.loadStorageReport()
3591 | ]);
3592 | } catch (error) {
3593 | console.error('Failed to load analytics data:', error);
3594 | this.showToast('Failed to load analytics data', 'error');
3595 | }
3596 | }
3597 |
3598 | /**
3599 | * Load analytics overview metrics
3600 | */
3601 | async loadAnalyticsOverview() {
3602 | try {
3603 | const response = await fetch(`${this.apiBase}/analytics/overview`);
3604 | if (!response.ok) throw new Error('Failed to load overview');
3605 |
3606 | const data = await response.json();
3607 |
3608 | // Update metric cards
3609 | this.updateElementText('analyticsTotalMemories', data.total_memories || 0);
3610 | this.updateElementText('analyticsThisWeek', data.memories_this_week || 0);
3611 | this.updateElementText('analyticsUniqueTags', data.unique_tags || 0);
3612 | this.updateElementText('analyticsDbSize', data.database_size_mb ?
3613 | `${data.database_size_mb.toFixed(1)} MB` : 'N/A');
3614 | } catch (error) {
3615 | console.error('Failed to load analytics overview:', error);
3616 | }
3617 | }
3618 |
3619 | /**
3620 | * Load memory growth chart
3621 | */
3622 | async loadMemoryGrowthChart() {
3623 | const container = document.getElementById('memoryGrowthChart');
3624 | const period = document.getElementById('growthPeriodSelect').value;
3625 |
3626 | if (!container) return;
3627 |
3628 | try {
3629 | const response = await fetch(`${this.apiBase}/analytics/memory-growth?period=${period}`);
3630 | if (!response.ok) throw new Error('Failed to load growth data');
3631 |
3632 | const data = await response.json();
3633 | this.renderMemoryGrowthChart(container, data);
3634 | } catch (error) {
3635 | console.error('Failed to load memory growth:', error);
3636 | container.innerHTML = '<p class="error">Failed to load growth chart</p>';
3637 | }
3638 | }
3639 |
3640 | /**
3641 | * Render memory growth chart
3642 | */
3643 | renderMemoryGrowthChart(container, data) {
3644 | if (!data.data_points || data.data_points.length === 0) {
3645 | container.innerHTML = '<p>No growth data available</p>';
3646 | return;
3647 | }
3648 |
3649 | // Find max count for scaling
3650 | const recentPoints = data.data_points.slice(-10);
3651 | const maxCount = Math.max(...recentPoints.map(p => p.count), 1);
3652 |
3653 | let html = '<div class="simple-chart">';
3654 |
3655 | recentPoints.forEach(point => {
3656 | // Normalize bar width relative to max, then convert to pixels (200px scale)
3657 | const barWidthPx = (point.count / maxCount) * 200;
3658 | const displayCount = point.count || 0;
3659 | const displayCumulative = point.cumulative || 0;
3660 | // Use label if available, otherwise fall back to date for backward compatibility
3661 | const displayLabel = point.label || point.date;
3662 |
3663 | html += `<div class="chart-row">
3664 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
3665 | <span class="chart-value">+${displayCount} <small>(${displayCumulative} total)</small></span>
3666 | <span class="chart-label">${displayLabel}</span>
3667 | </div>`;
3668 | });
3669 |
3670 | html += '</div>';
3671 | container.innerHTML = html;
3672 | }
3673 |
3674 | /**
3675 | * Load tag usage chart
3676 | */
3677 | async loadTagUsageChart() {
3678 | const container = document.getElementById('tagUsageChart');
3679 | if (!container) return;
3680 |
3681 | try {
3682 | const response = await fetch(`${this.apiBase}/analytics/tag-usage`);
3683 | if (!response.ok) throw new Error('Failed to load tag usage');
3684 |
3685 | const data = await response.json();
3686 | this.renderTagUsageChart(container, data);
3687 | } catch (error) {
3688 | console.error('Failed to load tag usage:', error);
3689 | container.innerHTML = '<p class="error">Failed to load tag usage chart</p>';
3690 | }
3691 | }
3692 |
3693 | /**
3694 | * Render tag usage chart
3695 | */
3696 | renderTagUsageChart(container, data) {
3697 | if (!data.tags || data.tags.length === 0) {
3698 | container.innerHTML = '<p>No tags found</p>';
3699 | return;
3700 | }
3701 |
3702 | // Filter tags with >10 memories, aggregate the rest
3703 | const significantTags = data.tags.filter(t => t.count > 10);
3704 | const minorTags = data.tags.filter(t => t.count <= 10);
3705 |
3706 | let html = '<div class="simple-chart">';
3707 |
3708 | // Render significant tags
3709 | significantTags.forEach(tag => {
3710 | const barWidthPx = (tag.percentage / 100) * 200; // Convert percentage to pixels (200px scale)
3711 | html += `<div class="chart-row">
3712 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
3713 | <span class="chart-value">${tag.count} (${tag.percentage}%)</span>
3714 | <span class="chart-label">${tag.tag}</span>
3715 | </div>`;
3716 | });
3717 |
3718 | // Add "diverse" category if there are minor tags
3719 | if (minorTags.length > 0) {
3720 | const diverseCount = minorTags.reduce((sum, t) => sum + t.count, 0);
3721 | const diversePercentage = minorTags.reduce((sum, t) => sum + t.percentage, 0);
3722 | const barWidthPx = (diversePercentage / 100) * 200;
3723 | html += `<div class="chart-row">
3724 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
3725 | <span class="chart-value">${diverseCount} (${diversePercentage.toFixed(1)}%)</span>
3726 | <span class="chart-label" title="${minorTags.length} tags with ≤10 memories each">diverse (${minorTags.length} tags)</span>
3727 | </div>`;
3728 | }
3729 |
3730 | html += '</div>';
3731 | container.innerHTML = html;
3732 | }
3733 |
3734 | /**
3735 | * Load memory types chart
3736 | */
3737 | async loadMemoryTypesChart() {
3738 | const container = document.getElementById('memoryTypesChart');
3739 | if (!container) return;
3740 |
3741 | try {
3742 | const response = await fetch(`${this.apiBase}/analytics/memory-types`);
3743 | if (!response.ok) throw new Error('Failed to load memory types');
3744 |
3745 | const data = await response.json();
3746 | this.renderMemoryTypesChart(container, data);
3747 | } catch (error) {
3748 | console.error('Failed to load memory types:', error);
3749 | container.innerHTML = '<p class="error">Failed to load memory types chart</p>';
3750 | }
3751 | }
3752 |
3753 | /**
3754 | * Render memory types chart
3755 | */
3756 | renderMemoryTypesChart(container, data) {
3757 | if (!data.types || data.types.length === 0) {
3758 | container.innerHTML = '<p>No memory types found</p>';
3759 | return;
3760 | }
3761 |
3762 | // Filter types with >10 memories, aggregate the rest
3763 | const significantTypes = data.types.filter(t => t.count > 10);
3764 | const minorTypes = data.types.filter(t => t.count <= 10);
3765 |
3766 | let html = '<div class="simple-chart">';
3767 |
3768 | // Render significant types
3769 | significantTypes.forEach(type => {
3770 | const barWidthPx = (type.percentage / 100) * 200; // Convert percentage to pixels (200px scale)
3771 | const typeName = type.memory_type || 'untyped';
3772 | html += `<div class="chart-row">
3773 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
3774 | <span class="chart-value">${type.count} (${type.percentage.toFixed(1)}%)</span>
3775 | <span class="chart-label" title="${typeName}">${typeName}</span>
3776 | </div>`;
3777 | });
3778 |
3779 | // Add "diverse" category if there are minor types
3780 | if (minorTypes.length > 0) {
3781 | const diverseCount = minorTypes.reduce((sum, t) => sum + t.count, 0);
3782 | const diversePercentage = minorTypes.reduce((sum, t) => sum + t.percentage, 0);
3783 | const barWidthPx = (diversePercentage / 100) * 200;
3784 | html += `<div class="chart-row">
3785 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
3786 | <span class="chart-value">${diverseCount} (${diversePercentage.toFixed(1)}%)</span>
3787 | <span class="chart-label" title="${minorTypes.length} types with ≤10 memories each">diverse (${minorTypes.length} types)</span>
3788 | </div>`;
3789 | }
3790 |
3791 | html += '</div>';
3792 | container.innerHTML = html;
3793 | }
3794 |
3795 | /**
3796 | * Load top tags report
3797 | */
3798 | async loadTopTagsReport() {
3799 | const container = document.getElementById('topTagsList');
3800 | const period = document.getElementById('topTagsPeriodSelect')?.value || '30d';
3801 | if (!container) return;
3802 |
3803 | try {
3804 | const response = await fetch(`${this.apiBase}/analytics/top-tags?period=${period}`);
3805 | if (!response.ok) throw new Error('Failed to load top tags');
3806 |
3807 | const data = await response.json();
3808 | this.renderTopTagsReport(container, data);
3809 | } catch (error) {
3810 | console.error('Failed to load top tags:', error);
3811 | container.innerHTML = '<p class="error">Failed to load top tags</p>';
3812 | }
3813 | }
3814 |
3815 | /**
3816 | * Render top tags report
3817 | */
3818 | renderTopTagsReport(container, data) {
3819 | if (!data.tags || data.tags.length === 0) {
3820 | container.innerHTML = '<p>No tags found</p>';
3821 | return;
3822 | }
3823 |
3824 | let html = '<div class="enhanced-tags-report">';
3825 | html += `<div class="report-period">Period: ${data.period}</div>`;
3826 | html += '<ul class="tags-list">';
3827 | data.tags.slice(0, 10).forEach(tag => {
3828 | const trendIcon = tag.trending ? '📈' : '';
3829 | const growthText = tag.growth_rate !== null ? ` (${tag.growth_rate > 0 ? '+' : ''}${tag.growth_rate}%)` : '';
3830 | html += `<li>
3831 | <div class="tag-header">
3832 | <strong>${tag.tag}</strong>${trendIcon}
3833 | <span class="tag-count">${tag.count} memories (${tag.percentage}%)${growthText}</span>
3834 | </div>`;
3835 | if (tag.co_occurring_tags && tag.co_occurring_tags.length > 0) {
3836 | html += '<div class="tag-cooccurrence">Often with: ';
3837 | html += tag.co_occurring_tags.slice(0, 3).map(co => `${co.tag} (${co.strength.toFixed(2)})`).join(', ');
3838 | html += '</div>';
3839 | }
3840 | html += '</li>';
3841 | });
3842 | html += '</ul></div>';
3843 |
3844 | container.innerHTML = html;
3845 | }
3846 |
3847 | /**
3848 | * Load recent activity report
3849 | */
3850 | async loadRecentActivityReport() {
3851 | const container = document.getElementById('recentActivityList');
3852 | const granularity = document.getElementById('activityGranularitySelect')?.value || 'daily';
3853 | if (!container) return;
3854 |
3855 | try {
3856 | const response = await fetch(`${this.apiBase}/analytics/activity-breakdown?granularity=${granularity}`);
3857 | if (!response.ok) throw new Error('Failed to load activity breakdown');
3858 |
3859 | const data = await response.json();
3860 | this.renderRecentActivityReport(container, data);
3861 | } catch (error) {
3862 | console.error('Failed to load recent activity:', error);
3863 | container.innerHTML = '<p class="error">Failed to load recent activity</p>';
3864 | }
3865 | }
3866 |
3867 | /**
3868 | * Render recent activity report
3869 | */
3870 | renderRecentActivityReport(container, data) {
3871 | let html = '<div class="activity-breakdown">';
3872 |
3873 | // Summary stats
3874 | html += '<div class="activity-summary">';
3875 | html += `<div class="activity-stat"><strong>Active Days:</strong> ${data.active_days}/${data.total_days}</div>`;
3876 | html += `<div class="activity-stat"><strong>Current Streak:</strong> ${data.current_streak} days</div>`;
3877 | html += `<div class="activity-stat"><strong>Longest Streak:</strong> ${data.longest_streak} days</div>`;
3878 | html += '</div>';
3879 |
3880 | if (data.peak_times && data.peak_times.length > 0) {
3881 | html += '<div class="peak-times">';
3882 | html += '<strong>Peak Times:</strong> ';
3883 | html += data.peak_times.join(', ');
3884 | html += '</div>';
3885 | }
3886 |
3887 | // Activity breakdown chart
3888 | if (data.breakdown && data.breakdown.length > 0) {
3889 | html += '<div class="activity-chart">';
3890 | // Calculate total count for percentage-based distribution
3891 | const totalCount = data.breakdown.reduce((sum, d) => sum + d.count, 0);
3892 | const maxCount = Math.max(...data.breakdown.map(d => d.count));
3893 |
3894 | data.breakdown.forEach(item => {
3895 | // Show percentage of total activity, with minimum width for visibility
3896 | const percentage = totalCount > 0 ? (item.count / totalCount * 100) : 0;
3897 | // Use percentage for bar width, but scale up for better visualization
3898 | // Use max count for scaling to ensure largest bar reaches reasonable width
3899 | const barWidth = totalCount > 0 ? (item.count / maxCount * 100) : 0;
3900 |
3901 | html += `<div class="activity-bar-row">
3902 | <span class="activity-label">${item.label}</span>
3903 | <div class="activity-bar" style="width: ${barWidth}%" title="${item.count} memories (${percentage.toFixed(1)}%)"></div>
3904 | <span class="activity-count">${item.count} (${percentage.toFixed(1)}%)</span>
3905 | </div>`;
3906 | });
3907 |
3908 | html += '</div>';
3909 | }
3910 |
3911 | html += '</div>';
3912 | container.innerHTML = html;
3913 | }
3914 |
3915 | /**
3916 | * Load activity heatmap chart
3917 | */
3918 | async loadActivityHeatmapChart() {
3919 | const container = document.getElementById('activityHeatmapChart');
3920 | const period = document.getElementById('heatmapPeriodSelect').value;
3921 |
3922 | if (!container) return;
3923 |
3924 | try {
3925 | const response = await fetch(`${this.apiBase}/analytics/activity-heatmap?days=${period}`);
3926 | if (!response.ok) throw new Error('Failed to load heatmap data');
3927 |
3928 | const data = await response.json();
3929 | this.renderActivityHeatmapChart(container, data);
3930 | } catch (error) {
3931 | console.error('Failed to load activity heatmap:', error);
3932 | container.innerHTML = '<p class="error">Failed to load activity heatmap</p>';
3933 | }
3934 | }
3935 |
3936 | /**
3937 | * Render activity heatmap chart
3938 | */
3939 | renderActivityHeatmapChart(container, data) {
3940 | if (!data.data || data.data.length === 0) {
3941 | container.innerHTML = '<p>No activity data available</p>';
3942 | return;
3943 | }
3944 |
3945 | // Create calendar grid
3946 | let html = '<div class="activity-heatmap">';
3947 | html += '<div class="heatmap-stats">';
3948 | html += `<span>${data.total_days} active days</span>`;
3949 | html += `<span>Max: ${data.max_count} memories/day</span>`;
3950 | html += '</div>';
3951 |
3952 | // Group by months
3953 | const months = {};
3954 | data.data.forEach(day => {
3955 | const date = new Date(day.date);
3956 | const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
3957 | if (!months[monthKey]) {
3958 | months[monthKey] = [];
3959 | }
3960 | months[monthKey].push(day);
3961 | });
3962 |
3963 | // Render each month
3964 | Object.keys(months).sort().reverse().forEach(monthKey => {
3965 | const [year, month] = monthKey.split('-');
3966 | const monthName = new Date(year, month - 1).toLocaleString('default', { month: 'short' });
3967 |
3968 | html += `<div class="heatmap-month">`;
3969 | html += `<div class="month-label">${monthName} ${year}</div>`;
3970 | html += '<div class="month-grid">';
3971 |
3972 | // Create 7x6 grid (weeks x days)
3973 | const monthData = months[monthKey];
3974 | // Parse date in local timezone to avoid day-of-week shifting near timezone boundaries
3975 | const [fYear, fMonth, fDay] = monthData[0].date.split('-').map(Number);
3976 | const firstDay = new Date(fYear, fMonth - 1, fDay).getDay();
3977 |
3978 | // Add empty cells for days before month starts
3979 | for (let i = 0; i < firstDay; i++) {
3980 | html += '<div class="heatmap-cell empty"></div>';
3981 | }
3982 |
3983 | // Add cells for each day
3984 | monthData.forEach(day => {
3985 | const level = day.level;
3986 | const tooltip = `${day.date}: ${day.count} memories`;
3987 | html += `<div class="heatmap-cell level-${level}" title="${tooltip}"></div>`;
3988 | });
3989 |
3990 | html += '</div></div>';
3991 | });
3992 |
3993 | // Legend
3994 | html += '<div class="heatmap-legend">';
3995 | html += '<span>Less</span>';
3996 | for (let i = 0; i <= 4; i++) {
3997 | html += `<div class="legend-cell level-${i}"></div>`;
3998 | }
3999 | html += '<span>More</span>';
4000 | html += '</div>';
4001 |
4002 | html += '</div>';
4003 | container.innerHTML = html;
4004 | }
4005 |
4006 | /**
4007 | * Handle heatmap period change
4008 | */
4009 | async handleHeatmapPeriodChange() {
4010 | await this.loadActivityHeatmapChart();
4011 | }
4012 |
4013 | /**
4014 | * Handle top tags period change
4015 | */
4016 | async handleTopTagsPeriodChange() {
4017 | await this.loadTopTagsReport();
4018 | }
4019 |
4020 | /**
4021 | * Handle activity granularity change
4022 | */
4023 | async handleActivityGranularityChange() {
4024 | await this.loadRecentActivityReport();
4025 | }
4026 |
4027 | /**
4028 | * Load storage report
4029 | */
4030 | async loadStorageReport() {
4031 | const container = document.getElementById('storageReport');
4032 | if (!container) return;
4033 |
4034 | try {
4035 | const response = await fetch(`${this.apiBase}/analytics/storage-stats`);
4036 | if (!response.ok) throw new Error('Failed to load storage stats');
4037 |
4038 | const data = await response.json();
4039 | this.renderStorageReport(container, data);
4040 | } catch (error) {
4041 | console.error('Failed to load storage report:', error);
4042 | container.innerHTML = '<p class="error">Failed to load storage report</p>';
4043 | }
4044 | }
4045 |
4046 | /**
4047 | * Render storage report
4048 | */
4049 | renderStorageReport(container, data) {
4050 | let html = '<div class="storage-report">';
4051 |
4052 | // Summary stats
4053 | html += '<div class="storage-summary">';
4054 | html += `<div class="storage-stat"><strong>Total Size:</strong> ${data.total_size_mb} MB</div>`;
4055 | html += `<div class="storage-stat"><strong>Average Memory:</strong> ${data.average_memory_size} chars</div>`;
4056 | html += `<div class="storage-stat"><strong>Efficiency:</strong> ${data.storage_efficiency}%</div>`;
4057 | html += '</div>';
4058 |
4059 | // Largest memories
4060 | if (data.largest_memories && data.largest_memories.length > 0) {
4061 | html += '<h4>Largest Memories</h4>';
4062 | html += '<ul class="largest-memories">';
4063 | data.largest_memories.slice(0, 5).forEach(memory => {
4064 | // Backend provides created_at as ISO string, not timestamp
4065 | const date = memory.created_at ? new Date(memory.created_at).toLocaleDateString() : 'Unknown';
4066 | // Backend provides size_kb and preview (not size and content_preview)
4067 | const sizeDisplay = memory.size_kb ? `${memory.size_kb} KB` : `${memory.size_bytes || 0} bytes`;
4068 | html += `<li>
4069 | <div class="memory-size">${sizeDisplay}</div>
4070 | <div class="memory-preview">${this.escapeHtml(memory.preview || '')}</div>
4071 | <div class="memory-meta">${date} • Tags: ${memory.tags.join(', ') || 'none'}</div>
4072 | </li>`;
4073 | });
4074 | html += '</ul>';
4075 | }
4076 |
4077 | html += '</div>';
4078 | container.innerHTML = html;
4079 | }
4080 |
4081 | /**
4082 | * Handle growth period change
4083 | */
4084 | async handleGrowthPeriodChange() {
4085 | await this.loadMemoryGrowthChart();
4086 | }
4087 |
4088 | // ===== UTILITY METHODS =====
4089 |
4090 | /**
4091 | * Show confirmation dialog for bulk operations
4092 | */
4093 | async confirmBulkOperation(message) {
4094 | return confirm(`⚠️ WARNING: ${message}
4095 |
4096 | This action cannot be undone. Are you sure?`);
4097 | }
4098 |
4099 | /**
4100 | * Update element text content
4101 | */
4102 | updateElementText(elementId, text) {
4103 | const element = document.getElementById(elementId);
4104 | if (element) {
4105 | element.textContent = text;
4106 | }
4107 | }
4108 |
4109 | /**
4110 | * Cleanup when page unloads
4111 | */
4112 | destroy() {
4113 | if (this.eventSource) {
4114 | this.eventSource.close();
4115 | }
4116 | }
4117 | }
4118 |
4119 | // Initialize the application when DOM is ready
4120 | console.log('⚡ Registering DOMContentLoaded listener');
4121 | document.addEventListener('DOMContentLoaded', () => {
4122 | console.log('⚡ DOMContentLoaded fired - Creating MemoryDashboard instance');
4123 | window.app = new MemoryDashboard();
4124 | console.log('⚡ MemoryDashboard created, window.app =', window.app);
4125 | });
4126 |
4127 | // Cleanup on page unload
4128 | window.addEventListener('beforeunload', () => {
4129 | if (window.app) {
4130 | window.app.destroy();
4131 | }
4132 | });
```