This is page 61 of 62. 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
│ ├── commands
│ │ ├── README.md
│ │ ├── refactor-function
│ │ ├── refactor-function-prod
│ │ └── refactor-function.md
│ ├── consolidation-fix-handoff.md
│ ├── consolidation-hang-fix-summary.md
│ ├── directives
│ │ ├── agents.md
│ │ ├── code-quality-workflow.md
│ │ ├── consolidation-details.md
│ │ ├── development-setup.md
│ │ ├── hooks-configuration.md
│ │ ├── memory-first.md
│ │ ├── memory-tagging.md
│ │ ├── pr-workflow.md
│ │ ├── quality-system-details.md
│ │ ├── README.md
│ │ ├── refactoring-checklist.md
│ │ ├── storage-backends.md
│ │ └── version-management.md
│ ├── prompts
│ │ └── hybrid-cleanup-integration.md
│ ├── settings.local.json.backup
│ └── settings.local.json.local
├── .commit-message
├── .coveragerc
├── .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-branch-automation.yml
│ ├── claude-code-review.yml
│ ├── claude.yml
│ ├── cleanup-images.yml.disabled
│ ├── dev-setup-validation.yml
│ ├── docker-publish.yml
│ ├── dockerfile-lint.yml
│ ├── LATEST_FIXES.md
│ ├── main-optimized.yml.disabled
│ ├── main.yml
│ ├── publish-and-test.yml
│ ├── publish-dual.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
├── .metrics
│ ├── baseline_cc_install_hooks.txt
│ ├── baseline_mi_install_hooks.txt
│ ├── baseline_nesting_install_hooks.txt
│ ├── BASELINE_REPORT.md
│ ├── COMPLEXITY_COMPARISON.txt
│ ├── QUICK_REFERENCE.txt
│ ├── README.md
│ ├── REFACTORED_BASELINE.md
│ ├── REFACTORING_COMPLETION_REPORT.md
│ └── TRACKING_TABLE.md
├── .pyscn
│ ├── .gitignore
│ └── reports
│ └── analyze_20251123_214224.html
├── AGENTS.md
├── ai-optimized-tool-descriptions.py
├── 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
│ │ ├── auto-capture-hook.js
│ │ ├── auto-capture-hook.ps1
│ │ ├── memory-retrieval.js
│ │ ├── mid-conversation.js
│ │ ├── permission-request.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-AUTO-CAPTURE.md
│ ├── README-NATURAL-TRIGGERS.md
│ ├── README-PERMISSION-REQUEST.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-permission-request.js
│ │ ├── test-session-tracking.json
│ │ └── test-threading.json
│ ├── utilities
│ │ ├── adaptive-pattern-detector.js
│ │ ├── auto-capture-patterns.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-cache.json
│ │ ├── session-tracker.js
│ │ ├── tiered-conversation-monitor.js
│ │ ├── user-override-detector.js
│ │ └── version-checker.js
│ └── WINDOWS-SESSIONSTART-BUG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── COMMIT_MESSAGE.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
│ │ ├── graph-database-design.md
│ │ ├── 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
│ ├── demo-recording-script.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-280-post-mortem.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
│ │ ├── quality-system-configs.md
│ │ └── tag-schema.json
│ ├── features
│ │ └── association-quality-boost.md
│ ├── 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
│ │ ├── memory-quality-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
│ │ └── update-restart-demo.png
│ ├── 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
│ ├── LIGHTWEIGHT_ONNX_SETUP.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
│ │ └── graph-migration-guide.md
│ ├── natural-memory-triggers
│ │ ├── cli-reference.md
│ │ ├── installation-guide.md
│ │ └── performance-optimization.md
│ ├── oauth-setup.md
│ ├── pr-graphql-integration.md
│ ├── quality-system-ui-implementation.md
│ ├── quick-setup-cloudflare-dual-environment.md
│ ├── README.md
│ ├── refactoring
│ │ └── phase-3-3-analysis.md
│ ├── releases
│ │ └── v8.72.0-testing.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
│ │ ├── database-transfer-migration.md
│ │ ├── general.md
│ │ ├── hooks-quick-reference.md
│ │ ├── memory-management.md
│ │ ├── pr162-schema-caching-issue.md
│ │ ├── session-end-hooks.md
│ │ └── sync-issues.md
│ ├── tutorials
│ │ ├── advanced-techniques.md
│ │ ├── data-analysis.md
│ │ └── demo-session-walkthrough.md
│ ├── wiki-documentation-plan.md
│ └── wiki-Graph-Database-Architecture.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
├── IMPLEMENTATION_SUMMARY.md
├── install_service.py
├── install.py
├── LICENSE
├── NOTICE
├── PR_DESCRIPTION.md
├── pyproject-lite.toml
├── pyproject.toml
├── pytest.ini
├── README.md
├── release-notes-v8.61.0.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
│ ├── ci
│ │ ├── check_dockerfile_args.sh
│ │ └── validate_imports.sh
│ ├── 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
│ │ ├── add_project_tags.py
│ │ ├── apply_quality_boost_retroactively.py
│ │ ├── assign_memory_types.py
│ │ ├── auto_retag_memory_merge.py
│ │ ├── auto_retag_memory.py
│ │ ├── backfill_graph_table.py
│ │ ├── check_memory_types.py
│ │ ├── cleanup_association_memories_hybrid.py
│ │ ├── cleanup_association_memories.py
│ │ ├── cleanup_corrupted_encoding.py
│ │ ├── cleanup_low_quality.py
│ │ ├── cleanup_memories.py
│ │ ├── cleanup_organize.py
│ │ ├── consolidate_memory_types.py
│ │ ├── consolidation_mappings.json
│ │ ├── delete_orphaned_vectors_fixed.py
│ │ ├── delete_test_memories.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
│ │ ├── retag_valuable_memories.py
│ │ ├── scan_todos.sh
│ │ ├── soft_delete_test_memories.py
│ │ └── sync_status.py
│ ├── 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
│ │ ├── pre_pr_check.sh
│ │ ├── quality_gate.sh
│ │ ├── resolve_threads.sh
│ │ ├── run_pyscn_analysis.sh
│ │ ├── run_quality_checks_on_files.sh
│ │ ├── run_quality_checks.sh
│ │ ├── thread_status.sh
│ │ └── watch_reviews.sh
│ ├── quality
│ │ ├── bulk_evaluate_onnx.py
│ │ ├── check_test_scores.py
│ │ ├── debug_deberta_scoring.py
│ │ ├── export_deberta_onnx.py
│ │ ├── fix_dead_code_install.sh
│ │ ├── migrate_to_deberta.py
│ │ ├── phase1_dead_code_analysis.md
│ │ ├── phase2_complexity_analysis.md
│ │ ├── README_PHASE1.md
│ │ ├── README_PHASE2.md
│ │ ├── rescore_deberta.py
│ │ ├── rescore_fallback.py
│ │ ├── reset_onnx_scores.py
│ │ ├── track_pyscn_metrics.sh
│ │ └── weekly_quality_review.sh
│ ├── README.md
│ ├── run
│ │ ├── memory_wrapper_cleanup.ps1
│ │ ├── memory_wrapper_cleanup.py
│ │ ├── memory_wrapper_cleanup.sh
│ │ ├── README_CLEANUP_WRAPPER.md
│ │ ├── 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
│ │ ├── http_server_manager.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
│ │ └── windows
│ │ ├── add_watchdog_trigger.ps1
│ │ ├── install_scheduled_task.ps1
│ │ ├── manage_service.ps1
│ │ ├── run_http_server_background.ps1
│ │ ├── uninstall_scheduled_task.ps1
│ │ └── update_and_restart.ps1
│ ├── setup-lightweight.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
│ ├── update_and_restart.sh
│ ├── utils
│ │ ├── claude_commands_utils.py
│ │ ├── detect_platform.py
│ │ ├── generate_personalized_claude_md.sh
│ │ ├── groq
│ │ ├── groq_agent_bridge.py
│ │ ├── list-collections.py
│ │ ├── memory_wrapper_uv.py
│ │ ├── query_memories.py
│ │ ├── README_detect_platform.md
│ │ ├── smithery_wrapper.py
│ │ ├── test_groq_bridge.sh
│ │ └── uv_wrapper.py
│ └── validation
│ ├── check_dev_setup.py
│ ├── check_documentation_links.py
│ ├── check_handler_coverage.py
│ ├── diagnose_backend_config.py
│ ├── validate_configuration_complete.py
│ ├── validate_graph_tools.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
│ ├── _version.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
│ ├── quality
│ │ ├── __init__.py
│ │ ├── ai_evaluator.py
│ │ ├── async_scorer.py
│ │ ├── config.py
│ │ ├── implicit_signals.py
│ │ ├── metadata_codec.py
│ │ ├── onnx_ranker.py
│ │ └── scorer.py
│ ├── server
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── cache_manager.py
│ │ ├── client_detection.py
│ │ ├── environment.py
│ │ ├── handlers
│ │ │ ├── __init__.py
│ │ │ ├── consolidation.py
│ │ │ ├── documents.py
│ │ │ ├── graph.py
│ │ │ ├── memory.py
│ │ │ ├── quality.py
│ │ │ └── utility.py
│ │ └── logging_config.py
│ ├── server_impl.py
│ ├── services
│ │ ├── __init__.py
│ │ └── memory_service.py
│ ├── storage
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── cloudflare.py
│ │ ├── factory.py
│ │ ├── graph.py
│ │ ├── http_client.py
│ │ ├── hybrid.py
│ │ ├── migrations
│ │ │ └── 008_add_graph_table.sql
│ │ └── 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
│ │ ├── directory_ingestion.py
│ │ ├── document_processing.py
│ │ ├── gpu_detection.py
│ │ ├── hashing.py
│ │ ├── health_check.py
│ │ ├── http_server_manager.py
│ │ ├── port_detection.py
│ │ ├── quality_analytics.py
│ │ ├── startup_orchestrator.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
│ │ ├── quality.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
│ ├── i18n
│ │ ├── de.json
│ │ ├── en.json
│ │ ├── es.json
│ │ ├── fr.json
│ │ ├── ja.json
│ │ ├── ko.json
│ │ └── zh.json
│ ├── index.html
│ ├── README.md
│ ├── sse_test.html
│ └── style.css
├── start_http_debug.bat
├── start_http_server.sh
├── test_document.txt
├── test_version_checker.js
├── TESTING_NOTES.md
├── 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
│ │ └── test_graph_modes.py
│ ├── contracts
│ │ └── api-specification.yml
│ ├── integration
│ │ ├── conftest.py
│ │ ├── HANDLER_COVERAGE_REPORT.md
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── test_all_memory_handlers.py
│ │ ├── 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
│ ├── storage
│ │ ├── conftest.py
│ │ └── test_graph_storage.py
│ ├── test_client.py
│ ├── test_content_splitting.py
│ ├── test_database.py
│ ├── test_deberta_quality.py
│ ├── test_fallback_quality.py
│ ├── test_graph_traversal.py
│ ├── test_hybrid_cloudflare_limits.py
│ ├── test_hybrid_storage.py
│ ├── test_lightweight_onnx.py
│ ├── test_memory_ops.py
│ ├── test_memory_wrapper_cleanup.py
│ ├── test_quality_integration.py
│ ├── test_quality_system.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_imports.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
│ └── test_uv_no_pip_installer_fallback.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
└── verify_compression.sh
```
# 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 | // 国际化状态
69 | this.currentLang = 'en';
70 | this.translations = {};
71 | this.fallbackTranslations = {};
72 | this.supportedLanguages = ['en', 'zh'];
73 | this.languageMetadata = {}; // Stores language metadata (flags, names, etc.)
74 | this.searchResults = [];
75 | this.isLoading = false;
76 | this.liveSearchEnabled = true;
77 | this.debounceTimer = null;
78 |
79 | // Settings with defaults
80 | this.settings = {
81 | theme: 'light',
82 | viewDensity: 'comfortable',
83 | previewLines: 3
84 | };
85 |
86 | // Documents upload state
87 | this.selectedFiles = [];
88 | this.documentsListenersSetup = false;
89 | this.processingMode = 'batch'; // 'batch' or 'individual'
90 |
91 | // Bind methods
92 | this.handleSearch = this.handleSearch.bind(this);
93 | this.handleQuickSearch = this.handleQuickSearch.bind(this);
94 | this.handleNavigation = this.handleNavigation.bind(this);
95 | this.handleAddMemory = this.handleAddMemory.bind(this);
96 | this.handleMemoryClick = this.handleMemoryClick.bind(this);
97 |
98 | this.init();
99 | }
100 |
101 | /**
102 | * Initialize the application
103 | */
104 | async init() {
105 | await this.initI18n();
106 | this.loadSettings();
107 | this.applyTheme();
108 | this.setupEventListeners();
109 | this.setupSSE();
110 | await this.loadVersion();
111 | await this.loadDashboardData();
112 | this.updateConnectionStatus('connected');
113 |
114 | // Initialize sync status monitoring for hybrid mode
115 | await this.checkSyncStatus();
116 | this.startSyncStatusMonitoring();
117 | }
118 |
119 | /**
120 | * 初始化国际化:检测语言,加载词典并应用
121 | */
122 | async initI18n() {
123 | await this.detectAvailableLanguages(); // Dynamically detect available languages
124 | await this.loadLanguageMetadata(); // Load metadata for all languages
125 | this.currentLang = this.detectLanguage();
126 | await this.ensureFallbackTranslations();
127 | await this.loadTranslations(this.currentLang);
128 | await this.buildLanguageSelector(); // Build dropdown dynamically
129 | await this.applyTranslations();
130 | }
131 |
132 | /**
133 | * 语言检测:localStorage > 浏览器语言前缀 > en
134 | */
135 | detectLanguage() {
136 | const stored = localStorage.getItem('memoryDashboardLang');
137 | if (stored && this.supportedLanguages.includes(stored)) {
138 | return stored;
139 | }
140 | const browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
141 | if (browserLang.startsWith('zh')) return 'zh';
142 | return 'en';
143 | }
144 |
145 | /**
146 | * 确保英文词典作为回退存在
147 | */
148 | async ensureFallbackTranslations() {
149 | if (Object.keys(this.fallbackTranslations).length > 0) return;
150 | try {
151 | const response = await fetch('/static/i18n/en.json');
152 | if (response.ok) {
153 | this.fallbackTranslations = await response.json();
154 | }
155 | } catch (error) {
156 | console.warn('Failed to load fallback translations', error);
157 | this.fallbackTranslations = {};
158 | }
159 | }
160 |
161 | /**
162 | * Dynamically detect available languages from API
163 | */
164 | async detectAvailableLanguages() {
165 | try {
166 | const response = await fetch('/api/languages');
167 | if (response.ok) {
168 | const data = await response.json();
169 | this.supportedLanguages = data.languages || ['en'];
170 | }
171 | } catch (error) {
172 | console.warn('Failed to fetch language list, using defaults:', error);
173 | this.supportedLanguages = ['en', 'zh'];
174 | }
175 | }
176 |
177 | /**
178 | * Load language metadata (flags, native names) from translation files
179 | */
180 | async loadLanguageMetadata() {
181 | for (const lang of this.supportedLanguages) {
182 | try {
183 | const response = await fetch(`/static/i18n/${lang}.json`);
184 | if (response.ok) {
185 | const data = await response.json();
186 | this.languageMetadata[lang] = {
187 | code: data['meta.language.code'] || lang,
188 | nativeName: data['meta.language.nativeName'] || lang.toUpperCase(),
189 | englishName: data['meta.language.englishName'] || lang.toUpperCase(),
190 | flag: data['meta.language.flag'] || '🌐'
191 | };
192 | }
193 | } catch (error) {
194 | console.warn(`Failed to load metadata for ${lang}:`, error);
195 | // Fallback metadata
196 | this.languageMetadata[lang] = {
197 | code: lang,
198 | nativeName: lang.toUpperCase(),
199 | englishName: lang.toUpperCase(),
200 | flag: '🌐'
201 | };
202 | }
203 | }
204 | }
205 |
206 | /**
207 | * Build language selector dropdown dynamically
208 | */
209 | async buildLanguageSelector() {
210 | const dropdown = document.querySelector('.lang-dropdown-menu');
211 | if (!dropdown) return;
212 |
213 | // Clear existing options
214 | dropdown.innerHTML = '';
215 |
216 | // Create option for each language
217 | this.supportedLanguages.forEach(lang => {
218 | const meta = this.languageMetadata[lang];
219 | if (!meta) return;
220 |
221 | const option = document.createElement('button');
222 | option.className = 'lang-option';
223 | option.dataset.lang = lang;
224 | option.setAttribute('role', 'menuitem');
225 | option.setAttribute('type', 'button');
226 |
227 | // Build option HTML
228 | option.innerHTML = `
229 | <span class="lang-flag">${meta.flag}</span>
230 | <span class="lang-name">${meta.nativeName}</span>
231 | <svg class="lang-check" width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
233 | </svg>
234 | `;
235 |
236 | // Add click handler
237 | option.addEventListener('click', async (e) => {
238 | e.preventDefault();
239 | e.stopPropagation();
240 | await this.setLanguage(lang);
241 | this.toggleLanguageDropdown(false);
242 | });
243 |
244 | dropdown.appendChild(option);
245 | });
246 | }
247 |
248 | /**
249 | * Toggle language dropdown visibility
250 | */
251 | toggleLanguageDropdown(show) {
252 | const trigger = document.querySelector('.lang-dropdown-trigger');
253 | const dropdown = document.querySelector('.lang-dropdown-menu');
254 |
255 | if (!trigger || !dropdown) return;
256 |
257 | if (show === undefined) {
258 | // Toggle
259 | show = dropdown.hasAttribute('hidden');
260 | }
261 |
262 | if (show) {
263 | dropdown.removeAttribute('hidden');
264 | trigger.setAttribute('aria-expanded', 'true');
265 | } else {
266 | dropdown.setAttribute('hidden', '');
267 | trigger.setAttribute('aria-expanded', 'false');
268 | }
269 | }
270 |
271 | /**
272 | * 加载指定语言词典
273 | */
274 | async loadTranslations(lang) {
275 | try {
276 | const response = await fetch(`/static/i18n/${lang}.json`);
277 | if (response.ok) {
278 | this.translations = await response.json();
279 | this.currentLang = lang;
280 | localStorage.setItem('memoryDashboardLang', lang);
281 | return;
282 | }
283 | } catch (error) {
284 | console.warn('Failed to load translations for', lang, error);
285 | }
286 | // 回退到英文
287 | this.translations = this.fallbackTranslations;
288 | this.currentLang = 'en';
289 | localStorage.setItem('memoryDashboardLang', 'en');
290 | }
291 |
292 | /**
293 | * 获取翻译,带占位符替换
294 | */
295 | t(key, fallback = '', vars = {}) {
296 | const fromCurrent = this.translations?.[key];
297 | const fromFallback = this.fallbackTranslations?.[key];
298 | const template = fromCurrent || fromFallback || fallback || key;
299 | return this.replacePlaceholders(template, vars);
300 | }
301 |
302 | replacePlaceholders(str, vars) {
303 | return str.replace(/\{(.*?)\}/g, (_, k) => vars[k] ?? `{${k}}`);
304 | }
305 |
306 | /**
307 | * 应用翻译到 DOM
308 | */
309 | async applyTranslations() {
310 | // 统一遍历带 i18n 属性的元素,减少多次 DOM 遍历
311 | document.querySelectorAll('[data-i18n], [data-i18n-html], [data-i18n-placeholder], [data-i18n-aria]').forEach(el => {
312 | const { i18n, i18nHtml, i18nPlaceholder, i18nAria } = el.dataset;
313 |
314 | if (i18n) {
315 | const text = this.t(i18n, el.textContent?.trim() || '');
316 | if (text) el.textContent = text;
317 | }
318 |
319 | if (i18nHtml) {
320 | const html = this.t(i18nHtml, el.innerHTML.trim() || '');
321 | if (html) el.innerHTML = html;
322 | }
323 |
324 | if (i18nPlaceholder) {
325 | const text = this.t(i18nPlaceholder, el.getAttribute('placeholder') || '');
326 | el.setAttribute('placeholder', text);
327 | }
328 |
329 | if (i18nAria) {
330 | const text = this.t(i18nAria, el.getAttribute('aria-label') || '');
331 | el.setAttribute('aria-label', text);
332 | }
333 | });
334 |
335 | // 文档标题
336 | document.title = this.t('meta.title', document.title);
337 |
338 | // 搜索模式文案
339 | const modeText = document.getElementById('searchModeText');
340 | if (modeText) {
341 | modeText.textContent = this.liveSearchEnabled
342 | ? this.t('search.modeLive', modeText.textContent)
343 | : this.t('search.modeManual', modeText.textContent);
344 | }
345 |
346 | // 更新语言按钮状态
347 | this.updateLanguageSwitcher();
348 | }
349 |
350 | /**
351 | * 切换语言
352 | */
353 | async setLanguage(lang) {
354 | if (!this.supportedLanguages.includes(lang) || lang === this.currentLang) return;
355 | await this.loadTranslations(lang);
356 | await this.applyTranslations();
357 | this.showToast(this.t('toast.languageSwitched', 'Language switched'), 'success', 2000);
358 | }
359 |
360 | /**
361 | * 更新语言选择器状态
362 | * Update language selector (dropdown trigger text and active option)
363 | */
364 | updateLanguageSwitcher() {
365 | const trigger = document.querySelector('.lang-dropdown-trigger');
366 | const currentLangSpan = trigger?.querySelector('.lang-current');
367 | const options = document.querySelectorAll('.lang-option');
368 |
369 | // Update trigger text with current language native name
370 | if (currentLangSpan && this.languageMetadata[this.currentLang]) {
371 | const meta = this.languageMetadata[this.currentLang];
372 | currentLangSpan.textContent = meta.nativeName;
373 | }
374 |
375 | // Update active state for dropdown options
376 | options.forEach(option => {
377 | const isActive = option.dataset.lang === this.currentLang;
378 | option.classList.toggle('active', isActive);
379 | });
380 | }
381 |
382 | /**
383 | * Set up event listeners for UI interactions
384 | */
385 | setupEventListeners() {
386 | console.log('⚡ setupEventListeners() called');
387 | // Navigation
388 | document.querySelectorAll('.nav-item').forEach(item => {
389 | item.addEventListener('click', this.handleNavigation);
390 | });
391 |
392 | // Language dropdown
393 | const langTrigger = document.querySelector('.lang-dropdown-trigger');
394 | const langDropdown = document.querySelector('.lang-dropdown-menu');
395 |
396 | if (langTrigger) {
397 | // Toggle dropdown on trigger click
398 | langTrigger.addEventListener('click', (e) => {
399 | e.stopPropagation();
400 | this.toggleLanguageDropdown();
401 | });
402 |
403 | // Keyboard navigation
404 | langTrigger.addEventListener('keydown', (e) => {
405 | if (e.key === 'Enter' || e.key === ' ') {
406 | e.preventDefault();
407 | this.toggleLanguageDropdown();
408 | } else if (e.key === 'Escape') {
409 | this.toggleLanguageDropdown(false);
410 | }
411 | });
412 | }
413 |
414 | // Close dropdown when clicking outside
415 | document.addEventListener('click', (e) => {
416 | if (langDropdown && !langDropdown.hasAttribute('hidden')) {
417 | const isClickInside = langTrigger?.contains(e.target) || langDropdown.contains(e.target);
418 | if (!isClickInside) {
419 | this.toggleLanguageDropdown(false);
420 | }
421 | }
422 | });
423 |
424 | // Search functionality
425 | const quickSearch = document.getElementById('quickSearch');
426 | const searchBtn = document.querySelector('.search-btn');
427 |
428 | if (quickSearch) {
429 | quickSearch.addEventListener('input', this.debounce(this.handleQuickSearch, 300));
430 | quickSearch.addEventListener('keypress', (e) => {
431 | if (e.key === 'Enter') {
432 | this.handleSearch(e.target.value);
433 | }
434 | });
435 | }
436 |
437 | if (searchBtn && quickSearch) {
438 | searchBtn.addEventListener('click', () => {
439 | this.handleSearch(quickSearch.value);
440 | });
441 | }
442 |
443 | // Add memory functionality
444 | const addMemoryBtn = document.getElementById('addMemoryBtn');
445 | if (addMemoryBtn) {
446 | addMemoryBtn.addEventListener('click', this.handleAddMemory);
447 | }
448 | document.querySelectorAll('[data-action="add-memory"]').forEach(btn => {
449 | btn.addEventListener('click', this.handleAddMemory);
450 | });
451 |
452 | // Modal close handlers
453 | document.querySelectorAll('.modal-close').forEach(btn => {
454 | btn.addEventListener('click', (e) => {
455 | this.closeModal(e.target.closest('.modal-overlay'));
456 | });
457 | });
458 |
459 | // Modal overlay click to close
460 | document.querySelectorAll('.modal-overlay').forEach(overlay => {
461 | overlay.addEventListener('click', (e) => {
462 | if (e.target === overlay) {
463 | this.closeModal(overlay);
464 | }
465 | });
466 | });
467 |
468 | // Add memory form submission
469 | const saveMemoryBtn = document.getElementById('saveMemoryBtn');
470 | if (saveMemoryBtn) {
471 | saveMemoryBtn.addEventListener('click', this.handleSaveMemory.bind(this));
472 | }
473 |
474 | const cancelAddBtn = document.getElementById('cancelAddBtn');
475 | if (cancelAddBtn) {
476 | cancelAddBtn.addEventListener('click', () => {
477 | this.closeModal(document.getElementById('addMemoryModal'));
478 | });
479 | }
480 |
481 | // Quick action handlers
482 | document.querySelectorAll('.action-card').forEach(card => {
483 | card.addEventListener('click', (e) => {
484 | const action = e.currentTarget.dataset.action;
485 | this.handleQuickAction(action);
486 | });
487 | });
488 |
489 | // Live search toggle handler
490 | const liveSearchToggle = document.getElementById('liveSearchToggle');
491 | liveSearchToggle?.addEventListener('change', this.handleLiveSearchToggle.bind(this));
492 |
493 | // Filter handlers for search view
494 | const tagFilterInput = document.getElementById('tagFilter');
495 | tagFilterInput?.addEventListener('input', this.handleDebouncedFilterChange.bind(this));
496 | tagFilterInput?.addEventListener('keypress', (e) => {
497 | if (e.key === 'Enter') {
498 | this.handleFilterChange();
499 | }
500 | });
501 | document.getElementById('dateFilter')?.addEventListener('change', this.handleFilterChange.bind(this));
502 | document.getElementById('typeFilter')?.addEventListener('change', this.handleFilterChange.bind(this));
503 |
504 | // View option handlers
505 | document.querySelectorAll('.view-btn').forEach(btn => {
506 | btn.addEventListener('click', (e) => {
507 | this.handleViewModeChange(e.target.dataset.view);
508 | });
509 | });
510 |
511 | // New filter action handlers
512 | document.getElementById('applyFiltersBtn')?.addEventListener('click', this.handleFilterChange.bind(this));
513 | document.getElementById('clearFiltersBtn')?.addEventListener('click', this.clearAllFilters.bind(this));
514 |
515 | // Theme toggle button
516 | document.getElementById('themeToggleBtn')?.addEventListener('click', () => {
517 | this.toggleTheme();
518 | });
519 |
520 | // Settings button
521 | document.getElementById('settingsBtn')?.addEventListener('click', () => {
522 | this.openSettingsModal();
523 | });
524 |
525 | // Settings modal handlers
526 | document.getElementById('saveSettingsBtn')?.addEventListener('click', () => {
527 | this.saveSettings();
528 | });
529 |
530 | document.getElementById('cancelSettingsBtn')?.addEventListener('click', () => {
531 | this.closeModal(document.getElementById('settingsModal'));
532 | });
533 |
534 | // Tag cloud event delegation
535 | document.getElementById('tagsCloudContainer')?.addEventListener('click', (e) => {
536 | if (e.target.classList.contains('tag-bubble') || e.target.closest('.tag-bubble')) {
537 | const tagButton = e.target.classList.contains('tag-bubble') ? e.target : e.target.closest('.tag-bubble');
538 | const tag = tagButton.dataset.tag;
539 | if (tag) {
540 | this.filterByTag(tag);
541 | }
542 | }
543 | });
544 |
545 | // Manage tab event listeners
546 | document.getElementById('deleteByTagBtn')?.addEventListener('click', this.handleBulkDeleteByTag.bind(this));
547 | document.getElementById('cleanupDuplicatesBtn')?.addEventListener('click', this.handleCleanupDuplicates.bind(this));
548 | document.getElementById('deleteByDateBtn')?.addEventListener('click', this.handleBulkDeleteByDate.bind(this));
549 | document.getElementById('deleteUntaggedBtn')?.addEventListener('click', this.handleDeleteUntagged.bind(this));
550 | document.getElementById('optimizeDbBtn')?.addEventListener('click', this.handleOptimizeDatabase.bind(this));
551 | document.getElementById('rebuildIndexBtn')?.addEventListener('click', this.handleRebuildIndex.bind(this));
552 |
553 | // Analytics tab event listeners
554 | document.getElementById('growthPeriodSelect')?.addEventListener('change', this.handleGrowthPeriodChange.bind(this));
555 | document.getElementById('heatmapPeriodSelect')?.addEventListener('change', this.handleHeatmapPeriodChange.bind(this));
556 | document.getElementById('topTagsPeriodSelect')?.addEventListener('change', this.handleTopTagsPeriodChange.bind(this));
557 | document.getElementById('activityGranularitySelect')?.addEventListener('change', this.handleActivityGranularityChange.bind(this));
558 |
559 | // Keyboard shortcuts
560 | document.addEventListener('keydown', (e) => {
561 | if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
562 | e.preventDefault();
563 | document.getElementById('searchInput').focus();
564 | }
565 | if ((e.ctrlKey || e.metaKey) && e.key === 'm') {
566 | e.preventDefault();
567 | this.handleAddMemory();
568 | }
569 | });
570 | }
571 |
572 | /**
573 | * Set up Server-Sent Events for real-time updates
574 | */
575 | setupSSE() {
576 | try {
577 | this.eventSource = new EventSource(`${this.apiBase}/events`);
578 |
579 | this.eventSource.onopen = () => {
580 | this.updateConnectionStatus('connected');
581 | };
582 |
583 | this.eventSource.onmessage = (event) => {
584 | try {
585 | const data = JSON.parse(event.data);
586 | this.handleRealtimeUpdate(data);
587 | } catch (error) {
588 | console.error('Error parsing SSE data:', error);
589 | }
590 | };
591 |
592 | // Add specific event listeners for sync progress
593 | this.eventSource.addEventListener('sync_progress', (event) => {
594 | try {
595 | const data = JSON.parse(event.data);
596 | this.handleSyncProgress(data);
597 | } catch (error) {
598 | console.error('Error parsing sync_progress event:', error);
599 | }
600 | });
601 |
602 | this.eventSource.addEventListener('sync_completed', (event) => {
603 | try {
604 | const data = JSON.parse(event.data);
605 | this.handleSyncCompleted(data);
606 | } catch (error) {
607 | console.error('Error parsing sync_completed event:', error);
608 | }
609 | });
610 |
611 | this.eventSource.onerror = (error) => {
612 | console.error('SSE connection error:', error);
613 | this.updateConnectionStatus('disconnected');
614 |
615 | // Attempt to reconnect after 5 seconds
616 | setTimeout(() => {
617 | if (this.eventSource.readyState === EventSource.CLOSED) {
618 | this.setupSSE();
619 | }
620 | }, 5000);
621 | };
622 |
623 | } catch (error) {
624 | console.error('Failed to establish SSE connection:', error);
625 | this.updateConnectionStatus('disconnected');
626 | }
627 | }
628 |
629 | /**
630 | * Handle real-time updates from SSE
631 | */
632 | handleRealtimeUpdate(data) {
633 | switch (data.type) {
634 | case 'memory_added':
635 | this.handleMemoryAdded(data.memory);
636 | this.showToast(this.t('toast.memoryAdded', 'Memory added successfully'), 'success');
637 | break;
638 | case 'memory_deleted':
639 | this.handleMemoryDeleted(data.memory_id);
640 | this.showToast(this.t('toast.memoryDeleted', 'Memory deleted'), 'success');
641 | break;
642 | case 'memory_updated':
643 | this.handleMemoryUpdated(data.memory);
644 | this.showToast(this.t('toast.memoryUpdated', 'Memory updated'), 'success');
645 | break;
646 | case 'stats_updated':
647 | this.updateDashboardStats(data.stats);
648 | break;
649 | default:
650 | // Unknown event type - ignore silently
651 | }
652 | }
653 |
654 | /**
655 | * Handle sync progress updates from SSE
656 | */
657 | handleSyncProgress(data) {
658 | console.log('Sync progress:', data);
659 |
660 | // Update sync status display if visible
661 | const syncStatus = document.getElementById('syncStatus');
662 | if (syncStatus) {
663 | const progressText = `Syncing: ${data.synced_count}/${data.total_count} (${data.progress_percentage}%)`;
664 | syncStatus.textContent = progressText;
665 | syncStatus.className = 'sync-status syncing';
666 | }
667 |
668 | // Update memory count in real-time if on dashboard
669 | if (this.currentView === 'dashboard') {
670 | const memoryCountElement = document.getElementById('totalMemories');
671 | if (memoryCountElement && data.synced_count) {
672 | // Refresh the detailed health to get accurate count
673 | this.loadDashboardData().catch(err => console.error('Error refreshing dashboard:', err));
674 | }
675 | }
676 |
677 | // Show toast notification for manual sync
678 | if (data.sync_type === 'manual') {
679 | this.showToast(
680 | data.message || this.t('toast.syncing', 'Syncing: {synced}/{total}', {
681 | synced: data.synced_count,
682 | total: data.total_count
683 | }),
684 | 'info'
685 | );
686 | }
687 | }
688 |
689 | /**
690 | * Handle sync completion from SSE
691 | */
692 | handleSyncCompleted(data) {
693 | console.log('Sync completed:', data);
694 |
695 | // Update sync status display
696 | const syncStatus = document.getElementById('syncStatus');
697 | if (syncStatus) {
698 | syncStatus.textContent = 'Synced';
699 | syncStatus.className = 'sync-status synced';
700 | }
701 |
702 | // Refresh dashboard data to show updated counts
703 | if (this.currentView === 'dashboard') {
704 | this.loadDashboardData().catch(err => console.error('Error refreshing dashboard:', err));
705 | }
706 |
707 | // Also refresh sync status for hybrid mode
708 | this.checkSyncStatus().catch(err => console.error('Error checking sync status:', err));
709 |
710 | // Show completion notification
711 | const message = data.message || `Sync completed: ${data.synced_count} memories synced`;
712 | this.showToast(message, 'success');
713 | }
714 |
715 | /**
716 | * Load application version from health endpoint
717 | */
718 | async loadVersion() {
719 | try {
720 | const healthResponse = await this.apiCall('/health');
721 | const versionBadge = document.getElementById('versionBadge');
722 | if (versionBadge && healthResponse.version) {
723 | versionBadge.textContent = `v${healthResponse.version}`;
724 | }
725 | } catch (error) {
726 | console.error('Error loading version:', error);
727 | const versionBadge = document.getElementById('versionBadge');
728 | if (versionBadge) {
729 | versionBadge.textContent = 'v?.?.?';
730 | }
731 | }
732 | }
733 |
734 | /**
735 | * Load initial dashboard data
736 | */
737 | async loadDashboardData() {
738 | this.setLoading(true);
739 |
740 | try {
741 | // Load recent memories for dashboard display
742 | const memoriesResponse = await this.apiCall('/memories?page=1&page_size=100');
743 | if (memoriesResponse.memories) {
744 | this.memories = memoriesResponse.memories;
745 | this.renderRecentMemories(memoriesResponse.memories);
746 | }
747 |
748 | // Load basic statistics
749 | const statsResponse = await this.apiCall('/health/detailed');
750 | if (statsResponse.storage) {
751 | this.updateDashboardStats(statsResponse.storage);
752 | }
753 |
754 |
755 | } catch (error) {
756 | console.error('Error loading dashboard data:', error);
757 | this.showToast(this.t('toast.loadDashboardFail', 'Failed to load dashboard data'), 'error');
758 | } finally {
759 | this.setLoading(false);
760 | }
761 | }
762 |
763 | /**
764 | * Load browse view data (tags)
765 | */
766 | async loadBrowseData() {
767 | this.setLoading(true);
768 | try {
769 | // Load tags with counts from the dedicated endpoint
770 | const tagsResponse = await this.apiCall('/tags');
771 | if (tagsResponse.tags) {
772 | this.tags = tagsResponse.tags;
773 | this.renderTagsCloud();
774 | }
775 | } catch (error) {
776 | console.error('Error loading browse data:', error);
777 | this.showToast(this.t('toast.loadBrowseFail', 'Failed to load browse data'), 'error');
778 | } finally {
779 | this.setLoading(false);
780 | }
781 | }
782 |
783 | /**
784 | * Load documents view data
785 | */
786 | async loadDocumentsData() {
787 | this.setLoading(true);
788 | try {
789 | // Load upload history
790 | await this.loadUploadHistory();
791 | // Setup document upload event listeners
792 | this.setupDocumentsEventListeners();
793 | } catch (error) {
794 | console.error('Error loading documents data:', error);
795 | this.showToast(this.t('toast.loadDocumentsFail', 'Failed to load documents data'), 'error');
796 | } finally {
797 | this.setLoading(false);
798 | }
799 | }
800 |
801 | /**
802 | * Load upload history from API
803 | */
804 | async loadUploadHistory() {
805 | console.log('Loading upload history...');
806 | try {
807 | const historyResponse = await this.apiCall('/documents/history');
808 | console.log('Upload history response:', historyResponse);
809 | if (historyResponse.uploads) {
810 | this.renderUploadHistory(historyResponse.uploads);
811 | } else {
812 | console.warn('No uploads property in response');
813 | this.renderUploadHistory([]);
814 | }
815 | } catch (error) {
816 | console.error('Error loading upload history:', error);
817 | // Show a message in the history container instead of just logging
818 | const historyContainer = document.getElementById('uploadHistory');
819 | if (historyContainer) {
820 | historyContainer.innerHTML = '<p style="text-align: center; color: var(--error);">Failed to load upload history. Please check the console for details.</p>';
821 | }
822 | }
823 | }
824 |
825 | /**
826 | * Render upload history
827 | */
828 | renderUploadHistory(uploads) {
829 | const historyContainer = document.getElementById('uploadHistory');
830 | if (!historyContainer) return;
831 |
832 | if (uploads.length === 0) {
833 | historyContainer.innerHTML = '<p style="text-align: center; color: var(--neutral-500);">No uploads yet. Start by uploading some documents!</p>';
834 | return;
835 | }
836 |
837 | const historyHtml = uploads.map(upload => {
838 | const statusClass = upload.status.toLowerCase();
839 | const statusText = upload.status.charAt(0).toUpperCase() + upload.status.slice(1);
840 | const progressPercent = upload.progress || 0;
841 | const hasMemories = upload.chunks_stored > 0;
842 |
843 | return `
844 | <div class="upload-item ${statusClass}" data-upload-id="${upload.upload_id}" data-filename="${this.escapeHtml(upload.filename)}">
845 | <div class="upload-info">
846 | <div class="upload-filename">${this.escapeHtml(upload.filename)}</div>
847 | <div class="upload-meta">
848 | ${upload.chunks_stored || 0} chunks stored •
849 | ${(upload.file_size / 1024).toFixed(1)} KB •
850 | ${new Date(upload.created_at).toLocaleString()}
851 | </div>
852 | ${upload.status === 'processing' ? `
853 | <div class="progress-bar">
854 | <div class="progress-fill" style="width: ${progressPercent}%"></div>
855 | </div>
856 | ` : ''}
857 | </div>
858 | <div class="upload-actions-container">
859 | <div class="upload-status ${statusClass}">
860 | <span>${statusText}</span>
861 | ${upload.errors && upload.errors.length > 0 ? `
862 | <span title="${this.escapeHtml(upload.errors.join('; '))}">⚠️</span>
863 | ` : ''}
864 | </div>
865 | ${upload.status === 'completed' && hasMemories ? `
866 | <div class="upload-actions">
867 | <button class="btn-icon btn-view-memory"
868 | title="View memory chunks">
869 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
870 | <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"/>
871 | </svg>
872 | <span>View</span>
873 | </button>
874 | <button class="btn-icon btn-remove"
875 | title="Remove document and memories">
876 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
877 | <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
878 | </svg>
879 | <span>Remove</span>
880 | </button>
881 | </div>
882 | ` : ''}
883 | </div>
884 | </div>
885 | `;
886 | }).join('');
887 |
888 | historyContainer.innerHTML = historyHtml;
889 | }
890 |
891 | /**
892 | * Setup event listeners for documents view
893 | */
894 | setupDocumentsEventListeners() {
895 | // Prevent duplicate event listener setup
896 | if (this.documentsListenersSetup) {
897 | console.log('Document listeners already set up, skipping...');
898 | return;
899 | }
900 |
901 | // File selection buttons
902 | const fileSelectBtn = document.getElementById('fileSelectBtn');
903 | const fileInput = document.getElementById('fileInput');
904 |
905 | if (fileSelectBtn && fileInput) {
906 | fileSelectBtn.addEventListener('click', () => {
907 | fileInput.click();
908 | });
909 |
910 | fileInput.addEventListener('change', (e) => {
911 | this.handleFileSelection(e.target.files);
912 | });
913 | }
914 |
915 | // Drag and drop
916 | const dropZone = document.getElementById('dropZone');
917 | if (dropZone) {
918 | dropZone.addEventListener('dragover', (e) => {
919 | e.preventDefault();
920 | dropZone.classList.add('drag-over');
921 | });
922 |
923 | dropZone.addEventListener('dragleave', (e) => {
924 | e.preventDefault();
925 | dropZone.classList.remove('drag-over');
926 | });
927 |
928 | dropZone.addEventListener('drop', (e) => {
929 | e.preventDefault();
930 | dropZone.classList.remove('drag-over');
931 | const files = e.dataTransfer.files;
932 | this.handleFileSelection(files);
933 | });
934 | }
935 |
936 | // Configuration controls - cache for performance
937 | this.chunkSizeInput = document.getElementById('chunkSize');
938 | this.chunkOverlapInput = document.getElementById('chunkOverlap');
939 | this.memoryTypeInput = document.getElementById('memoryType');
940 | const chunkSizeValue = document.getElementById('chunkSizeValue');
941 | const chunkOverlapValue = document.getElementById('chunkOverlapValue');
942 |
943 | if (this.chunkSizeInput && chunkSizeValue) {
944 | this.chunkSizeInput.addEventListener('input', (e) => {
945 | chunkSizeValue.textContent = e.target.value;
946 | this.updateUploadButton();
947 | });
948 | }
949 |
950 | // Chunking help info icon
951 | const infoIcon = document.querySelector('.info-icon');
952 | if (infoIcon) {
953 | infoIcon.addEventListener('click', () => {
954 | this.toggleChunkingHelp();
955 | });
956 | }
957 |
958 | // Overlap help info icon
959 | const overlapInfoIcon = document.querySelector('.info-icon-overlap');
960 | if (overlapInfoIcon) {
961 | overlapInfoIcon.addEventListener('click', () => {
962 | this.toggleOverlapHelp();
963 | });
964 | }
965 |
966 | // Processing mode help info icon
967 | const processingModeInfoIcon = document.querySelector('.info-icon-processing');
968 | if (processingModeInfoIcon) {
969 | processingModeInfoIcon.addEventListener('click', () => {
970 | this.toggleProcessingModeHelp();
971 | });
972 | }
973 |
974 | if (this.chunkOverlapInput && chunkOverlapValue) {
975 | this.chunkOverlapInput.addEventListener('input', (e) => {
976 | chunkOverlapValue.textContent = e.target.value;
977 | this.updateUploadButton();
978 | });
979 | }
980 |
981 | // Processing mode toggle buttons - cache for performance
982 | this.batchModeBtn = document.getElementById('batchModeBtn');
983 | this.individualModeBtn = document.getElementById('individualModeBtn');
984 | this.modeDescription = document.getElementById('modeDescription');
985 |
986 | if (this.batchModeBtn) {
987 | this.batchModeBtn.addEventListener('click', () => {
988 | this.setProcessingMode('batch');
989 | });
990 | }
991 |
992 | if (this.individualModeBtn) {
993 | this.individualModeBtn.addEventListener('click', () => {
994 | this.setProcessingMode('individual');
995 | });
996 | }
997 |
998 | // Upload button
999 | const uploadBtn = document.getElementById('uploadBtn');
1000 | if (uploadBtn) {
1001 | uploadBtn.addEventListener('click', () => {
1002 | this.handleDocumentUpload();
1003 | });
1004 | }
1005 |
1006 | // Add event listeners for buttons with data-action attribute
1007 | document.querySelectorAll('[data-action]').forEach(button => {
1008 | button.addEventListener('click', (e) => {
1009 | const action = e.currentTarget.dataset.action;
1010 | if (this[action] && typeof this[action] === 'function') {
1011 | this[action]();
1012 | }
1013 | });
1014 | });
1015 |
1016 | // Sync buttons event listeners are attached in checkSyncStatus()
1017 | // after buttons are confirmed to be accessible in the DOM
1018 |
1019 | // Backup now button
1020 | const backupNowButton = document.getElementById('backupNowButton');
1021 | if (backupNowButton) {
1022 | backupNowButton.addEventListener('click', () => {
1023 | this.createBackup();
1024 | });
1025 | }
1026 |
1027 | // Document search button
1028 | const docSearchBtn = document.getElementById('docSearchBtn');
1029 | const docSearchInput = document.getElementById('docSearchInput');
1030 | if (docSearchBtn && docSearchInput) {
1031 | docSearchBtn.addEventListener('click', () => {
1032 | const query = docSearchInput.value.trim();
1033 | if (query) {
1034 | this.searchDocumentContent(query);
1035 | } else {
1036 | this.showToast(this.t('toast.enterSearch', 'Please enter a search query'), 'warning');
1037 | }
1038 | });
1039 |
1040 | // Enter key to search
1041 | docSearchInput.addEventListener('keypress', (e) => {
1042 | if (e.key === 'Enter') {
1043 | const query = docSearchInput.value.trim();
1044 | if (query) {
1045 | this.searchDocumentContent(query);
1046 | }
1047 | }
1048 | });
1049 | }
1050 |
1051 | // Upload history action buttons (event delegation)
1052 | const uploadHistory = document.getElementById('uploadHistory');
1053 | if (uploadHistory) {
1054 | uploadHistory.addEventListener('click', (e) => {
1055 | const button = e.target.closest('.btn-view-memory, .btn-remove');
1056 | if (!button) return;
1057 |
1058 | const uploadItem = button.closest('.upload-item');
1059 | const uploadId = uploadItem?.dataset.uploadId;
1060 | const filename = uploadItem?.dataset.filename;
1061 |
1062 | if (!uploadId) return;
1063 |
1064 | if (button.classList.contains('btn-view-memory')) {
1065 | this.viewDocumentMemory(uploadId);
1066 | } else if (button.classList.contains('btn-remove')) {
1067 | this.removeDocument(uploadId, filename);
1068 | }
1069 | });
1070 | }
1071 |
1072 | // Close modal when clicking outside
1073 | const memoryViewerModal = document.getElementById('memoryViewerModal');
1074 | if (memoryViewerModal) {
1075 | memoryViewerModal.addEventListener('click', (e) => {
1076 | if (e.target === memoryViewerModal) {
1077 | this.closeMemoryViewer();
1078 | }
1079 | });
1080 | }
1081 |
1082 | // Mark listeners as set up to prevent duplicates
1083 | this.documentsListenersSetup = true;
1084 | console.log('Document listeners setup complete');
1085 | }
1086 |
1087 | /**
1088 | * Handle file selection from input or drag-drop
1089 | */
1090 | handleFileSelection(files) {
1091 | if (!files || files.length === 0) return;
1092 |
1093 | this.selectedFiles = Array.from(files);
1094 | this.updateUploadButton();
1095 |
1096 | // Show file preview in drop zone
1097 | const dropZone = document.getElementById('dropZone');
1098 | if (dropZone) {
1099 | const fileNames = this.selectedFiles.map(f => this.escapeHtml(f.name)).join(', ');
1100 | const content = dropZone.querySelector('.drop-zone-content');
1101 | if (content) {
1102 | content.innerHTML = `
1103 | <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24">
1104 | <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
1105 | </svg>
1106 | <h3>${files.length} file${files.length > 1 ? 's' : ''} selected</h3>
1107 | <p>${fileNames}</p>
1108 | <input type="file" id="fileInput" multiple accept=".pdf,.docx,.pptx,.txt,.md,.json" style="display: none;">
1109 | `;
1110 | }
1111 | }
1112 | }
1113 |
1114 | /**
1115 | * Update upload button state based on selections
1116 | */
1117 | updateUploadButton() {
1118 | const uploadBtn = document.getElementById('uploadBtn');
1119 | const hasFiles = this.selectedFiles && this.selectedFiles.length > 0;
1120 |
1121 | if (uploadBtn) {
1122 | uploadBtn.disabled = !hasFiles;
1123 | uploadBtn.textContent = hasFiles ?
1124 | `Upload & Ingest ${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''}` :
1125 | 'Upload & Ingest';
1126 | }
1127 |
1128 | // Show/hide processing mode section based on file count
1129 | const processingModeSection = document.getElementById('processingModeSection');
1130 | if (processingModeSection) {
1131 | processingModeSection.style.display = (hasFiles && this.selectedFiles.length > 1) ? 'block' : 'none';
1132 | }
1133 | }
1134 |
1135 | /**
1136 | * Set processing mode (batch or individual)
1137 | */
1138 | setProcessingMode(mode) {
1139 | this.processingMode = mode;
1140 |
1141 | // Update button states (using cached DOM elements)
1142 | if (this.batchModeBtn) {
1143 | this.batchModeBtn.classList.toggle('active', mode === 'batch');
1144 | }
1145 | if (this.individualModeBtn) {
1146 | this.individualModeBtn.classList.toggle('active', mode === 'individual');
1147 | }
1148 | if (this.modeDescription) {
1149 | this.modeDescription.innerHTML = mode === 'batch'
1150 | ? '<small>All selected files will be processed together with the same tags.</small>'
1151 | : '<small>Each file will be processed individually with the same tags.</small>';
1152 | }
1153 |
1154 | console.log(`Processing mode set to: ${mode}`);
1155 | }
1156 |
1157 | /**
1158 | * Handle document upload
1159 | */
1160 | async handleDocumentUpload() {
1161 | if (!this.selectedFiles || this.selectedFiles.length === 0) {
1162 | this.showToast(this.t('toast.noFiles', 'No files selected'), 'error');
1163 | return;
1164 | }
1165 |
1166 | const tags = document.getElementById('docTags')?.value || '';
1167 | const chunkSize = this.chunkSizeInput?.value || 1000;
1168 | const chunkOverlap = this.chunkOverlapInput?.value || 200;
1169 | const memoryType = this.memoryTypeInput?.value || 'document';
1170 |
1171 | try {
1172 | this.setLoading(true);
1173 |
1174 | if (this.selectedFiles.length === 1 || this.processingMode === 'individual') {
1175 | // Individual file processing (single file or individual mode for multiple files)
1176 | for (let i = 0; i < this.selectedFiles.length; i++) {
1177 | const file = this.selectedFiles[i];
1178 | try {
1179 | await this.uploadSingleDocument(file, {
1180 | tags,
1181 | chunk_size: parseInt(chunkSize),
1182 | chunk_overlap: parseInt(chunkOverlap),
1183 | memory_type: memoryType
1184 | });
1185 |
1186 | // Small delay between individual uploads to avoid overwhelming the server
1187 | if (i < this.selectedFiles.length - 1) {
1188 | await new Promise(resolve => setTimeout(resolve, this.constructor.INDIVIDUAL_UPLOAD_DELAY));
1189 | }
1190 | } catch (error) {
1191 | console.error(`Failed to upload ${file.name}:`, error);
1192 | this.showToast(
1193 | this.t('toast.uploadFileFail', 'Failed to upload {name}: {message}', {
1194 | name: file.name,
1195 | message: error.message
1196 | }),
1197 | 'error'
1198 | );
1199 | // Continue with remaining files
1200 | }
1201 | }
1202 | } else {
1203 | // Batch upload
1204 | await this.uploadBatchDocuments(this.selectedFiles, {
1205 | tags,
1206 | chunk_size: parseInt(chunkSize),
1207 | chunk_overlap: parseInt(chunkOverlap),
1208 | memory_type: memoryType
1209 | });
1210 | }
1211 |
1212 | // Clear selection and reload history
1213 | this.selectedFiles = [];
1214 | this.updateUploadButton();
1215 | await this.loadUploadHistory();
1216 |
1217 | // Reset drop zone
1218 | const dropZone = document.getElementById('dropZone');
1219 | if (dropZone) {
1220 | const content = dropZone.querySelector('.drop-zone-content');
1221 | if (content) {
1222 | content.innerHTML = `
1223 | <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24">
1224 | <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
1225 | </svg>
1226 | <h3>Drag & drop files here</h3>
1227 | <p>or <button id="fileSelectBtn" class="link-button">browse to select files</button></p>
1228 | <p class="supported-formats">Supported formats: PDF, DOCX, PPTX, TXT, MD, JSON</p>
1229 | <input type="file" id="fileInput" multiple accept=".pdf,.docx,.pptx,.txt,.md,.json" style="display: none;">
1230 | `;
1231 | }
1232 | // Re-setup event listeners for the new elements
1233 | this.setupDocumentsEventListeners();
1234 | }
1235 |
1236 | } catch (error) {
1237 | console.error('Upload error:', error);
1238 | this.showToast(this.t('toast.uploadFailed', 'Upload failed: {message}', { message: error.message }), 'error');
1239 | } finally {
1240 | this.setLoading(false);
1241 | }
1242 | }
1243 |
1244 | /**
1245 | * Upload single document
1246 | */
1247 | async uploadSingleDocument(file, config) {
1248 | console.log(`Uploading file: ${file.name}, size: ${file.size} bytes`);
1249 | const formData = new FormData();
1250 | formData.append('file', file);
1251 | formData.append('tags', config.tags);
1252 | formData.append('chunk_size', config.chunk_size.toString());
1253 | formData.append('chunk_overlap', config.chunk_overlap.toString());
1254 | formData.append('memory_type', config.memory_type);
1255 |
1256 | const response = await fetch(`${this.apiBase}/documents/upload`, {
1257 | method: 'POST',
1258 | body: formData
1259 | });
1260 |
1261 | console.log(`Upload response status: ${response.status}`);
1262 |
1263 | if (!response.ok) {
1264 | let errorMessage = `Upload failed with status ${response.status}`;
1265 | try {
1266 | const error = await response.json();
1267 | console.error('Upload error details:', error);
1268 | errorMessage = error.detail || error.message || errorMessage;
1269 | } catch (e) {
1270 | console.error('Could not parse error response:', e);
1271 | try {
1272 | const errorText = await response.text();
1273 | console.error('Error response text:', errorText);
1274 | errorMessage = errorText || errorMessage;
1275 | } catch (e2) {
1276 | console.error('Could not read error response:', e2);
1277 | }
1278 | }
1279 | throw new Error(errorMessage);
1280 | }
1281 |
1282 | const result = await response.json();
1283 | console.log('Upload result:', result);
1284 | this.showToast(this.t('toast.uploadStartedSingle', 'Upload started for {name}', { name: file.name }), 'success');
1285 |
1286 | // Monitor progress if we have an upload ID
1287 | if (result.upload_id) {
1288 | this.monitorUploadProgress(result.upload_id);
1289 | }
1290 |
1291 | return result;
1292 | }
1293 |
1294 | /**
1295 | * Upload batch documents
1296 | */
1297 | async uploadBatchDocuments(files, config) {
1298 | const formData = new FormData();
1299 | files.forEach(file => {
1300 | formData.append('files', file);
1301 | });
1302 | formData.append('tags', config.tags);
1303 | formData.append('chunk_size', config.chunk_size.toString());
1304 | formData.append('chunk_overlap', config.chunk_overlap.toString());
1305 | formData.append('memory_type', config.memory_type);
1306 |
1307 | const response = await fetch(`${this.apiBase}/documents/batch-upload`, {
1308 | method: 'POST',
1309 | body: formData
1310 | });
1311 |
1312 | if (!response.ok) {
1313 | const error = await response.json();
1314 | throw new Error(error.detail || 'Batch upload failed');
1315 | }
1316 |
1317 | const result = await response.json();
1318 | this.showToast(
1319 | this.t('toast.uploadStartedBatch', 'Batch upload started for {count} files', { count: files.length }),
1320 | 'success'
1321 | );
1322 |
1323 | // Monitor progress if we have an upload ID
1324 | if (result.upload_id) {
1325 | this.monitorUploadProgress(result.upload_id);
1326 | }
1327 |
1328 | return result;
1329 | }
1330 |
1331 | /**
1332 | * Monitor upload progress by polling status endpoint
1333 | */
1334 | monitorUploadProgress(uploadId) {
1335 | const pollStatus = async () => {
1336 | try {
1337 | const statusResponse = await this.apiCall(`/documents/status/${uploadId}`);
1338 | this.updateUploadProgress(uploadId, statusResponse);
1339 |
1340 | if (statusResponse.progress >= 100 || statusResponse.status === 'completed' || statusResponse.status === 'failed') {
1341 | // Upload completed, refresh history
1342 | this.loadUploadHistory();
1343 | } else {
1344 | // Continue polling
1345 | setTimeout(pollStatus, 2000); // Poll every 2 seconds
1346 | }
1347 | } catch (error) {
1348 | // If polling fails, try again with longer interval
1349 | setTimeout(pollStatus, 5000);
1350 | }
1351 | };
1352 |
1353 | // Start polling after a short delay
1354 | setTimeout(pollStatus, 1000);
1355 | }
1356 |
1357 | /**
1358 | * Update upload progress display
1359 | */
1360 | updateUploadProgress(uploadId, statusData) {
1361 | // Find the upload item in history and update it
1362 | const historyContainer = document.getElementById('uploadHistory');
1363 | if (!historyContainer) return;
1364 |
1365 | const uploadItems = historyContainer.querySelectorAll('.upload-item');
1366 | uploadItems.forEach(item => {
1367 | const filename = item.querySelector('.upload-filename');
1368 | if (filename && filename.textContent.includes(uploadId)) {
1369 | // This is a simplified update - in practice you'd match by upload ID
1370 | this.loadUploadHistory(); // For now, just refresh the entire history
1371 | }
1372 | });
1373 | }
1374 |
1375 | /**
1376 | * Check hybrid backend sync status
1377 | */
1378 | async checkSyncStatus() {
1379 | // Skip UI updates during force sync to prevent periodic polling from overwriting the syncing state
1380 | if (this._isForceSyncing) {
1381 | return;
1382 | }
1383 |
1384 | try {
1385 | const syncStatus = await this.apiCall('/sync/status');
1386 |
1387 | // Get compact sync control element
1388 | const syncControl = document.getElementById('syncControl');
1389 | if (!syncControl) {
1390 | console.warn('Sync control element not found');
1391 | return;
1392 | }
1393 |
1394 | if (!syncStatus.is_hybrid) {
1395 | syncControl.style.display = 'none';
1396 | return;
1397 | }
1398 |
1399 | // Show sync control for hybrid mode
1400 | syncControl.style.display = 'block';
1401 |
1402 | // Update sync status UI elements
1403 | const statusText = document.getElementById('syncStatusText');
1404 | const syncProgress = document.getElementById('syncProgress');
1405 | const pauseButton = document.getElementById('pauseSyncButton');
1406 | const resumeButton = document.getElementById('resumeSyncButton');
1407 | const syncButton = document.getElementById('forceSyncButton');
1408 |
1409 | // Update pause/resume button visibility based on running state
1410 | const isPaused = syncStatus.is_paused || !syncStatus.is_running;
1411 |
1412 | // Attach event listeners if not already attached
1413 | if (pauseButton && !pauseButton._listenerAttached) {
1414 | pauseButton.addEventListener('click', () => {
1415 | this.pauseSync();
1416 | });
1417 | pauseButton._listenerAttached = true;
1418 | }
1419 | if (resumeButton && !resumeButton._listenerAttached) {
1420 | resumeButton.addEventListener('click', () => {
1421 | this.resumeSync();
1422 | });
1423 | resumeButton._listenerAttached = true;
1424 | }
1425 | if (syncButton && !syncButton._listenerAttached) {
1426 | syncButton.addEventListener('click', () => {
1427 | this.forceSync();
1428 | });
1429 | syncButton._listenerAttached = true;
1430 | }
1431 |
1432 | if (pauseButton) {
1433 | pauseButton.style.display = isPaused ? 'none' : 'flex';
1434 | }
1435 | if (resumeButton) {
1436 | resumeButton.style.display = isPaused ? 'flex' : 'none';
1437 | }
1438 |
1439 | // Determine status and update UI (dot color is handled by CSS classes)
1440 | if (isPaused) {
1441 | statusText.textContent = 'Paused';
1442 | syncProgress.textContent = '';
1443 | syncControl.className = 'sync-control-compact paused';
1444 | if (syncButton) syncButton.disabled = true;
1445 | } else if (syncStatus.status === 'syncing') {
1446 | statusText.textContent = 'Syncing';
1447 | syncProgress.textContent = syncStatus.operations_pending > 0 ? `${syncStatus.operations_pending} pending` : '';
1448 | syncControl.className = 'sync-control-compact syncing';
1449 | if (syncButton) syncButton.disabled = true;
1450 | } else if (syncStatus.status === 'pending') {
1451 | statusText.textContent = 'Pending';
1452 | syncProgress.textContent = `${syncStatus.operations_pending} ops`;
1453 | syncControl.className = 'sync-control-compact pending';
1454 | if (syncButton) syncButton.disabled = false;
1455 | } else if (syncStatus.status === 'error') {
1456 | statusText.textContent = 'Error';
1457 | syncProgress.textContent = `${syncStatus.operations_failed} failed`;
1458 | syncControl.className = 'sync-control-compact error';
1459 | if (syncButton) syncButton.disabled = false;
1460 | } else {
1461 | // synced status
1462 | statusText.textContent = 'Synced';
1463 | syncProgress.textContent = '';
1464 | syncControl.className = 'sync-control-compact synced';
1465 | if (syncButton) syncButton.disabled = false;
1466 | }
1467 |
1468 | } catch (error) {
1469 | console.error('Error checking sync status:', error);
1470 | // Hide sync control on error (likely not hybrid mode)
1471 | const syncControl = document.getElementById('syncControl');
1472 | if (syncControl) syncControl.style.display = 'none';
1473 | }
1474 | }
1475 |
1476 | /**
1477 | * Format time delta in human readable format
1478 | */
1479 | formatTimeDelta(seconds) {
1480 | if (seconds < 60) return `${seconds}s ago`;
1481 | if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1482 | if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1483 | return `${Math.floor(seconds / 86400)}d ago`;
1484 | }
1485 |
1486 | /**
1487 | * Pause background sync
1488 | */
1489 | async pauseSync() {
1490 | try {
1491 | const result = await this.apiCall('/sync/pause', 'POST');
1492 | if (result.success) {
1493 | this.showToast(this.t('toast.syncPaused', 'Sync paused'), 'success');
1494 |
1495 | // Update UI immediately using API response data
1496 | const pauseButton = document.getElementById('pauseSyncButton');
1497 | const resumeButton = document.getElementById('resumeSyncButton');
1498 | const statusText = document.getElementById('syncStatusText');
1499 | const syncControl = document.getElementById('syncControl');
1500 |
1501 | if (pauseButton) pauseButton.style.display = 'none';
1502 | if (resumeButton) resumeButton.style.display = 'flex';
1503 | if (statusText) statusText.textContent = 'Paused';
1504 | if (syncControl) syncControl.className = 'sync-control-compact paused';
1505 |
1506 | // Small delay to allow backend state to propagate before checking status
1507 | await new Promise(resolve => setTimeout(resolve, 200));
1508 | } else {
1509 | this.showToast(
1510 | this.t('toast.syncPauseFailWithReason', 'Failed to pause sync: {reason}', { reason: result.message }),
1511 | 'error'
1512 | );
1513 | }
1514 | await this.checkSyncStatus();
1515 | } catch (error) {
1516 | console.error('Error pausing sync:', error);
1517 | this.showToast(this.t('toast.syncPauseFail', 'Failed to pause sync'), 'error');
1518 | }
1519 | }
1520 |
1521 | /**
1522 | * Resume background sync
1523 | */
1524 | async resumeSync() {
1525 | try {
1526 | const result = await this.apiCall('/sync/resume', 'POST');
1527 | if (result.success) {
1528 | this.showToast(this.t('toast.syncResumed', 'Sync resumed'), 'success');
1529 |
1530 | // Update UI immediately using API response data
1531 | const pauseButton = document.getElementById('pauseSyncButton');
1532 | const resumeButton = document.getElementById('resumeSyncButton');
1533 | const statusText = document.getElementById('syncStatusText');
1534 | const syncControl = document.getElementById('syncControl');
1535 |
1536 | if (pauseButton) pauseButton.style.display = 'flex';
1537 | if (resumeButton) resumeButton.style.display = 'none';
1538 | if (statusText) statusText.textContent = 'Synced';
1539 | if (syncControl) syncControl.className = 'sync-control-compact synced';
1540 |
1541 | // Small delay to allow backend state to propagate before checking status
1542 | await new Promise(resolve => setTimeout(resolve, 200));
1543 | } else {
1544 | this.showToast(
1545 | this.t('toast.syncResumeFailWithReason', 'Failed to resume sync: {reason}', { reason: result.message }),
1546 | 'error'
1547 | );
1548 | }
1549 | await this.checkSyncStatus();
1550 | } catch (error) {
1551 | console.error('Error resuming sync:', error);
1552 | this.showToast(this.t('toast.syncResumeFail', 'Failed to resume sync'), 'error');
1553 | }
1554 | }
1555 |
1556 | /**
1557 | * Check backup status and update Settings modal
1558 | */
1559 | async checkBackupStatus() {
1560 | try {
1561 | const backupStatus = await this.apiCall('/backup/status');
1562 |
1563 | // Update backup elements in Settings modal
1564 | const lastBackup = document.getElementById('settingsLastBackup');
1565 | const backupCount = document.getElementById('settingsBackupCount');
1566 | const nextBackup = document.getElementById('settingsNextBackup');
1567 |
1568 | if (!backupStatus.enabled) {
1569 | if (lastBackup) lastBackup.textContent = 'Backups disabled';
1570 | if (backupCount) backupCount.textContent = '-';
1571 | if (nextBackup) nextBackup.textContent = '-';
1572 | return;
1573 | }
1574 |
1575 | // Update last backup time
1576 | if (lastBackup) {
1577 | if (backupStatus.time_since_last_seconds) {
1578 | lastBackup.textContent = this.formatTimeDelta(Math.floor(backupStatus.time_since_last_seconds)) + ' ago';
1579 | } else {
1580 | lastBackup.textContent = 'Never';
1581 | }
1582 | }
1583 |
1584 | // Update backup count with size
1585 | if (backupCount) {
1586 | const sizeMB = (backupStatus.total_size_bytes / 1024 / 1024).toFixed(1);
1587 | backupCount.textContent = `${backupStatus.backup_count} (${sizeMB} MB)`;
1588 | }
1589 |
1590 | // Update next scheduled backup
1591 | if (nextBackup && backupStatus.next_backup_at) {
1592 | const nextDate = new Date(backupStatus.next_backup_at);
1593 | nextBackup.textContent = nextDate.toLocaleString();
1594 | } else if (nextBackup) {
1595 | nextBackup.textContent = backupStatus.scheduler_running ? 'Scheduled' : 'Not scheduled';
1596 | }
1597 |
1598 | } catch (error) {
1599 | console.error('Error checking backup status:', error);
1600 | }
1601 | }
1602 |
1603 | /**
1604 | * Create a backup manually
1605 | */
1606 | async createBackup() {
1607 | const backupButton = document.getElementById('backupNowButton');
1608 | if (backupButton) backupButton.disabled = true;
1609 |
1610 | try {
1611 | this.showToast(this.t('toast.backupCreating', 'Creating backup...'), 'info');
1612 | const result = await this.apiCall('/backup/now', 'POST');
1613 |
1614 | if (result.success) {
1615 | const sizeMB = (result.size_bytes / 1024 / 1024).toFixed(2);
1616 | this.showToast(
1617 | this.t('toast.backupCreated', 'Backup created: {name} ({size} MB)', {
1618 | name: result.filename,
1619 | size: sizeMB
1620 | }),
1621 | 'success'
1622 | );
1623 | } else {
1624 | this.showToast(
1625 | this.t('toast.backupFailedWithReason', 'Backup failed: {reason}', { reason: result.error }),
1626 | 'error'
1627 | );
1628 | }
1629 |
1630 | await this.checkBackupStatus();
1631 |
1632 | } catch (error) {
1633 | console.error('Error creating backup:', error);
1634 | this.showToast(this.t('toast.backupFailed', 'Failed to create backup'), 'error');
1635 | } finally {
1636 | if (backupButton) backupButton.disabled = false;
1637 | }
1638 | }
1639 |
1640 | /**
1641 | * Start periodic sync status monitoring
1642 | */
1643 | startSyncStatusMonitoring() {
1644 | // Check sync status every 10 seconds
1645 | setInterval(() => {
1646 | this.checkSyncStatus();
1647 | }, 10000);
1648 | }
1649 |
1650 | /**
1651 | * Manually force sync to Cloudflare
1652 | */
1653 | async forceSync() {
1654 | const syncButton = document.getElementById('forceSyncButton');
1655 | const originalText = syncButton.innerHTML;
1656 |
1657 | try {
1658 | // Check if sync was paused before force sync
1659 | const statusBefore = await this.apiCall('/sync/status');
1660 | const wasPaused = statusBefore.is_paused;
1661 |
1662 | // Set flag to prevent periodic polling from overwriting UI during force sync
1663 | this._isForceSyncing = true;
1664 |
1665 | // Disable button and show loading state (just egg timer, no text - widget shows "Syncing")
1666 | syncButton.disabled = true;
1667 | syncButton.innerHTML = '<span class="sync-button-icon">⏳</span>';
1668 |
1669 | // IMMEDIATELY update sync control widget to show syncing state
1670 | const statusText = document.getElementById('syncStatusText');
1671 | const syncProgress = document.getElementById('syncProgress');
1672 | const syncControl = document.getElementById('syncControl');
1673 |
1674 | if (statusText) statusText.textContent = 'Syncing';
1675 | if (syncProgress) syncProgress.textContent = statusBefore.operations_pending > 0 ? `${statusBefore.operations_pending} pending` : '';
1676 | if (syncControl) syncControl.className = 'sync-control-compact syncing';
1677 |
1678 | // Show toast when sync starts
1679 | this.showToast(this.t('toast.syncStarting', 'Starting sync...'), 'info');
1680 |
1681 | const result = await this.apiCall('/sync/force', 'POST');
1682 |
1683 | if (result.success) {
1684 | this.showToast(
1685 | this.t('toast.syncCompleted', 'Synced {count} operations in {seconds}s', {
1686 | count: result.operations_synced,
1687 | seconds: result.time_taken_seconds
1688 | }),
1689 | 'success'
1690 | );
1691 |
1692 | // Refresh dashboard data to show newly synced memories
1693 | if (this.currentView === 'dashboard') {
1694 | await this.loadDashboardData();
1695 | }
1696 |
1697 | // If sync was paused before, pause it again after force sync
1698 | if (wasPaused) {
1699 | await this.apiCall('/sync/pause', 'POST');
1700 | }
1701 | } else {
1702 | this.showToast(
1703 | this.t('toast.syncFailedWithReason', 'Sync failed: {reason}', { reason: result.message }),
1704 | 'error'
1705 | );
1706 | }
1707 |
1708 | } catch (error) {
1709 | console.error('Error forcing sync:', error);
1710 | this.showToast(
1711 | this.t('toast.syncForceFailed', 'Failed to force sync: {reason}', { reason: error.message }),
1712 | 'error'
1713 | );
1714 | } finally {
1715 | // Clear flag to allow periodic polling to resume
1716 | this._isForceSyncing = false;
1717 |
1718 | // Re-enable button
1719 | syncButton.disabled = false;
1720 | syncButton.innerHTML = originalText;
1721 |
1722 | // Refresh sync status immediately
1723 | await this.checkSyncStatus();
1724 | }
1725 | }
1726 |
1727 | /**
1728 | * Render tags cloud from API data
1729 | */
1730 | renderTagsCloud() {
1731 | const container = document.getElementById('tagsCloudContainer');
1732 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1733 |
1734 | // Hide the tagged memories view initially
1735 | taggedContainer.style.display = 'none';
1736 |
1737 | if (!this.tags || this.tags.length === 0) {
1738 | container.innerHTML = '<p class="text-neutral-600">No tags found. Start adding tags to your memories to see them here.</p>';
1739 | return;
1740 | }
1741 |
1742 | // Render tag bubbles (tags are already sorted by count from backend)
1743 | container.innerHTML = this.tags.map(tagData => `
1744 | <button class="tag-bubble" data-tag="${this.escapeHtml(tagData.tag)}">
1745 | ${this.escapeHtml(tagData.tag)}
1746 | <span class="count">${tagData.count}</span>
1747 | </button>
1748 | `).join('');
1749 | }
1750 |
1751 | /**
1752 | * Filter memories by selected tag
1753 | */
1754 | async filterByTag(tag) {
1755 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1756 | const tagNameSpan = document.getElementById('selectedTagName');
1757 | const memoriesList = document.getElementById('taggedMemoriesList');
1758 |
1759 | try {
1760 | // Fetch memories for this specific tag
1761 | const memoriesResponse = await this.apiCall(`/memories?tag=${encodeURIComponent(tag)}&limit=100`);
1762 | const filteredMemories = memoriesResponse.memories || [];
1763 |
1764 | // Show the tagged memories section
1765 | tagNameSpan.textContent = tag;
1766 | taggedContainer.style.display = 'block';
1767 |
1768 | // Smooth scroll to results section for better UX
1769 | taggedContainer.scrollIntoView({
1770 | behavior: 'smooth',
1771 | block: 'start'
1772 | });
1773 |
1774 | // Render filtered memories
1775 | this.renderMemoriesInContainer(filteredMemories, memoriesList);
1776 |
1777 | // Add event listener for clear filter button
1778 | const clearBtn = document.getElementById('clearTagFilter');
1779 | clearBtn.onclick = () => this.clearTagFilter();
1780 | } catch (error) {
1781 | console.error('Error filtering by tag:', error);
1782 | this.showToast(this.t('toast.loadTagFail', 'Failed to load memories for tag'), 'error');
1783 | }
1784 | }
1785 |
1786 | /**
1787 | * Clear tag filter and show all tags
1788 | */
1789 | clearTagFilter() {
1790 | const taggedContainer = document.getElementById('taggedMemoriesContainer');
1791 | taggedContainer.style.display = 'none';
1792 | }
1793 |
1794 | /**
1795 | * Escape HTML to prevent XSS
1796 | */
1797 | escapeHtml(text) {
1798 | const div = document.createElement('div');
1799 | div.textContent = text;
1800 | return div.innerHTML;
1801 | }
1802 |
1803 | /**
1804 | * Get time-of-day emoji icon based on timestamp
1805 | * @param {number} timestamp - Unix timestamp in seconds
1806 | * @returns {string} HTML with emoji + tooltip
1807 | */
1808 | renderTimeIcon(timestamp) {
1809 | if (!timestamp) return '';
1810 |
1811 | const date = new Date(timestamp * 1000);
1812 | const hour = date.getHours(); // Local timezone
1813 | const segment = Math.floor(hour / 3); // 0-7
1814 |
1815 | const icons = ['🌙', '🌅', '☕', '💻', '🍽️', '⛅', '🍷', '🛏️'];
1816 | const labels = [
1817 | 'Late Night', // 00-03
1818 | 'Early Morning', // 03-06
1819 | 'Morning', // 06-09
1820 | 'Late Morning', // 09-12
1821 | 'Afternoon', // 12-15
1822 | 'Late Afternoon',// 15-18
1823 | 'Evening', // 18-21
1824 | 'Night' // 21-24
1825 | ];
1826 |
1827 | return `<span class="time-icon" title="${labels[segment]}">${icons[segment]}</span>`;
1828 | }
1829 |
1830 | /**
1831 | * Toggle chunking help section visibility
1832 | */
1833 | toggleChunkingHelp() {
1834 | const helpSection = document.getElementById('chunkingHelpSection');
1835 | if (helpSection) {
1836 | if (helpSection.style.display === 'none') {
1837 | helpSection.style.display = 'block';
1838 | } else {
1839 | helpSection.style.display = 'none';
1840 | }
1841 | }
1842 | }
1843 |
1844 | /**
1845 | * Hide chunking help section
1846 | */
1847 | hideChunkingHelp() {
1848 | const helpSection = document.getElementById('chunkingHelpSection');
1849 | if (helpSection) {
1850 | helpSection.style.display = 'none';
1851 | }
1852 | }
1853 |
1854 | /**
1855 | * Toggle overlap help section visibility
1856 | */
1857 | toggleOverlapHelp() {
1858 | const helpSection = document.getElementById('overlapHelpSection');
1859 | if (helpSection) {
1860 | if (helpSection.style.display === 'none') {
1861 | helpSection.style.display = 'block';
1862 | } else {
1863 | helpSection.style.display = 'none';
1864 | }
1865 | }
1866 | }
1867 |
1868 | /**
1869 | * Hide overlap help section
1870 | */
1871 | hideOverlapHelp() {
1872 | const helpSection = document.getElementById('overlapHelpSection');
1873 | if (helpSection) {
1874 | helpSection.style.display = 'none';
1875 | }
1876 | }
1877 |
1878 | /**
1879 | * Toggle processing mode help section visibility
1880 | */
1881 | toggleProcessingModeHelp() {
1882 | const helpSection = document.getElementById('processingModeHelpSection');
1883 | if (helpSection) {
1884 | helpSection.style.display = helpSection.style.display === 'block' ? 'none' : 'block';
1885 | }
1886 | }
1887 |
1888 | /**
1889 | * Hide processing mode help section
1890 | */
1891 | hideProcessingModeHelp() {
1892 | const helpSection = document.getElementById('processingModeHelpSection');
1893 | if (helpSection) {
1894 | helpSection.style.display = 'none';
1895 | }
1896 | }
1897 |
1898 | /**
1899 | * Render memories in a specific container
1900 | */
1901 | renderMemoriesInContainer(memories, container) {
1902 | if (!memories || memories.length === 0) {
1903 | container.innerHTML = '<p class="empty-state">No memories found with this tag.</p>';
1904 | return;
1905 | }
1906 |
1907 | container.innerHTML = memories.map(memory => this.renderMemoryCard(memory)).join('');
1908 |
1909 | // Add click handlers
1910 | container.querySelectorAll('.memory-card').forEach((card, index) => {
1911 | card.addEventListener('click', () => this.handleMemoryClick(memories[index]));
1912 | });
1913 | }
1914 |
1915 | /**
1916 | * Handle navigation between views
1917 | */
1918 | handleNavigation(e) {
1919 | const viewName = e.currentTarget.dataset.view;
1920 | this.switchView(viewName);
1921 | }
1922 |
1923 | /**
1924 | * Switch between different views
1925 | */
1926 | switchView(viewName) {
1927 | // Update navigation active state (if navigation exists)
1928 | document.querySelectorAll('.nav-item').forEach(item => {
1929 | item.classList.remove('active');
1930 | });
1931 | const navItem = document.querySelector(`[data-view="${viewName}"]`);
1932 | if (navItem) {
1933 | navItem.classList.add('active');
1934 | }
1935 |
1936 | // Hide all views (if view containers exist)
1937 | document.querySelectorAll('.view-container').forEach(view => {
1938 | view.classList.remove('active');
1939 | });
1940 |
1941 | // Show target view (if it exists)
1942 | const targetView = document.getElementById(`${viewName}View`);
1943 | if (targetView) {
1944 | targetView.classList.add('active');
1945 | this.currentView = viewName;
1946 |
1947 | // Load view-specific data
1948 | this.loadViewData(viewName);
1949 | }
1950 | }
1951 |
1952 | /**
1953 | * Load data specific to the current view
1954 | */
1955 | async loadViewData(viewName) {
1956 | switch (viewName) {
1957 | case 'search':
1958 | // Initialize search view with recent search or empty state
1959 | break;
1960 | case 'browse':
1961 | await this.loadBrowseData();
1962 | break;
1963 | case 'documents':
1964 | await this.loadDocumentsData();
1965 | break;
1966 | case 'manage':
1967 | await this.loadManageData();
1968 | break;
1969 | case 'analytics':
1970 | await this.loadAnalyticsData();
1971 | break;
1972 | case 'qualityAnalytics':
1973 | await this.loadQualityAnalytics();
1974 | break;
1975 | case 'apiDocs':
1976 | // API docs view - static content, no additional loading needed
1977 | break;
1978 | default:
1979 | // Dashboard view is loaded in loadDashboardData
1980 | break;
1981 | }
1982 | }
1983 |
1984 | /**
1985 | * Handle quick search input
1986 | */
1987 | async handleQuickSearch(e) {
1988 | const query = e.target.value.trim();
1989 | if (query.length >= 2) {
1990 | try {
1991 | const results = await this.searchMemories(query);
1992 | // Could show dropdown suggestions here
1993 | } catch (error) {
1994 | console.error('Quick search error:', error);
1995 | }
1996 | }
1997 | }
1998 |
1999 | /**
2000 | * Handle full search
2001 | */
2002 | async handleSearch(query) {
2003 | if (!query.trim()) return;
2004 |
2005 | this.switchView('search');
2006 | this.setLoading(true);
2007 |
2008 | try {
2009 | const results = await this.searchMemories(query);
2010 | this.searchResults = results;
2011 | this.renderSearchResults(results);
2012 | this.updateResultsCount(results.length);
2013 | } catch (error) {
2014 | console.error('Search error:', error);
2015 | this.showToast(this.t('toast.searchFailed', 'Search failed'), 'error');
2016 | } finally {
2017 | this.setLoading(false);
2018 | }
2019 | }
2020 |
2021 | /**
2022 | * Search memories using the API
2023 | */
2024 | async searchMemories(query, filters = {}) {
2025 | // Detect tag search patterns: #tag, tag:value, or "tag:value"
2026 | const tagPattern = /^(#|tag:)(.+)$/i;
2027 | const tagMatch = query.match(tagPattern);
2028 |
2029 | if (tagMatch) {
2030 | // Use tag search endpoint
2031 | const tagValue = tagMatch[2].trim();
2032 | const payload = {
2033 | tags: [tagValue],
2034 | match_all: false // ANY match by default
2035 | };
2036 |
2037 | const response = await this.apiCall('/search/by-tag', 'POST', payload);
2038 | return response.results || [];
2039 | } else {
2040 | // Use semantic search endpoint
2041 | const payload = {
2042 | query: query,
2043 | n_results: filters.limit || 20,
2044 | ...filters
2045 | };
2046 |
2047 | // Only add similarity_threshold if explicitly set in filters
2048 | if (filters.threshold !== undefined) {
2049 | payload.similarity_threshold = filters.threshold;
2050 | }
2051 |
2052 | const response = await this.apiCall('/search', 'POST', payload);
2053 | return response.results || [];
2054 | }
2055 | }
2056 |
2057 | /**
2058 | * Handle filter changes in search view
2059 | */
2060 | async handleFilterChange() {
2061 | const tagFilter = document.getElementById('tagFilter')?.value;
2062 | const dateFilter = document.getElementById('dateFilter')?.value;
2063 | const typeFilter = document.getElementById('typeFilter')?.value;
2064 | const query = document.getElementById('quickSearch')?.value?.trim() || '';
2065 |
2066 | // Add loading state
2067 | const applyBtn = document.getElementById('applyFiltersBtn');
2068 | if (applyBtn) {
2069 | applyBtn.classList.add('loading');
2070 | applyBtn.disabled = true;
2071 | }
2072 |
2073 | try {
2074 | let results = [];
2075 |
2076 | // Priority 1: If we have a semantic query, start with semantic search
2077 | if (query) {
2078 | const filters = {};
2079 | if (typeFilter) filters.type = typeFilter;
2080 | results = await this.searchMemories(query, filters);
2081 |
2082 | // Apply tag filtering to semantic search results if tags are specified
2083 | if (tagFilter && tagFilter.trim()) {
2084 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
2085 | if (tags.length > 0) {
2086 | results = results.filter(result => {
2087 | const memoryTags = result.memory.tags || [];
2088 | // Check if any of the specified tags match memory tags (case-insensitive)
2089 | return tags.some(filterTag =>
2090 | memoryTags.some(memoryTag =>
2091 | memoryTag.toLowerCase().includes(filterTag.toLowerCase())
2092 | )
2093 | );
2094 | });
2095 | }
2096 | }
2097 | }
2098 | // Priority 2: Tag-only search (when no semantic query)
2099 | else if (tagFilter && tagFilter.trim()) {
2100 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
2101 |
2102 | if (tags.length > 0) {
2103 | const payload = {
2104 | tags: tags,
2105 | match_all: false // ANY match by default
2106 | };
2107 |
2108 | const response = await this.apiCall('/search/by-tag', 'POST', payload);
2109 | results = response.results || [];
2110 |
2111 | // Apply type filter if present
2112 | if (typeFilter && typeFilter.trim()) {
2113 | results = results.filter(result => {
2114 | const memoryType = result.memory.memory_type || 'note';
2115 | return memoryType === typeFilter;
2116 | });
2117 | }
2118 | }
2119 | }
2120 | // Priority 3: Date-based search
2121 | else if (dateFilter && dateFilter.trim()) {
2122 | const payload = {
2123 | query: dateFilter,
2124 | n_results: 100
2125 | };
2126 | const response = await this.apiCall('/search/by-time', 'POST', payload);
2127 | results = response.results || [];
2128 |
2129 | // Apply type filter if present
2130 | if (typeFilter && typeFilter.trim()) {
2131 | results = results.filter(result => {
2132 | const memoryType = result.memory.memory_type || 'note';
2133 | return memoryType === typeFilter;
2134 | });
2135 | }
2136 | }
2137 | // Priority 4: Type-only filter
2138 | else if (typeFilter && typeFilter.trim()) {
2139 | const allMemoriesResponse = await this.apiCall('/memories?page=1&page_size=1000');
2140 | if (allMemoriesResponse.memories) {
2141 | results = allMemoriesResponse.memories
2142 | .filter(memory => (memory.memory_type || 'note') === typeFilter)
2143 | .map(memory => ({ memory, similarity: 1.0 }));
2144 | }
2145 | } else {
2146 | // No filters, clear results
2147 | results = [];
2148 | }
2149 |
2150 | this.searchResults = results;
2151 | this.renderSearchResults(results);
2152 | this.updateResultsCount(results.length);
2153 | this.updateActiveFilters();
2154 |
2155 | } catch (error) {
2156 | console.error('Filter search error:', error);
2157 | this.showToast(this.t('toast.filterSearchFailed', 'Filter search failed'), 'error');
2158 | } finally {
2159 | // Remove loading state
2160 | const applyBtn = document.getElementById('applyFiltersBtn');
2161 | if (applyBtn) {
2162 | applyBtn.classList.remove('loading');
2163 | applyBtn.disabled = false;
2164 | }
2165 | }
2166 | }
2167 |
2168 | /**
2169 | * Handle view mode changes (grid/list)
2170 | */
2171 | handleViewModeChange(mode) {
2172 | document.querySelectorAll('.view-btn').forEach(btn => {
2173 | btn.classList.remove('active');
2174 | });
2175 | document.querySelector(`[data-view="${mode}"]`).classList.add('active');
2176 |
2177 | const resultsContainer = document.getElementById('searchResultsList');
2178 | resultsContainer.className = mode === 'grid' ? 'memory-grid' : 'memory-list';
2179 | }
2180 |
2181 | /**
2182 | * Handle quick actions
2183 | */
2184 | handleQuickAction(action) {
2185 | switch (action) {
2186 | case 'quick-search':
2187 | this.switchView('search');
2188 | const searchInput = document.getElementById('quickSearch');
2189 | if (searchInput) {
2190 | searchInput.focus();
2191 | }
2192 | break;
2193 | case 'add-memory':
2194 | this.handleAddMemory();
2195 | break;
2196 | case 'browse-tags':
2197 | this.switchView('browse');
2198 | break;
2199 | case 'export-data':
2200 | this.handleExportData();
2201 | break;
2202 | }
2203 | }
2204 |
2205 | /**
2206 | * Handle add memory action
2207 | */
2208 | handleAddMemory() {
2209 | const modal = document.getElementById('addMemoryModal');
2210 |
2211 | // Reset modal for adding new memory
2212 | this.resetAddMemoryModal();
2213 |
2214 | this.openModal(modal);
2215 | document.getElementById('memoryContent').focus();
2216 | }
2217 |
2218 | /**
2219 | * Reset add memory modal to default state
2220 | */
2221 | resetAddMemoryModal() {
2222 | const modal = document.getElementById('addMemoryModal');
2223 | const title = modal.querySelector('.modal-header h3');
2224 | const saveBtn = document.getElementById('saveMemoryBtn');
2225 |
2226 | // Reset modal title and button text
2227 | title.textContent = 'Add New Memory';
2228 | saveBtn.textContent = 'Save Memory';
2229 |
2230 | // Clear form
2231 | document.getElementById('addMemoryForm').reset();
2232 |
2233 | // Clear editing state
2234 | this.editingMemory = null;
2235 | }
2236 |
2237 | /**
2238 | * Handle save memory
2239 | */
2240 | async handleSaveMemory() {
2241 | const content = document.getElementById('memoryContent').value.trim();
2242 | const tags = document.getElementById('memoryTags').value.trim();
2243 | const type = document.getElementById('memoryType').value;
2244 |
2245 | if (!content) {
2246 | this.showToast(this.t('toast.enterMemoryContent', 'Please enter memory content'), 'warning');
2247 | return;
2248 | }
2249 |
2250 | const payload = {
2251 | content: content,
2252 | tags: tags ? tags.split(',').map(t => t.trim()) : [],
2253 | memory_type: type,
2254 | metadata: {
2255 | created_via: 'dashboard',
2256 | user_agent: navigator.userAgent,
2257 | updated_via: this.editingMemory ? 'dashboard_edit' : 'dashboard_create'
2258 | }
2259 | };
2260 |
2261 |
2262 | try {
2263 | let response;
2264 | let successMessage;
2265 |
2266 | if (this.editingMemory) {
2267 | // Smart update: check if only metadata changed vs content changes
2268 | const originalContentHash = this.editingMemory.content_hash;
2269 | const contentChanged = this.editingMemory.content !== payload.content;
2270 |
2271 |
2272 | if (!contentChanged) {
2273 | // Only metadata (tags, type, metadata) changed - use PUT endpoint
2274 | const updatePayload = {
2275 | tags: payload.tags,
2276 | memory_type: payload.memory_type,
2277 | metadata: payload.metadata
2278 | };
2279 |
2280 | response = await this.apiCall(`/memories/${originalContentHash}`, 'PUT', updatePayload);
2281 | successMessage = 'Memory updated successfully';
2282 | } else {
2283 | // Content changed - use create-delete approach (but with proper error handling)
2284 |
2285 | try {
2286 | // Step 1: Create updated memory first
2287 | response = await this.apiCall('/memories', 'POST', payload);
2288 |
2289 | // CRITICAL: Only proceed with deletion if creation actually succeeded
2290 | if (response.success) {
2291 | successMessage = 'Memory updated successfully';
2292 |
2293 | try {
2294 | // Step 2: Delete original memory (only after successful creation)
2295 | const deleteResponse = await this.apiCall(`/memories/${originalContentHash}`, 'DELETE');
2296 | } catch (deleteError) {
2297 | console.error('Failed to delete original memory after creating new version:', deleteError);
2298 | this.showToast(
2299 | this.t('toast.duplicateMemoryWarning', 'Memory updated, but original version still exists. You may need to manually delete the duplicate.'),
2300 | 'warning'
2301 | );
2302 | }
2303 | } else {
2304 | // Creation failed - do NOT delete original memory
2305 | console.error('Creation failed:', response.message);
2306 | throw new Error(`Failed to create updated memory: ${response.message}`);
2307 | }
2308 | } catch (createError) {
2309 | // CREATE failed - original memory intact, no cleanup needed
2310 | console.error('Failed to create updated memory:', createError);
2311 | throw new Error(`Failed to update memory: ${createError.message}`);
2312 | }
2313 | }
2314 | } else {
2315 | // Create new memory
2316 | response = await this.apiCall('/memories', 'POST', payload);
2317 | successMessage = 'Memory saved successfully';
2318 | }
2319 |
2320 | this.closeModal(document.getElementById('addMemoryModal'));
2321 | this.showToast(successMessage, 'success');
2322 |
2323 | // Reset editing state
2324 | this.editingMemory = null;
2325 | this.resetAddMemoryModal();
2326 |
2327 | // Refresh current view if needed
2328 | if (this.currentView === 'dashboard') {
2329 | this.loadDashboardData();
2330 | } else if (this.currentView === 'search') {
2331 | // Refresh search results
2332 | const query = document.getElementById('searchInput').value.trim();
2333 | if (query) {
2334 | this.handleSearch(query);
2335 | }
2336 | } else if (this.currentView === 'browse') {
2337 | // Refresh browse view (tags cloud)
2338 | this.loadBrowseData();
2339 | }
2340 | } catch (error) {
2341 | console.error('Error saving memory:', error);
2342 | this.showToast(
2343 | this.t('toast.saveMemoryFailed', 'Failed to save memory: {reason}', { reason: error.message }),
2344 | 'error'
2345 | );
2346 | }
2347 | }
2348 |
2349 | /**
2350 | * Handle memory click to show details
2351 | */
2352 | handleMemoryClick(memory) {
2353 | this.showMemoryDetails(memory);
2354 | }
2355 |
2356 | /**
2357 | * Show memory details in modal
2358 | */
2359 | showMemoryDetails(memory) {
2360 | const modal = document.getElementById('memoryModal');
2361 | const title = document.getElementById('modalTitle');
2362 | const content = document.getElementById('modalContent');
2363 |
2364 | title.textContent = 'Memory Details';
2365 | content.innerHTML = this.renderMemoryDetails(memory);
2366 |
2367 | // Set up action buttons
2368 | document.getElementById('editMemoryBtn').onclick = () => this.editMemory(memory);
2369 | document.getElementById('deleteMemoryBtn').onclick = () => this.deleteMemory(memory);
2370 | document.getElementById('shareMemoryBtn').onclick = () => this.shareMemory(memory);
2371 |
2372 | this.openModal(modal);
2373 | }
2374 |
2375 | /**
2376 | * Render memory details HTML
2377 | */
2378 | renderMemoryDetails(memory) {
2379 | const createdDate = new Date(memory.created_at * 1000).toLocaleString();
2380 | const updatedDate = memory.updated_at ? new Date(memory.updated_at * 1000).toLocaleString() : null;
2381 |
2382 | return `
2383 | <div class="memory-detail">
2384 | <div class="memory-meta">
2385 | <p><strong>Created:</strong> ${createdDate} ${this.renderTimeIcon(memory.created_at)}</p>
2386 | ${updatedDate ? `<p><strong>Updated:</strong> ${updatedDate}</p>` : ''}
2387 | <p><strong>Type:</strong> ${memory.memory_type || 'note'}</p>
2388 | <p><strong>ID:</strong> ${memory.content_hash}</p>
2389 | </div>
2390 |
2391 | <div class="memory-content">
2392 | <h4>Content</h4>
2393 | <div class="content-text">${this.escapeHtml(memory.content)}</div>
2394 | </div>
2395 |
2396 | ${memory.tags && memory.tags.length > 0 ? `
2397 | <div class="memory-tags-section">
2398 | <h4>Tags</h4>
2399 | <div class="memory-tags">
2400 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2401 | </div>
2402 | </div>
2403 | ` : ''}
2404 |
2405 | ${memory.metadata ? `
2406 | <div class="memory-metadata">
2407 | <h4 class="metadata-toggle" onclick="this.parentElement.classList.toggle('expanded')" style="cursor: pointer; user-select: none;">
2408 | <span class="toggle-icon">▶</span> Metadata
2409 | </h4>
2410 | <div class="metadata-content">
2411 | ${this.renderMetadata(memory.metadata)}
2412 | </div>
2413 | </div>
2414 | ` : ''}
2415 | </div>
2416 | `;
2417 | }
2418 |
2419 | /**
2420 | * Render metadata in a prettier format
2421 | */
2422 | renderMetadata(metadata) {
2423 | if (!metadata || typeof metadata !== 'object') {
2424 | return '<p class="metadata-empty">No metadata available</p>';
2425 | }
2426 |
2427 | let html = '<div class="metadata-items">';
2428 |
2429 | for (const [key, value] of Object.entries(metadata)) {
2430 | let displayValue;
2431 |
2432 | if (typeof value === 'string') {
2433 | displayValue = `<span class="metadata-string">"${this.escapeHtml(value)}"</span>`;
2434 | } else if (typeof value === 'number') {
2435 | displayValue = `<span class="metadata-number">${value}</span>`;
2436 | } else if (typeof value === 'boolean') {
2437 | displayValue = `<span class="metadata-boolean">${value}</span>`;
2438 | } else if (Array.isArray(value)) {
2439 | displayValue = `<span class="metadata-array">[${value.map(v =>
2440 | typeof v === 'string' ? `"${this.escapeHtml(v)}"` : v
2441 | ).join(', ')}]</span>`;
2442 | } else {
2443 | displayValue = `<span class="metadata-object">${JSON.stringify(value)}</span>`;
2444 | }
2445 |
2446 | html += `
2447 | <div class="metadata-item">
2448 | <span class="metadata-key">${this.escapeHtml(key)}:</span>
2449 | <span class="metadata-value">${displayValue}</span>
2450 | </div>
2451 | `;
2452 | }
2453 |
2454 | html += '</div>';
2455 | return html;
2456 | }
2457 |
2458 | /**
2459 | * Delete memory
2460 | */
2461 | async deleteMemory(memory) {
2462 | if (!confirm('Are you sure you want to delete this memory? This action cannot be undone.')) {
2463 | return;
2464 | }
2465 |
2466 | try {
2467 | await this.apiCall(`/memories/${memory.content_hash}`, 'DELETE');
2468 | this.closeModal(document.getElementById('memoryModal'));
2469 | this.showToast(this.t('toast.memoryDeletedSuccess', 'Memory deleted successfully'), 'success');
2470 |
2471 | // Refresh current view
2472 | if (this.currentView === 'dashboard') {
2473 | this.loadDashboardData();
2474 | } else if (this.currentView === 'search') {
2475 | this.searchResults = this.searchResults.filter(m => m.memory.content_hash !== memory.content_hash);
2476 | this.renderSearchResults(this.searchResults);
2477 | } else if (this.currentView === 'browse') {
2478 | // Refresh browse view (tags cloud)
2479 | this.loadBrowseData();
2480 | }
2481 | } catch (error) {
2482 | console.error('Error deleting memory:', error);
2483 | this.showToast(this.t('toast.memoryDeleteFailed', 'Failed to delete memory'), 'error');
2484 | }
2485 | }
2486 |
2487 | /**
2488 | * Edit memory
2489 | */
2490 | editMemory(memory) {
2491 | // Close the memory details modal first
2492 | this.closeModal(document.getElementById('memoryModal'));
2493 |
2494 | // Open the add memory modal with pre-filled data
2495 | const modal = document.getElementById('addMemoryModal');
2496 | const title = modal.querySelector('.modal-header h3');
2497 | const saveBtn = document.getElementById('saveMemoryBtn');
2498 |
2499 | // Update modal for editing
2500 | title.textContent = 'Edit Memory';
2501 | saveBtn.textContent = 'Update Memory';
2502 |
2503 | // Pre-fill the form with existing data
2504 | document.getElementById('memoryContent').value = memory.content || '';
2505 |
2506 | // Handle tags - ensure they're displayed correctly
2507 | const tagsValue = memory.tags && Array.isArray(memory.tags) ? memory.tags.join(', ') : '';
2508 | document.getElementById('memoryTags').value = tagsValue;
2509 |
2510 | document.getElementById('memoryType').value = memory.memory_type || 'note';
2511 |
2512 |
2513 | // Store the memory being edited
2514 | this.editingMemory = memory;
2515 |
2516 | this.openModal(modal);
2517 |
2518 | // Use setTimeout to ensure modal is fully rendered before setting values
2519 | setTimeout(() => {
2520 | document.getElementById('memoryContent').focus();
2521 | }, 100);
2522 | }
2523 |
2524 | /**
2525 | * Share memory
2526 | */
2527 | shareMemory(memory) {
2528 | // Create shareable data
2529 | const shareData = {
2530 | content: memory.content,
2531 | tags: memory.tags || [],
2532 | type: memory.memory_type || 'note',
2533 | created: new Date(memory.created_at * 1000).toISOString(),
2534 | id: memory.content_hash
2535 | };
2536 |
2537 | // Try to use Web Share API if available
2538 | if (navigator.share) {
2539 | navigator.share({
2540 | title: 'Memory from MCP Memory Service',
2541 | text: memory.content,
2542 | url: window.location.href
2543 | }).catch(err => {
2544 | // Share API failed, fall back to clipboard
2545 | this.fallbackShare(shareData);
2546 | });
2547 | } else {
2548 | this.fallbackShare(shareData);
2549 | }
2550 | }
2551 |
2552 | /**
2553 | * Fallback share method (copy to clipboard)
2554 | */
2555 | fallbackShare(shareData) {
2556 | const shareText = `Memory Content:\n${shareData.content}\n\nTags: ${shareData.tags.join(', ')}\nType: ${shareData.type}\nCreated: ${shareData.created}`;
2557 |
2558 | navigator.clipboard.writeText(shareText).then(() => {
2559 | this.showToast(this.t('toast.copySuccess', 'Memory copied to clipboard'), 'success');
2560 | }).catch(err => {
2561 | console.error('Could not copy text: ', err);
2562 | this.showToast(this.t('toast.copyFailed', 'Failed to copy to clipboard'), 'error');
2563 | });
2564 | }
2565 |
2566 | /**
2567 | * Handle data export
2568 | */
2569 | async handleExportData() {
2570 | try {
2571 | this.showToast(this.t('toast.exportPreparing', 'Preparing export...'), 'info');
2572 |
2573 | // Fetch all memories using pagination
2574 | const allMemories = [];
2575 | const pageSize = 100; // Reasonable batch size
2576 | let page = 1;
2577 | let hasMore = true;
2578 | let totalMemories = 0;
2579 |
2580 | while (hasMore) {
2581 | const response = await this.apiCall(`/memories?page=${page}&page_size=${pageSize}`);
2582 |
2583 | if (page === 1) {
2584 | totalMemories = response.total;
2585 | }
2586 |
2587 | if (response.memories && response.memories.length > 0) {
2588 | allMemories.push(...response.memories);
2589 | hasMore = response.has_more;
2590 | page++;
2591 |
2592 | // Update progress
2593 | this.showToast(
2594 | this.t('toast.exportFetching', 'Fetching memories... ({current}/{total})', {
2595 | current: allMemories.length,
2596 | total: totalMemories
2597 | }),
2598 | 'info'
2599 | );
2600 | } else {
2601 | hasMore = false;
2602 | }
2603 | }
2604 |
2605 | const data = {
2606 | export_date: new Date().toISOString(),
2607 | total_memories: totalMemories,
2608 | exported_memories: allMemories.length,
2609 | memories: allMemories
2610 | };
2611 |
2612 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
2613 | const url = URL.createObjectURL(blob);
2614 | const a = document.createElement('a');
2615 | a.href = url;
2616 | a.download = `mcp-memories-export-${new Date().toISOString().split('T')[0]}.json`;
2617 | document.body.appendChild(a);
2618 | a.click();
2619 | document.body.removeChild(a);
2620 | URL.revokeObjectURL(url);
2621 |
2622 | this.showToast(
2623 | this.t('toast.exportSuccess', 'Successfully exported {count} memories', { count: allMemories.length }),
2624 | 'success'
2625 | );
2626 | } catch (error) {
2627 | console.error('Export error:', error);
2628 | this.showToast(this.t('toast.exportFailed', 'Failed to export data'), 'error');
2629 | }
2630 | }
2631 |
2632 | /**
2633 | * Render recent memories
2634 | */
2635 | renderRecentMemories(memories) {
2636 | const container = document.getElementById('recentMemoriesList');
2637 |
2638 | if (!container) {
2639 | console.error('recentMemoriesList container not found');
2640 | return;
2641 | }
2642 |
2643 | if (!memories || memories.length === 0) {
2644 | container.innerHTML = '<p class="empty-state">No memories found. <a href="#" onclick="app.handleAddMemory()">Add your first memory</a></p>';
2645 | return;
2646 | }
2647 |
2648 | // Group document chunks by upload_id
2649 | const groupedMemories = this.groupMemoriesByUpload(memories);
2650 |
2651 | container.innerHTML = groupedMemories.map(group => {
2652 | if (group.type === 'document') {
2653 | return this.renderDocumentGroup(group);
2654 | } else {
2655 | return this.renderMemoryCard(group.memory);
2656 | }
2657 | }).join('');
2658 |
2659 | // Add click handlers for individual memories
2660 | container.querySelectorAll('.memory-card').forEach((card, index) => {
2661 | const group = groupedMemories[index];
2662 | if (group.type === 'single') {
2663 | card.addEventListener('click', () => this.handleMemoryClick(group.memory));
2664 | }
2665 | });
2666 |
2667 | // Add click handlers for document groups
2668 | container.querySelectorAll('.document-group').forEach((groupEl, index) => {
2669 | const group = groupedMemories.filter(g => g.type === 'document')[index];
2670 | if (group) {
2671 | groupEl.addEventListener('click', (e) => {
2672 | // Don't trigger if clicking on action buttons
2673 | if (e.target.closest('.document-actions')) return;
2674 | this.showDocumentChunks(group);
2675 | });
2676 | }
2677 | });
2678 |
2679 | // Add click handlers for document action buttons
2680 | container.querySelectorAll('.document-group').forEach((groupEl, index) => {
2681 | const group = groupedMemories.filter(g => g.type === 'document')[index];
2682 | if (group) {
2683 | // View chunks button
2684 | const viewBtn = groupEl.querySelector('.btn-view-chunks');
2685 | if (viewBtn) {
2686 | viewBtn.addEventListener('click', (e) => {
2687 | e.stopPropagation();
2688 | this.showDocumentChunks(group);
2689 | });
2690 | }
2691 |
2692 | // Remove button
2693 | const removeBtn = groupEl.querySelector('.btn-remove');
2694 | if (removeBtn) {
2695 | removeBtn.addEventListener('click', async (e) => {
2696 | e.stopPropagation();
2697 | await this.removeDocument(group.upload_id, group.source_file);
2698 | // removeDocument() already handles view refresh
2699 | });
2700 | }
2701 | }
2702 | });
2703 | }
2704 |
2705 | /**
2706 | * Group memories by upload_id for document chunks
2707 | */
2708 | groupMemoriesByUpload(memories) {
2709 | const groups = [];
2710 | const documentGroups = new Map();
2711 | const processedHashes = new Set();
2712 |
2713 | for (const memory of memories) {
2714 | // Check if this is a document chunk
2715 | const isDocumentChunk = memory.metadata && memory.metadata.upload_id;
2716 |
2717 | if (isDocumentChunk && !processedHashes.has(memory.content_hash)) {
2718 | const uploadId = memory.metadata.upload_id;
2719 | const sourceFile = memory.metadata.source_file || 'Unknown file';
2720 |
2721 | if (!documentGroups.has(uploadId)) {
2722 | documentGroups.set(uploadId, {
2723 | upload_id: uploadId,
2724 | source_file: sourceFile,
2725 | memories: [],
2726 | created_at: memory.created_at,
2727 | tags: new Set()
2728 | });
2729 | }
2730 |
2731 | const group = documentGroups.get(uploadId);
2732 | group.memories.push(memory);
2733 | group.tags.add(...(memory.tags || []));
2734 | processedHashes.add(memory.content_hash);
2735 | } else if (!processedHashes.has(memory.content_hash)) {
2736 | // Regular memory
2737 | groups.push({
2738 | type: 'single',
2739 | memory: memory
2740 | });
2741 | processedHashes.add(memory.content_hash);
2742 | }
2743 | }
2744 |
2745 | // Convert document groups to array format
2746 | for (const group of documentGroups.values()) {
2747 | groups.push({
2748 | type: 'document',
2749 | upload_id: group.upload_id,
2750 | source_file: group.source_file,
2751 | memories: group.memories,
2752 | created_at: group.created_at,
2753 | tags: Array.from(group.tags)
2754 | });
2755 | }
2756 |
2757 | // Sort by creation time (most recent first)
2758 | groups.sort((a, b) => {
2759 | const timeA = a.type === 'document' ? a.created_at : a.memory.created_at;
2760 | const timeB = b.type === 'document' ? b.created_at : b.memory.created_at;
2761 | return timeB - timeA;
2762 | });
2763 |
2764 | return groups;
2765 | }
2766 |
2767 | /**
2768 | * Render a document group card
2769 | */
2770 | renderDocumentGroup(group) {
2771 | const createdDate = new Date(group.created_at * 1000).toLocaleDateString();
2772 | const fileName = this.escapeHtml(group.source_file);
2773 | const chunkCount = group.memories.length;
2774 | // Filter out metadata tags AND tags that are too long (likely corrupted/malformed)
2775 | const uniqueTags = [...new Set(group.tags.filter(tag =>
2776 | !tag.startsWith('upload_id:') &&
2777 | !tag.startsWith('source_file:') &&
2778 | !tag.startsWith('file_type:') &&
2779 | tag.length < 100 // Reject tags longer than 100 chars (likely corrupted metadata)
2780 | ))];
2781 |
2782 | return `
2783 | <div class="document-group" data-upload-id="${this.escapeHtml(group.upload_id)}">
2784 | <div class="document-header">
2785 | <div class="document-icon">📄</div>
2786 | <div class="document-info">
2787 | <div class="document-title">${fileName}</div>
2788 | <div class="document-meta">
2789 | ${chunkCount} chunks • ${createdDate} ${this.renderTimeIcon(group.created_at)}
2790 | </div>
2791 | </div>
2792 | </div>
2793 | <div class="document-preview">
2794 | ${group.memories[0] ? this.escapeHtml(group.memories[0].content.substring(0, 150)) + (group.memories[0].content.length > 150 ? '...' : '') : 'No content preview available'}
2795 | </div>
2796 | ${uniqueTags.length > 0 ? `
2797 | <div class="document-tags">
2798 | ${uniqueTags.slice(0, 3).map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2799 | ${uniqueTags.length > 3 ? `<span class="tag more">+${uniqueTags.length - 3} more</span>` : ''}
2800 | </div>
2801 | ` : ''}
2802 | <div class="document-actions">
2803 | <button class="btn-icon btn-view-chunks" title="View all chunks">
2804 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
2805 | <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"/>
2806 | </svg>
2807 | <span>View Chunks</span>
2808 | </button>
2809 | <button class="btn-icon btn-remove" title="Remove document">
2810 | <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
2811 | <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
2812 | </svg>
2813 | <span>Remove</span>
2814 | </button>
2815 | </div>
2816 | </div>
2817 | `;
2818 | }
2819 |
2820 | /**
2821 | * Show document chunks in a modal
2822 | */
2823 | showDocumentChunks(group) {
2824 | const modal = document.createElement('div');
2825 | modal.className = 'modal-overlay';
2826 | modal.innerHTML = `
2827 | <div class="modal-content large-modal">
2828 | <div class="modal-header">
2829 | <h3>📄 ${this.escapeHtml(group.source_file)}</h3>
2830 | <button class="modal-close">×</button>
2831 | </div>
2832 | <div class="modal-body">
2833 | <div class="document-chunks">
2834 | ${group.memories.map((memory, index) => `
2835 | <div class="chunk-item">
2836 | <div class="chunk-header">
2837 | <span class="chunk-number">Chunk ${index + 1}</span>
2838 | <div class="chunk-meta">
2839 | ${memory.metadata && memory.metadata.page ? `Page ${memory.metadata.page} • ` : ''}
2840 | ${memory.content.length} chars
2841 | </div>
2842 | </div>
2843 | <div class="chunk-content">
2844 | ${this.escapeHtml(memory.content)}
2845 | </div>
2846 | ${memory.tags && memory.tags.length > 0 ? `
2847 | <div class="chunk-tags">
2848 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2849 | </div>
2850 | ` : ''}
2851 | </div>
2852 | `).join('')}
2853 | </div>
2854 | </div>
2855 | </div>
2856 | `;
2857 |
2858 | document.body.appendChild(modal);
2859 |
2860 | // Add active class to show modal (required for display: flex)
2861 | setTimeout(() => modal.classList.add('active'), 10);
2862 |
2863 | // Add close handlers
2864 | const closeModal = () => {
2865 | modal.classList.remove('active');
2866 | setTimeout(() => document.body.removeChild(modal), 300);
2867 | };
2868 | modal.querySelector('.modal-close').addEventListener('click', closeModal);
2869 | modal.addEventListener('click', (e) => {
2870 | if (e.target === modal) closeModal();
2871 | });
2872 | }
2873 |
2874 | /**
2875 | * Render tags in sidebar
2876 | */
2877 | renderTagsSidebar(tags) {
2878 | const container = document.getElementById('tagsCloudContainer');
2879 |
2880 | if (!container) {
2881 | console.warn('tagsCloudContainer element not found - skipping tags sidebar rendering');
2882 | return;
2883 | }
2884 |
2885 | if (!tags || tags.length === 0) {
2886 | container.innerHTML = '<div class="no-tags">No tags found.</div>';
2887 | return;
2888 | }
2889 |
2890 | // Take top tags for sidebar display
2891 | const topTags = tags.slice(0, 10);
2892 | container.innerHTML = topTags.map(tagData => `
2893 | <div class="tag-item" data-tag="${this.escapeHtml(tagData.tag)}">
2894 | <span class="tag-name">${this.escapeHtml(tagData.tag)}</span>
2895 | <span class="tag-count">${tagData.count}</span>
2896 | </div>
2897 | `).join('');
2898 |
2899 | // Add click handlers
2900 | container.querySelectorAll('.tag-item').forEach(item => {
2901 | item.addEventListener('click', () => {
2902 | const tagName = item.dataset.tag;
2903 | const searchInput = document.getElementById('searchInput');
2904 | searchInput.value = `#${tagName}`;
2905 | this.handleSearch(`#${tagName}`);
2906 | });
2907 | });
2908 | }
2909 |
2910 | /**
2911 | * Render search results
2912 | */
2913 | renderSearchResults(results) {
2914 | const container = document.getElementById('searchResultsList');
2915 |
2916 | if (!results || results.length === 0) {
2917 | container.innerHTML = '<p class="empty-state">No results found. Try a different search term.</p>';
2918 | return;
2919 | }
2920 |
2921 | container.innerHTML = results.map(result => this.renderMemoryCard(result.memory, result)).join('');
2922 |
2923 | // Add click handlers
2924 | container.querySelectorAll('.memory-card').forEach((card, index) => {
2925 | card.addEventListener('click', () => this.handleMemoryClick(results[index].memory));
2926 | });
2927 | }
2928 |
2929 | /**
2930 | * Render a memory card
2931 | */
2932 | renderMemoryCard(memory, searchResult = null) {
2933 | const createdDate = new Date(memory.created_at * 1000).toLocaleDateString();
2934 | const relevanceScore = searchResult &&
2935 | searchResult.similarity_score !== null &&
2936 | searchResult.similarity_score !== undefined &&
2937 | !isNaN(searchResult.similarity_score) &&
2938 | searchResult.similarity_score > 0
2939 | ? (searchResult.similarity_score * 100).toFixed(1)
2940 | : null;
2941 |
2942 | // Truncate content to 150 characters for preview
2943 | const truncatedContent = memory.content.length > 150
2944 | ? memory.content.substring(0, 150) + '...'
2945 | : memory.content;
2946 |
2947 | return `
2948 | <div class="memory-card" data-memory-id="${memory.content_hash}">
2949 | ${this.renderQualityBadge(memory)}
2950 | <div class="memory-header">
2951 | <div class="memory-meta">
2952 | <span>${createdDate} ${this.renderTimeIcon(memory.created_at)}</span>
2953 | ${memory.memory_type ? `<span> • ${memory.memory_type}</span>` : ''}
2954 | ${relevanceScore ? `<span> • ${relevanceScore}% match</span>` : ''}
2955 | </div>
2956 | </div>
2957 |
2958 | <div class="memory-content">
2959 | ${this.escapeHtml(truncatedContent)}
2960 | </div>
2961 |
2962 | ${memory.tags && memory.tags.length > 0 ? `
2963 | <div class="memory-tags">
2964 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
2965 | </div>
2966 | ` : ''}
2967 | </div>
2968 | `;
2969 | }
2970 |
2971 | /**
2972 | * Update dashboard statistics
2973 | */
2974 | updateDashboardStats(stats) {
2975 | const totalMemoriesEl = document.getElementById('totalMemories');
2976 | if (totalMemoriesEl) {
2977 | totalMemoriesEl.textContent = stats.total_memories || '0';
2978 | }
2979 |
2980 | const recentMemoriesEl = document.getElementById('recentMemories');
2981 | if (recentMemoriesEl) {
2982 | recentMemoriesEl.textContent = stats.memories_this_week || '0';
2983 | }
2984 |
2985 | const uniqueTagsEl = document.getElementById('uniqueTags');
2986 | if (uniqueTagsEl) {
2987 | uniqueTagsEl.textContent = stats.unique_tags || '0';
2988 | }
2989 |
2990 | const storageBackendEl = document.getElementById('storageBackend');
2991 | if (storageBackendEl) {
2992 | storageBackendEl.textContent = stats.backend || 'unknown';
2993 | }
2994 | }
2995 |
2996 | /**
2997 | * Update search results count
2998 | */
2999 | updateResultsCount(count) {
3000 | const element = document.getElementById('resultsCount');
3001 | if (element) {
3002 | element.textContent = `${count} result${count !== 1 ? 's' : ''}`;
3003 | }
3004 | }
3005 |
3006 | /**
3007 | * Update active filters display
3008 | */
3009 | updateActiveFilters() {
3010 | const activeFiltersContainer = document.getElementById('activeFilters');
3011 | const filtersList = document.getElementById('activeFiltersList');
3012 |
3013 | if (!activeFiltersContainer || !filtersList) return;
3014 |
3015 | const tagFilter = document.getElementById('tagFilter')?.value?.trim();
3016 | const dateFilter = document.getElementById('dateFilter')?.value;
3017 | const typeFilter = document.getElementById('typeFilter')?.value;
3018 |
3019 | const filters = [];
3020 |
3021 | if (tagFilter) {
3022 | const tags = tagFilter.split(',').map(t => t.trim()).filter(t => t);
3023 | tags.forEach(tag => {
3024 | filters.push({
3025 | type: 'tag',
3026 | value: tag,
3027 | label: `Tag: ${tag}`
3028 | });
3029 | });
3030 | }
3031 |
3032 | if (dateFilter) {
3033 | const dateLabels = {
3034 | 'today': 'Today',
3035 | 'week': 'This week',
3036 | 'month': 'This month',
3037 | 'year': 'This year'
3038 | };
3039 | filters.push({
3040 | type: 'date',
3041 | value: dateFilter,
3042 | label: `Date: ${dateLabels[dateFilter] || dateFilter}`
3043 | });
3044 | }
3045 |
3046 | if (typeFilter) {
3047 | const typeLabels = {
3048 | 'note': 'Notes',
3049 | 'code': 'Code',
3050 | 'reference': 'References',
3051 | 'idea': 'Ideas'
3052 | };
3053 | filters.push({
3054 | type: 'type',
3055 | value: typeFilter,
3056 | label: `Type: ${typeLabels[typeFilter] || typeFilter}`
3057 | });
3058 | }
3059 |
3060 | if (filters.length === 0) {
3061 | activeFiltersContainer.style.display = 'none';
3062 | return;
3063 | }
3064 |
3065 | activeFiltersContainer.style.display = 'block';
3066 | filtersList.innerHTML = filters.map(filter => `
3067 | <div class="filter-pill">
3068 | ${this.escapeHtml(filter.label)}
3069 | <button class="remove-filter" data-filter-type="${this.escapeHtml(filter.type)}" data-filter-value="${this.escapeHtml(filter.value)}">
3070 | ×
3071 | </button>
3072 | </div>
3073 | `).join('');
3074 |
3075 | // Add event listeners for filter removal
3076 | filtersList.addEventListener('click', (e) => {
3077 | const button = e.target.closest('.remove-filter');
3078 | if (!button) return;
3079 |
3080 | const type = button.dataset.filterType;
3081 | const value = button.dataset.filterValue;
3082 | this.removeFilter(type, value);
3083 | });
3084 | }
3085 |
3086 | /**
3087 | * Remove a specific filter
3088 | */
3089 | removeFilter(type, value) {
3090 | switch (type) {
3091 | case 'tag':
3092 | const tagInput = document.getElementById('tagFilter');
3093 | if (tagInput) {
3094 | const tags = tagInput.value.split(',').map(t => t.trim()).filter(t => t && t !== value);
3095 | tagInput.value = tags.join(', ');
3096 | }
3097 | break;
3098 | case 'date':
3099 | const dateSelect = document.getElementById('dateFilter');
3100 | if (dateSelect) {
3101 | dateSelect.value = '';
3102 | }
3103 | break;
3104 | case 'type':
3105 | const typeSelect = document.getElementById('typeFilter');
3106 | if (typeSelect) {
3107 | typeSelect.value = '';
3108 | }
3109 | break;
3110 | }
3111 | this.handleFilterChange();
3112 | }
3113 |
3114 | /**
3115 | * Clear all filters
3116 | */
3117 | clearAllFilters() {
3118 | const tagFilter = document.getElementById('tagFilter');
3119 | const dateFilter = document.getElementById('dateFilter');
3120 | const typeFilter = document.getElementById('typeFilter');
3121 |
3122 | if (tagFilter) tagFilter.value = '';
3123 | if (dateFilter) dateFilter.value = '';
3124 | if (typeFilter) typeFilter.value = '';
3125 |
3126 | this.searchResults = [];
3127 | this.renderSearchResults([]);
3128 | this.updateResultsCount(0);
3129 | this.updateActiveFilters();
3130 |
3131 | this.showToast(this.t('toast.filtersCleared', 'All filters cleared'), 'info');
3132 | }
3133 |
3134 | /**
3135 | * Handle live search toggle
3136 | */
3137 | handleLiveSearchToggle(event) {
3138 | this.liveSearchEnabled = event.target.checked;
3139 | const modeText = document.getElementById('searchModeText');
3140 | if (modeText) {
3141 | modeText.textContent = this.liveSearchEnabled
3142 | ? this.t('search.modeLive', 'Live Search')
3143 | : this.t('search.modeManual', 'Manual Search');
3144 | }
3145 |
3146 | // Show a toast to indicate the mode change
3147 | this.showToast(
3148 | this.liveSearchEnabled
3149 | ? this.t('toast.searchModeLive', 'Search mode: Live (searches as you type)')
3150 | : this.t('toast.searchModeManual', 'Search mode: Manual (click Search button)'),
3151 | 'info'
3152 | );
3153 | }
3154 |
3155 | /**
3156 | * Handle debounced filter changes for live search
3157 | */
3158 | handleDebouncedFilterChange() {
3159 | // Clear any existing timer
3160 | if (this.debounceTimer) {
3161 | clearTimeout(this.debounceTimer);
3162 | }
3163 |
3164 | // Only trigger search if live search is enabled
3165 | if (this.liveSearchEnabled) {
3166 | this.debounceTimer = setTimeout(() => {
3167 | this.handleFilterChange();
3168 | }, 300); // 300ms debounce
3169 | }
3170 | }
3171 |
3172 | /**
3173 | * Handle memory added via SSE
3174 | */
3175 | handleMemoryAdded(memory) {
3176 | if (this.currentView === 'dashboard') {
3177 | this.loadDashboardData();
3178 | }
3179 | }
3180 |
3181 | /**
3182 | * Handle memory deleted via SSE
3183 | */
3184 | handleMemoryDeleted(memoryId) {
3185 | // Remove from current view
3186 | const cards = document.querySelectorAll(`[data-memory-id="${memoryId}"]`);
3187 | cards.forEach(card => card.remove());
3188 |
3189 | // Update search results if in search view
3190 | if (this.currentView === 'search') {
3191 | this.searchResults = this.searchResults.filter(r => r.memory.content_hash !== memoryId);
3192 | this.updateResultsCount(this.searchResults.length);
3193 | }
3194 | }
3195 |
3196 | /**
3197 | * Handle memory updated via SSE
3198 | */
3199 | handleMemoryUpdated(memory) {
3200 | // Refresh relevant views
3201 | if (this.currentView === 'dashboard') {
3202 | this.loadDashboardData();
3203 | }
3204 | }
3205 |
3206 | /**
3207 | * Update connection status indicator
3208 | */
3209 | updateConnectionStatus(status) {
3210 | const statusElement = document.getElementById('connectionStatus');
3211 | if (statusElement) {
3212 | const indicator = statusElement.querySelector('.status-indicator');
3213 | const text = statusElement.querySelector('.status-text');
3214 | if (!indicator || !text) return;
3215 |
3216 | // Reset indicator classes
3217 | indicator.className = 'status-indicator';
3218 |
3219 | switch (status) {
3220 | case 'connected':
3221 | text.textContent = 'Connected';
3222 | // Connected uses default green color (no additional class needed)
3223 | break;
3224 | case 'connecting':
3225 | text.textContent = 'Connecting...';
3226 | indicator.classList.add('connecting');
3227 | break;
3228 | case 'disconnected':
3229 | text.textContent = 'Disconnected';
3230 | indicator.classList.add('disconnected');
3231 | break;
3232 | default:
3233 | text.textContent = 'Unknown';
3234 | indicator.classList.add('disconnected');
3235 | }
3236 | }
3237 | }
3238 |
3239 | /**
3240 | * Generic API call wrapper
3241 | */
3242 | async apiCall(endpoint, method = 'GET', data = null) {
3243 | const options = {
3244 | method: method,
3245 | headers: {
3246 | 'Content-Type': 'application/json',
3247 | }
3248 | };
3249 |
3250 | if (data) {
3251 | options.body = JSON.stringify(data);
3252 | }
3253 |
3254 | const response = await fetch(`${this.apiBase}${endpoint}`, options);
3255 |
3256 | if (!response.ok) {
3257 | const errorData = await response.json().catch(() => ({}));
3258 | throw new Error(errorData.detail || `HTTP ${response.status}`);
3259 | }
3260 |
3261 | return await response.json();
3262 | }
3263 |
3264 | /**
3265 | * Modal management
3266 | */
3267 | openModal(modal) {
3268 | modal.classList.add('active');
3269 | document.body.style.overflow = 'hidden';
3270 |
3271 | // Focus first input
3272 | const firstInput = modal.querySelector('input, textarea');
3273 | if (firstInput) {
3274 | firstInput.focus();
3275 | }
3276 | }
3277 |
3278 | closeModal(modal) {
3279 | modal.classList.remove('active');
3280 | document.body.style.overflow = '';
3281 | }
3282 |
3283 | /**
3284 | * Loading state management
3285 | */
3286 | setLoading(loading) {
3287 | this.isLoading = loading;
3288 | const indicator = document.getElementById('loadingOverlay');
3289 | if (indicator) {
3290 | if (loading) {
3291 | indicator.classList.remove('hidden');
3292 | } else {
3293 | indicator.classList.add('hidden');
3294 | }
3295 | }
3296 | }
3297 |
3298 | /**
3299 | * View document memory chunks
3300 | */
3301 | async viewDocumentMemory(uploadId) {
3302 | try {
3303 | this.setLoading(true);
3304 | const response = await this.apiCall(`/documents/search-content/${uploadId}`);
3305 |
3306 | if (response.status === 'success' || response.status === 'partial') {
3307 | // Show modal
3308 | const modal = document.getElementById('memoryViewerModal');
3309 | const filename = document.getElementById('memoryViewerFilename');
3310 | const stats = document.getElementById('memoryViewerStats');
3311 | const chunksList = document.getElementById('memoryChunksList');
3312 |
3313 | filename.textContent = response.filename || 'Document';
3314 | stats.textContent = `${response.total_found} chunk${response.total_found !== 1 ? 's' : ''} found`;
3315 |
3316 | // Render chunks
3317 | if (response.memories && response.memories.length > 0) {
3318 | const chunksHtml = response.memories.map((memory, index) => {
3319 | const chunkIndex = memory.chunk_index !== undefined ? memory.chunk_index : index;
3320 | const page = memory.page ? ` • Page ${memory.page}` : '';
3321 | const contentPreview = memory.content.length > 300
3322 | ? memory.content.substring(0, 300) + '...'
3323 | : memory.content;
3324 |
3325 | return `
3326 | <div class="memory-chunk-item">
3327 | <div class="chunk-header">
3328 | <span class="chunk-number">Chunk ${chunkIndex + 1}${page}</span>
3329 | <span class="chunk-hash" title="${memory.content_hash}">${memory.content_hash.substring(0, 12)}...</span>
3330 | </div>
3331 | <div class="chunk-content">${this.escapeHtml(contentPreview)}</div>
3332 | <div class="chunk-tags">
3333 | ${memory.tags.map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
3334 | </div>
3335 | </div>
3336 | `;
3337 | }).join('');
3338 |
3339 | chunksList.innerHTML = chunksHtml;
3340 | } else {
3341 | chunksList.innerHTML = '<p class="text-muted">No memory chunks found for this document.</p>';
3342 | }
3343 |
3344 | modal.style.display = 'flex';
3345 |
3346 | if (response.status === 'partial') {
3347 | this.showToast(
3348 | this.t('toast.documentChunksPartial', 'Found {count} chunks (partial results)', {
3349 | count: response.total_found
3350 | }),
3351 | 'warning'
3352 | );
3353 | }
3354 | } else {
3355 | this.showToast(this.t('toast.loadDocumentMemoriesFail', 'Failed to load document memories'), 'error');
3356 | }
3357 | } catch (error) {
3358 | console.error('Error viewing document memory:', error);
3359 | this.showToast(this.t('toast.errorLoadingDocumentMemories', 'Error loading document memories'), 'error');
3360 | } finally {
3361 | this.setLoading(false);
3362 | }
3363 | }
3364 |
3365 | /**
3366 | * Close memory viewer modal
3367 | */
3368 | closeMemoryViewer() {
3369 | const modal = document.getElementById('memoryViewerModal');
3370 | if (modal) {
3371 | modal.style.display = 'none';
3372 | }
3373 | }
3374 |
3375 | /**
3376 | * Remove document and its memories
3377 | */
3378 | async removeDocument(uploadId, filename) {
3379 | console.log('removeDocument called with:', { uploadId, filename, currentView: this.currentView });
3380 |
3381 | if (!confirm(`Remove "${filename}" and all its memory chunks?\n\nThis action cannot be undone.`)) {
3382 | console.log('User cancelled removal');
3383 | return;
3384 | }
3385 |
3386 | try {
3387 | this.setLoading(true);
3388 | console.log('Making DELETE request to:', `/documents/remove/${uploadId}`);
3389 |
3390 | const response = await this.apiCall(`/documents/remove/${uploadId}`, 'DELETE');
3391 |
3392 | console.log('Delete response:', response);
3393 |
3394 | if (response.status === 'success') {
3395 | this.showToast(
3396 | this.t('toast.documentRemoved', 'Removed "{name}" ({count} memories deleted)', {
3397 | name: filename,
3398 | count: response.memories_deleted
3399 | }),
3400 | 'success'
3401 | );
3402 | // Refresh the current view (Dashboard or Documents tab)
3403 | console.log('Refreshing view:', this.currentView);
3404 | if (this.currentView === 'dashboard') {
3405 | await this.loadDashboardData();
3406 | } else if (this.currentView === 'documents') {
3407 | await this.loadUploadHistory();
3408 | }
3409 | } else {
3410 | console.error('Removal failed with response:', response);
3411 | this.showToast(this.t('toast.removeDocumentFailed', 'Failed to remove document'), 'error');
3412 | }
3413 | } catch (error) {
3414 | console.error('Error removing document:', error);
3415 | console.error('Error stack:', error.stack);
3416 | this.showToast(this.t('toast.errorRemovingDocument', 'Error removing document'), 'error');
3417 | } finally {
3418 | this.setLoading(false);
3419 | }
3420 | }
3421 |
3422 | /**
3423 | * Search document content
3424 | */
3425 | async searchDocumentContent(query) {
3426 | try {
3427 | this.setLoading(true);
3428 | const resultsContainer = document.getElementById('docSearchResults');
3429 | const resultsList = document.getElementById('docSearchResultsList');
3430 | const resultsCount = document.getElementById('docSearchCount');
3431 |
3432 | // Use the regular search endpoint but filter for document memories
3433 | // Higher n_results to ensure we get enough document results after filtering
3434 | const response = await this.apiCall('/search', 'POST', {
3435 | query: query,
3436 | n_results: 100
3437 | });
3438 |
3439 | if (response.results) {
3440 | // Filter results to only show document-type memories
3441 | const documentResults = response.results.filter(r =>
3442 | r.memory?.memory_type === 'document' || (r.memory?.tags && r.memory.tags.some(tag => tag.startsWith('upload_id:')))
3443 | );
3444 |
3445 | // Limit display to top 20 most relevant document results
3446 | const displayResults = documentResults.slice(0, 20);
3447 |
3448 | const count = documentResults.length;
3449 | const extra = count > 20 ? ' (showing top 20)' : '';
3450 | const key = count === 1 ? 'documents.search.count.one' : 'documents.search.count.other';
3451 | const fallback = count === 1 ? '{count} result{extra}' : '{count} results{extra}';
3452 | const baseCount = this.t(key, fallback, { count, extra });
3453 | resultsCount.textContent = baseCount;
3454 |
3455 | if (displayResults.length > 0) {
3456 | const resultsHtml = displayResults.map(result => {
3457 | const mem = result.memory;
3458 | const uploadIdTag = mem.tags?.find(tag => tag.startsWith('upload_id:'));
3459 | const sourceFile = mem.metadata?.source_file || 'Unknown file';
3460 | const contentPreview = mem.content.length > 200
3461 | ? mem.content.substring(0, 200) + '...'
3462 | : mem.content;
3463 |
3464 | return `
3465 | <div class="search-result-item">
3466 | <div class="result-header">
3467 | <strong>${this.escapeHtml(sourceFile)}</strong>
3468 | <span class="similarity-score">${Math.round((result.similarity_score || 0) * 100)}% match</span>
3469 | </div>
3470 | <div class="result-content">${this.escapeHtml(contentPreview)}</div>
3471 | <div class="result-tags">
3472 | ${(mem.tags || []).slice(0, 5).map(tag => `<span class="tag">${this.escapeHtml(tag)}</span>`).join('')}
3473 | </div>
3474 | </div>
3475 | `;
3476 | }).join('');
3477 |
3478 | resultsList.innerHTML = resultsHtml;
3479 | } else {
3480 | resultsList.innerHTML = `<p class="text-muted">${this.t('documents.search.noMatch', 'No matching document content found. Try different search terms.')}</p>`;
3481 | }
3482 |
3483 | resultsContainer.style.display = 'block';
3484 | } else {
3485 | this.showToast(this.t('toast.searchFailed', 'Search failed'), 'error');
3486 | }
3487 | } catch (error) {
3488 | console.error('Error searching documents:', error);
3489 | this.showToast(this.t('toast.errorPerformingSearch', 'Error performing search'), 'error');
3490 | } finally {
3491 | this.setLoading(false);
3492 | }
3493 | }
3494 |
3495 | /**
3496 | * Toast notification system
3497 | */
3498 | showToast(message, type = 'info', duration = 5000) {
3499 | const container = document.getElementById('toastContainer');
3500 | const toast = document.createElement('div');
3501 | toast.className = `toast ${type}`;
3502 | toast.textContent = message;
3503 |
3504 | container.appendChild(toast);
3505 |
3506 | // Auto-remove after duration
3507 | setTimeout(() => {
3508 | toast.remove();
3509 | }, duration);
3510 |
3511 | // Click to remove
3512 | toast.addEventListener('click', () => {
3513 | toast.remove();
3514 | });
3515 | }
3516 |
3517 | /**
3518 | * Utility: Debounce function
3519 | */
3520 | debounce(func, wait) {
3521 | let timeout;
3522 | return function executedFunction(...args) {
3523 | const later = () => {
3524 | clearTimeout(timeout);
3525 | func(...args);
3526 | };
3527 | clearTimeout(timeout);
3528 | timeout = setTimeout(later, wait);
3529 | };
3530 | }
3531 |
3532 | /**
3533 | * Load settings from localStorage
3534 | */
3535 | loadSettings() {
3536 | try {
3537 | const saved = localStorage.getItem('memoryDashboardSettings');
3538 | if (saved) {
3539 | this.settings = { ...this.settings, ...JSON.parse(saved) };
3540 | }
3541 | } catch (error) {
3542 | console.warn('Failed to load settings:', error);
3543 | }
3544 | }
3545 |
3546 | /**
3547 | * Save settings to localStorage
3548 | */
3549 | saveSettingsToStorage() {
3550 | try {
3551 | localStorage.setItem('memoryDashboardSettings', JSON.stringify(this.settings));
3552 | } catch (error) {
3553 | console.error('Failed to save settings:', error);
3554 | this.showToast(
3555 | this.t('toast.saveSettingsFailed', 'Failed to save settings. Your preferences will not be persisted.'),
3556 | 'error'
3557 | );
3558 | }
3559 | }
3560 |
3561 | /**
3562 | * Apply theme to the page
3563 | */
3564 | applyTheme(theme = this.settings.theme) {
3565 | const isDark = theme === 'dark';
3566 | document.body.classList.toggle('dark-mode', isDark);
3567 |
3568 | // Configure Chart.js colors for dark mode
3569 | if (typeof Chart !== 'undefined' && Chart.defaults) {
3570 | // Set global defaults for text and borders
3571 | if (Chart.defaults.color !== undefined) {
3572 | Chart.defaults.color = isDark ? '#f9fafb' : '#374151';
3573 | }
3574 | if (Chart.defaults.borderColor !== undefined) {
3575 | Chart.defaults.borderColor = isDark ? '#4b5563' : '#e5e7eb';
3576 | }
3577 |
3578 | // Redraw existing charts if Quality tab is visible
3579 | if (this.qualityDistributionChart) {
3580 | this.qualityDistributionChart.options.scales.y.ticks = {
3581 | ...this.qualityDistributionChart.options.scales.y.ticks,
3582 | color: isDark ? '#f9fafb' : '#374151'
3583 | };
3584 | this.qualityDistributionChart.options.scales.y.grid = {
3585 | ...this.qualityDistributionChart.options.scales.y.grid,
3586 | color: isDark ? '#374151' : '#e5e7eb'
3587 | };
3588 | this.qualityDistributionChart.options.scales.x = {
3589 | ...this.qualityDistributionChart.options.scales.x,
3590 | ticks: { color: isDark ? '#f9fafb' : '#374151' },
3591 | grid: { color: isDark ? '#374151' : '#e5e7eb' }
3592 | };
3593 | this.qualityDistributionChart.update();
3594 | }
3595 |
3596 | if (this.qualityProviderChart) {
3597 | this.qualityProviderChart.options.plugins.legend.labels = {
3598 | ...this.qualityProviderChart.options.plugins.legend.labels,
3599 | color: isDark ? '#f9fafb' : '#374151'
3600 | };
3601 | this.qualityProviderChart.update();
3602 | }
3603 | }
3604 |
3605 | // Toggle icon visibility using CSS classes
3606 | const sunIcon = document.getElementById('sunIcon');
3607 | const moonIcon = document.getElementById('moonIcon');
3608 | if (sunIcon && moonIcon) {
3609 | sunIcon.classList.toggle('hidden', isDark);
3610 | moonIcon.classList.toggle('hidden', !isDark);
3611 | }
3612 | }
3613 |
3614 | /**
3615 | * Toggle between light and dark theme
3616 | */
3617 | toggleTheme() {
3618 | const newTheme = this.settings.theme === 'dark' ? 'light' : 'dark';
3619 | this.settings.theme = newTheme;
3620 | this.applyTheme(newTheme);
3621 | this.saveSettingsToStorage();
3622 | this.showToast(
3623 | this.t('toast.themeSwitched', 'Switched to {theme} mode', { theme: newTheme }),
3624 | 'success'
3625 | );
3626 | }
3627 |
3628 | /**
3629 | * Open settings modal
3630 | */
3631 | async openSettingsModal() {
3632 | const modal = document.getElementById('settingsModal');
3633 |
3634 | // Populate form with current settings
3635 | document.getElementById('themeSelect').value = this.settings.theme;
3636 | document.getElementById('viewDensity').value = this.settings.viewDensity;
3637 | document.getElementById('previewLines').value = this.settings.previewLines;
3638 |
3639 | // Reset system info to loading state
3640 | this.resetSystemInfoLoadingState();
3641 |
3642 | // Load system information and backup status
3643 | await Promise.all([
3644 | this.loadSystemInfo(),
3645 | this.checkBackupStatus()
3646 | ]);
3647 |
3648 | this.openModal(modal);
3649 | }
3650 |
3651 | /**
3652 | * Reset system info fields to loading state
3653 | */
3654 | resetSystemInfoLoadingState() {
3655 | Object.keys(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(id => {
3656 | const element = document.getElementById(id);
3657 | if (element) {
3658 | element.textContent = 'Loading...';
3659 | }
3660 | });
3661 | }
3662 |
3663 | /**
3664 | * Load system information for settings modal
3665 | */
3666 | async loadSystemInfo() {
3667 | try {
3668 | // Use Promise.allSettled for robust error handling
3669 | const [healthResult, detailedHealthResult] = await Promise.allSettled([
3670 | this.apiCall('/health'),
3671 | this.apiCall('/health/detailed')
3672 | ]);
3673 |
3674 | const apiData = {
3675 | health: healthResult.status === 'fulfilled' ? healthResult.value : null,
3676 | detailedHealth: detailedHealthResult.status === 'fulfilled' ? detailedHealthResult.value : null
3677 | };
3678 |
3679 | // Update fields using configuration
3680 | Object.entries(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(([fieldId, config]) => {
3681 | const element = document.getElementById(fieldId);
3682 | if (!element) return;
3683 |
3684 | let value = null;
3685 | for (const source of config.sources) {
3686 | const apiResponse = apiData[source.api];
3687 | if (apiResponse) {
3688 | value = this.getNestedValue(apiResponse, source.path);
3689 | if (value !== undefined && value !== null) break;
3690 | }
3691 | }
3692 |
3693 | element.textContent = config.formatter(value);
3694 | });
3695 |
3696 | // Log warnings for failed API calls
3697 | if (healthResult.status === 'rejected') {
3698 | console.warn('Failed to load health endpoint:', healthResult.reason);
3699 | }
3700 | if (detailedHealthResult.status === 'rejected') {
3701 | console.warn('Failed to load detailed health endpoint:', detailedHealthResult.reason);
3702 | }
3703 | } catch (error) {
3704 | console.error('Unexpected error loading system info:', error);
3705 | // Set all system info fields that are still in loading state to error
3706 | Object.keys(MemoryDashboard.SYSTEM_INFO_CONFIG).forEach(id => {
3707 | const element = document.getElementById(id);
3708 | if (element && element.textContent === 'Loading...') {
3709 | element.textContent = 'Error';
3710 | }
3711 | });
3712 | }
3713 | }
3714 |
3715 | /**
3716 | * Get nested object value by path string
3717 | * @param {Object} obj - Object to traverse
3718 | * @param {string} path - Dot-separated path (e.g., 'storage.primary_stats.embedding_model')
3719 | * @returns {*} Value at path or undefined
3720 | */
3721 | getNestedValue(obj, path) {
3722 | return path.split('.').reduce((current, key) => current?.[key], obj);
3723 | }
3724 |
3725 | /**
3726 | * Format uptime seconds into human readable string
3727 | * @param {number} seconds - Uptime in seconds
3728 | * @returns {string} Formatted uptime string
3729 | */
3730 | static formatUptime(seconds) {
3731 | const days = Math.floor(seconds / 86400);
3732 | const hours = Math.floor((seconds % 86400) / 3600);
3733 | const minutes = Math.floor((seconds % 3600) / 60);
3734 |
3735 | const parts = [];
3736 | if (days > 0) parts.push(`${days}d`);
3737 | if (hours > 0) parts.push(`${hours}h`);
3738 | if (minutes > 0) parts.push(`${minutes}m`);
3739 |
3740 | return parts.length > 0 ? parts.join(' ') : '< 1m';
3741 | }
3742 |
3743 | /**
3744 | * Save settings from modal
3745 | */
3746 | saveSettings() {
3747 | // Get values from form
3748 | const theme = document.getElementById('themeSelect').value;
3749 | const viewDensity = document.getElementById('viewDensity').value;
3750 | const previewLines = parseInt(document.getElementById('previewLines').value, 10);
3751 |
3752 | // Update settings
3753 | this.settings.theme = theme;
3754 | this.settings.viewDensity = viewDensity;
3755 | this.settings.previewLines = previewLines;
3756 |
3757 | // Apply changes
3758 | this.applyTheme(theme);
3759 | this.saveSettingsToStorage();
3760 |
3761 | // Close modal and show confirmation
3762 | this.closeModal(document.getElementById('settingsModal'));
3763 | this.showToast(this.t('toast.settingsSaved', 'Settings saved successfully'), 'success');
3764 | }
3765 |
3766 | // ===== MANAGE TAB METHODS =====
3767 |
3768 | /**
3769 | * Load manage tab data
3770 | */
3771 | async loadManageData() {
3772 | try {
3773 | // Load tag statistics for bulk operations
3774 | await this.loadTagSelectOptions();
3775 | await this.loadTagManagementStats();
3776 | await this.loadUntaggedCount();
3777 | } catch (error) {
3778 | console.error('Failed to load manage data:', error);
3779 | this.showToast(this.t('toast.loadManageFail', 'Failed to load management data'), 'error');
3780 | }
3781 | }
3782 |
3783 | /**
3784 | * Load count of untagged memories
3785 | */
3786 | async loadUntaggedCount() {
3787 | try {
3788 | const response = await fetch(`${this.apiBase}/manage/untagged/count`);
3789 | if (!response.ok) return;
3790 |
3791 | const data = await response.json();
3792 | const count = data.count || 0;
3793 | const countSpan = document.getElementById('untaggedCount');
3794 | const card = document.getElementById('deleteUntaggedCard');
3795 |
3796 | if (countSpan) {
3797 | countSpan.textContent = count;
3798 | }
3799 | if (card) {
3800 | card.style.display = count > 0 ? '' : 'none';
3801 | }
3802 | } catch (error) {
3803 | console.error('Failed to load untagged count:', error);
3804 | }
3805 | }
3806 |
3807 | /**
3808 | * Load tag options for bulk delete select
3809 | */
3810 | async loadTagSelectOptions() {
3811 | try {
3812 | const response = await fetch(`${this.apiBase}/manage/tags/stats`);
3813 | if (!response.ok) throw new Error('Failed to load tags');
3814 |
3815 | const data = await response.json();
3816 | const select = document.getElementById('deleteTagSelect');
3817 | if (!select) return;
3818 |
3819 | // Clear existing options except the first
3820 | while (select.children.length > 1) {
3821 | select.removeChild(select.lastChild);
3822 | }
3823 |
3824 | // Add tag options
3825 | data.tags.forEach(tagStat => {
3826 | const option = document.createElement('option');
3827 | option.value = tagStat.tag;
3828 | option.textContent = `${tagStat.tag} (${tagStat.count} memories)`;
3829 | option.dataset.count = tagStat.count; // Store count in data attribute
3830 | select.appendChild(option);
3831 | });
3832 | } catch (error) {
3833 | console.error('Failed to load tag options:', error);
3834 | }
3835 | }
3836 |
3837 | /**
3838 | * Load tag management statistics
3839 | */
3840 | async loadTagManagementStats() {
3841 | const container = document.getElementById('tagManagementContainer');
3842 | if (!container) return;
3843 |
3844 | try {
3845 | const response = await fetch(`${this.apiBase}/manage/tags/stats`);
3846 | if (!response.ok) throw new Error('Failed to load tag stats');
3847 |
3848 | const data = await response.json();
3849 | this.renderTagManagementTable(data);
3850 | } catch (error) {
3851 | console.error('Failed to load tag management stats:', error);
3852 | container.innerHTML = '<p class="error">Failed to load tag statistics</p>';
3853 | }
3854 | }
3855 |
3856 | /**
3857 | * Render tag management table
3858 | */
3859 | renderTagManagementTable(data) {
3860 | const container = document.getElementById('tagManagementContainer');
3861 | if (!container) return;
3862 |
3863 | let html = '<table class="tag-stats-table">';
3864 | html += '<thead><tr>';
3865 | html += '<th>Tag</th>';
3866 | html += '<th>Count</th>';
3867 | html += '<th>Actions</th>';
3868 | html += '</tr></thead><tbody>';
3869 |
3870 | data.tags.forEach(tagStat => {
3871 | html += '<tr>';
3872 | html += `<td class="tag-name">${tagStat.tag}</td>`;
3873 | html += `<td class="tag-count">${tagStat.count}</td>`;
3874 | html += '<td class="tag-actions">';
3875 | html += `<button class="tag-action-btn" data-action="rename-tag" data-tag="${this.escapeHtml(tagStat.tag)}">Rename</button>`;
3876 | html += `<button class="tag-action-btn danger" data-action="delete-tag" data-tag="${this.escapeHtml(tagStat.tag)}" data-count="${tagStat.count}">Delete</button>`;
3877 | html += '</td></tr>';
3878 | });
3879 |
3880 | html += '</tbody></table>';
3881 | container.innerHTML = html;
3882 |
3883 | // Add event listeners for tag actions
3884 | container.addEventListener('click', (e) => {
3885 | const button = e.target.closest('[data-action]');
3886 | if (!button) return;
3887 |
3888 | const action = button.dataset.action;
3889 | const tag = button.dataset.tag;
3890 |
3891 | if (action === 'rename-tag') {
3892 | this.renameTag(tag);
3893 | } else if (action === 'delete-tag') {
3894 | const count = parseInt(button.dataset.count, 10);
3895 | this.deleteTag(tag, count);
3896 | }
3897 | });
3898 | }
3899 |
3900 | /**
3901 | * Handle bulk delete by tag
3902 | */
3903 | async handleBulkDeleteByTag() {
3904 | const select = document.getElementById('deleteTagSelect');
3905 | const tag = select.value;
3906 |
3907 | if (!tag) {
3908 | this.showToast(this.t('toast.selectTagToDelete', 'Please select a tag to delete'), 'warning');
3909 | return;
3910 | }
3911 |
3912 | // Extract count from data attribute
3913 | const option = select.querySelector(`option[value="${tag}"]`);
3914 | const count = parseInt(option.dataset.count, 10) || 0;
3915 |
3916 | if (!await this.confirmBulkOperation(`Delete ${count} memories with tag "${tag}"?`)) {
3917 | return;
3918 | }
3919 |
3920 | this.setLoading(true);
3921 | try {
3922 | const response = await fetch(`${this.apiBase}/manage/bulk-delete`, {
3923 | method: 'POST',
3924 | headers: { 'Content-Type': 'application/json' },
3925 | body: JSON.stringify({
3926 | tag: tag,
3927 | confirm_count: count
3928 | })
3929 | });
3930 |
3931 | const result = await response.json();
3932 |
3933 | // Check HTTP status first (handles HTTPException responses with 'detail')
3934 | if (!response.ok) {
3935 | const errorMsg = result.detail || result.message || 'Unknown error';
3936 | this.showToast(errorMsg, 'error');
3937 | return;
3938 | }
3939 |
3940 | if (result.success) {
3941 | this.showToast(result.message, 'success');
3942 | await this.loadManageData(); // Refresh data
3943 | await this.loadDashboardData(); // Refresh dashboard stats
3944 | } else {
3945 | this.showToast(result.message || 'Operation failed', 'error');
3946 | }
3947 | } catch (error) {
3948 | console.error('Bulk delete failed:', error);
3949 | this.showToast(this.t('toast.bulkDeleteFailed', 'Bulk delete operation failed'), 'error');
3950 | } finally {
3951 | this.setLoading(false);
3952 | }
3953 | }
3954 |
3955 | /**
3956 | * Handle cleanup duplicates
3957 | */
3958 | async handleCleanupDuplicates() {
3959 | if (!await this.confirmBulkOperation('Remove all duplicate memories?')) {
3960 | return;
3961 | }
3962 |
3963 | this.setLoading(true);
3964 | try {
3965 | const response = await fetch(`${this.apiBase}/manage/cleanup-duplicates`, {
3966 | method: 'POST'
3967 | });
3968 |
3969 | const result = await response.json();
3970 |
3971 | if (!response.ok) {
3972 | const errorMsg = result.detail || result.message || 'Unknown error';
3973 | this.showToast(errorMsg, 'error');
3974 | return;
3975 | }
3976 |
3977 | if (result.success) {
3978 | this.showToast(result.message, 'success');
3979 | await this.loadManageData();
3980 | await this.loadDashboardData();
3981 | } else {
3982 | this.showToast(result.message || 'Operation failed', 'error');
3983 | }
3984 | } catch (error) {
3985 | console.error('Cleanup duplicates failed:', error);
3986 | this.showToast(this.t('toast.cleanupFailed', 'Cleanup operation failed'), 'error');
3987 | } finally {
3988 | this.setLoading(false);
3989 | }
3990 | }
3991 |
3992 | /**
3993 | * Handle bulk delete by date
3994 | */
3995 | async handleBulkDeleteByDate() {
3996 | const dateInput = document.getElementById('deleteDateInput');
3997 | const date = dateInput.value;
3998 |
3999 | if (!date) {
4000 | this.showToast(this.t('toast.selectDate', 'Please select a date'), 'warning');
4001 | return;
4002 | }
4003 |
4004 | if (!await this.confirmBulkOperation(`Delete all memories before ${date}?`)) {
4005 | return;
4006 | }
4007 |
4008 | this.setLoading(true);
4009 | try {
4010 | const response = await fetch(`${this.apiBase}/manage/bulk-delete`, {
4011 | method: 'POST',
4012 | headers: { 'Content-Type': 'application/json' },
4013 | body: JSON.stringify({
4014 | before_date: date
4015 | })
4016 | });
4017 |
4018 | const result = await response.json();
4019 |
4020 | if (!response.ok) {
4021 | const errorMsg = result.detail || result.message || 'Unknown error';
4022 | this.showToast(errorMsg, 'error');
4023 | return;
4024 | }
4025 |
4026 | if (result.success) {
4027 | this.showToast(result.message, 'success');
4028 | await this.loadManageData();
4029 | await this.loadDashboardData();
4030 | } else {
4031 | this.showToast(result.message || 'Operation failed', 'error');
4032 | }
4033 | } catch (error) {
4034 | console.error('Bulk delete by date failed:', error);
4035 | this.showToast(this.t('toast.bulkDeleteFailed', 'Bulk delete operation failed'), 'error');
4036 | } finally {
4037 | this.setLoading(false);
4038 | }
4039 | }
4040 |
4041 | /**
4042 | * Handle delete untagged memories
4043 | */
4044 | async handleDeleteUntagged() {
4045 | // Get current count
4046 | const countSpan = document.getElementById('untaggedCount');
4047 | const count = parseInt(countSpan?.textContent || '0', 10);
4048 |
4049 | if (count === 0) {
4050 | this.showToast('No untagged memories to delete', 'info');
4051 | return;
4052 | }
4053 |
4054 | if (!await this.confirmBulkOperation(`Delete ${count} memories without tags?`)) {
4055 | return;
4056 | }
4057 |
4058 | this.setLoading(true);
4059 | try {
4060 | const response = await fetch(`${this.apiBase}/manage/delete-untagged?confirm_count=${count}`, {
4061 | method: 'POST'
4062 | });
4063 |
4064 | const result = await response.json();
4065 |
4066 | if (!response.ok) {
4067 | const errorMsg = result.detail || result.message || 'Unknown error';
4068 | this.showToast(errorMsg, 'error');
4069 | return;
4070 | }
4071 |
4072 | if (result.success) {
4073 | this.showToast(result.message, 'success');
4074 | await this.loadManageData();
4075 | await this.loadDashboardData();
4076 | } else {
4077 | this.showToast(result.message || 'Operation failed', 'error');
4078 | }
4079 | } catch (error) {
4080 | console.error('Delete untagged failed:', error);
4081 | this.showToast('Delete untagged operation failed', 'error');
4082 | } finally {
4083 | this.setLoading(false);
4084 | }
4085 | }
4086 |
4087 | /**
4088 | * Handle database optimization
4089 | */
4090 | async handleOptimizeDatabase() {
4091 | this.showToast(this.t('toast.dbOptimizeTodo', 'Database optimization not yet implemented'), 'warning');
4092 | }
4093 |
4094 | /**
4095 | * Handle index rebuild
4096 | */
4097 | async handleRebuildIndex() {
4098 | this.showToast(this.t('toast.indexRebuildTodo', 'Index rebuild not yet implemented'), 'warning');
4099 | }
4100 |
4101 | /**
4102 | * Rename a tag
4103 | */
4104 | async renameTag(oldTag) {
4105 | const newTag = prompt(`Rename tag "${oldTag}" to:`, oldTag);
4106 | if (!newTag || newTag === oldTag) return;
4107 |
4108 | this.showToast(this.t('toast.tagRenameTodo', 'Tag renaming not yet implemented'), 'warning');
4109 | }
4110 |
4111 | /**
4112 | * Delete a tag
4113 | */
4114 | async deleteTag(tag, count) {
4115 | if (!await this.confirmBulkOperation(`Delete tag "${tag}" from ${count} memories?`)) {
4116 | return;
4117 | }
4118 |
4119 | this.showToast(this.t('toast.tagDeleteTodo', 'Tag deletion not yet implemented'), 'warning');
4120 | }
4121 |
4122 | // ===== ANALYTICS TAB METHODS =====
4123 |
4124 | /**
4125 | * Load analytics tab data
4126 | */
4127 | async loadAnalyticsData() {
4128 | try {
4129 | await Promise.all([
4130 | this.loadAnalyticsOverview(),
4131 | this.loadMemoryGrowthChart(),
4132 | this.loadTagUsageChart(),
4133 | this.loadMemoryTypesChart(),
4134 | this.loadActivityHeatmapChart(),
4135 | this.loadTopTagsReport(),
4136 | this.loadRecentActivityReport(),
4137 | this.loadStorageReport()
4138 | ]);
4139 | } catch (error) {
4140 | console.error('Failed to load analytics data:', error);
4141 | this.showToast(this.t('toast.loadAnalyticsFail', 'Failed to load analytics data'), 'error');
4142 | }
4143 | }
4144 |
4145 | /**
4146 | * Load analytics overview metrics
4147 | */
4148 | async loadAnalyticsOverview() {
4149 | try {
4150 | const response = await fetch(`${this.apiBase}/analytics/overview`);
4151 | if (!response.ok) throw new Error('Failed to load overview');
4152 |
4153 | const data = await response.json();
4154 |
4155 | // Update metric cards
4156 | this.updateElementText('analyticsTotalMemories', data.total_memories || 0);
4157 | this.updateElementText('analyticsThisWeek', data.memories_this_week || 0);
4158 | this.updateElementText('analyticsUniqueTags', data.unique_tags || 0);
4159 | this.updateElementText('analyticsDbSize', data.database_size_mb ?
4160 | `${data.database_size_mb.toFixed(1)} MB` : 'N/A');
4161 | } catch (error) {
4162 | console.error('Failed to load analytics overview:', error);
4163 | }
4164 | }
4165 |
4166 | /**
4167 | * Load memory growth chart
4168 | */
4169 | async loadMemoryGrowthChart() {
4170 | const container = document.getElementById('memoryGrowthChart');
4171 | const period = document.getElementById('growthPeriodSelect').value;
4172 |
4173 | if (!container) return;
4174 |
4175 | try {
4176 | const response = await fetch(`${this.apiBase}/analytics/memory-growth?period=${period}`);
4177 | if (!response.ok) throw new Error('Failed to load growth data');
4178 |
4179 | const data = await response.json();
4180 | this.renderMemoryGrowthChart(container, data);
4181 | } catch (error) {
4182 | console.error('Failed to load memory growth:', error);
4183 | container.innerHTML = '<p class="error">Failed to load growth chart</p>';
4184 | }
4185 | }
4186 |
4187 | /**
4188 | * Render memory growth chart
4189 | */
4190 | renderMemoryGrowthChart(container, data) {
4191 | if (!data.data_points || data.data_points.length === 0) {
4192 | container.innerHTML = '<p>No growth data available</p>';
4193 | return;
4194 | }
4195 |
4196 | // Find max count for scaling
4197 | const recentPoints = data.data_points.slice(-10);
4198 | const maxCount = Math.max(...recentPoints.map(p => p.count), 1);
4199 |
4200 | let html = '<div class="simple-chart">';
4201 |
4202 | recentPoints.forEach(point => {
4203 | // Normalize bar width relative to max, then convert to pixels (200px scale)
4204 | const barWidthPx = (point.count / maxCount) * 200;
4205 | const displayCount = point.count || 0;
4206 | const displayCumulative = point.cumulative || 0;
4207 | // Use label if available, otherwise fall back to date for backward compatibility
4208 | const displayLabel = point.label || point.date;
4209 |
4210 | html += `<div class="chart-row">
4211 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
4212 | <span class="chart-value">+${displayCount} <small>(${displayCumulative} total)</small></span>
4213 | <span class="chart-label">${displayLabel}</span>
4214 | </div>`;
4215 | });
4216 |
4217 | html += '</div>';
4218 | container.innerHTML = html;
4219 | }
4220 |
4221 | /**
4222 | * Load tag usage chart
4223 | */
4224 | async loadTagUsageChart() {
4225 | const container = document.getElementById('tagUsageChart');
4226 | if (!container) return;
4227 |
4228 | try {
4229 | const response = await fetch(`${this.apiBase}/analytics/tag-usage`);
4230 | if (!response.ok) throw new Error('Failed to load tag usage');
4231 |
4232 | const data = await response.json();
4233 | this.renderTagUsageChart(container, data);
4234 | } catch (error) {
4235 | console.error('Failed to load tag usage:', error);
4236 | container.innerHTML = '<p class="error">Failed to load tag usage chart</p>';
4237 | }
4238 | }
4239 |
4240 | /**
4241 | * Render tag usage chart
4242 | */
4243 | renderTagUsageChart(container, data) {
4244 | if (!data.tags || data.tags.length === 0) {
4245 | container.innerHTML = '<p>No tags found</p>';
4246 | return;
4247 | }
4248 |
4249 | // Filter tags with >10 memories, aggregate the rest
4250 | const significantTags = data.tags.filter(t => t.count > 10);
4251 | const minorTags = data.tags.filter(t => t.count <= 10);
4252 |
4253 | let html = '<div class="simple-chart">';
4254 |
4255 | // Render significant tags
4256 | significantTags.forEach(tag => {
4257 | const barWidthPx = (tag.percentage / 100) * 200; // Convert percentage to pixels (200px scale)
4258 | html += `<div class="chart-row">
4259 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
4260 | <span class="chart-value">${tag.count} (${tag.percentage}%)</span>
4261 | <span class="chart-label">${tag.tag}</span>
4262 | </div>`;
4263 | });
4264 |
4265 | // Add "diverse" category if there are minor tags
4266 | if (minorTags.length > 0) {
4267 | const diverseCount = minorTags.reduce((sum, t) => sum + t.count, 0);
4268 | const diversePercentage = minorTags.reduce((sum, t) => sum + t.percentage, 0);
4269 | const barWidthPx = (diversePercentage / 100) * 200;
4270 | html += `<div class="chart-row">
4271 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
4272 | <span class="chart-value">${diverseCount} (${diversePercentage.toFixed(1)}%)</span>
4273 | <span class="chart-label" title="${minorTags.length} tags with ≤10 memories each">diverse (${minorTags.length} tags)</span>
4274 | </div>`;
4275 | }
4276 |
4277 | html += '</div>';
4278 | container.innerHTML = html;
4279 | }
4280 |
4281 | /**
4282 | * Load memory types chart
4283 | */
4284 | async loadMemoryTypesChart() {
4285 | const container = document.getElementById('memoryTypesChart');
4286 | if (!container) return;
4287 |
4288 | try {
4289 | const response = await fetch(`${this.apiBase}/analytics/memory-types`);
4290 | if (!response.ok) throw new Error('Failed to load memory types');
4291 |
4292 | const data = await response.json();
4293 | this.renderMemoryTypesChart(container, data);
4294 | } catch (error) {
4295 | console.error('Failed to load memory types:', error);
4296 | container.innerHTML = '<p class="error">Failed to load memory types chart</p>';
4297 | }
4298 | }
4299 |
4300 | /**
4301 | * Render memory types chart
4302 | */
4303 | renderMemoryTypesChart(container, data) {
4304 | if (!data.types || data.types.length === 0) {
4305 | container.innerHTML = '<p>No memory types found</p>';
4306 | return;
4307 | }
4308 |
4309 | // Filter types with >10 memories, aggregate the rest
4310 | const significantTypes = data.types.filter(t => t.count > 10);
4311 | const minorTypes = data.types.filter(t => t.count <= 10);
4312 |
4313 | let html = '<div class="simple-chart">';
4314 |
4315 | // Render significant types
4316 | significantTypes.forEach(type => {
4317 | const barWidthPx = (type.percentage / 100) * 200; // Convert percentage to pixels (200px scale)
4318 | const typeName = type.memory_type || 'untyped';
4319 | html += `<div class="chart-row">
4320 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
4321 | <span class="chart-value">${type.count} (${type.percentage.toFixed(1)}%)</span>
4322 | <span class="chart-label" title="${typeName}">${typeName}</span>
4323 | </div>`;
4324 | });
4325 |
4326 | // Add "diverse" category if there are minor types
4327 | if (minorTypes.length > 0) {
4328 | const diverseCount = minorTypes.reduce((sum, t) => sum + t.count, 0);
4329 | const diversePercentage = minorTypes.reduce((sum, t) => sum + t.percentage, 0);
4330 | const barWidthPx = (diversePercentage / 100) * 200;
4331 | html += `<div class="chart-row">
4332 | <div class="chart-bar" style="width: ${barWidthPx}px"></div>
4333 | <span class="chart-value">${diverseCount} (${diversePercentage.toFixed(1)}%)</span>
4334 | <span class="chart-label" title="${minorTypes.length} types with ≤10 memories each">diverse (${minorTypes.length} types)</span>
4335 | </div>`;
4336 | }
4337 |
4338 | html += '</div>';
4339 | container.innerHTML = html;
4340 | }
4341 |
4342 | /**
4343 | * Load top tags report
4344 | */
4345 | async loadTopTagsReport() {
4346 | const container = document.getElementById('topTagsList');
4347 | const period = document.getElementById('topTagsPeriodSelect')?.value || '30d';
4348 | if (!container) return;
4349 |
4350 | try {
4351 | const response = await fetch(`${this.apiBase}/analytics/top-tags?period=${period}`);
4352 | if (!response.ok) throw new Error('Failed to load top tags');
4353 |
4354 | const data = await response.json();
4355 | this.renderTopTagsReport(container, data);
4356 | } catch (error) {
4357 | console.error('Failed to load top tags:', error);
4358 | container.innerHTML = '<p class="error">Failed to load top tags</p>';
4359 | }
4360 | }
4361 |
4362 | /**
4363 | * Render top tags report
4364 | */
4365 | renderTopTagsReport(container, data) {
4366 | if (!data.tags || data.tags.length === 0) {
4367 | container.innerHTML = '<p>No tags found</p>';
4368 | return;
4369 | }
4370 |
4371 | let html = '<div class="enhanced-tags-report">';
4372 | html += `<div class="report-period">Period: ${data.period}</div>`;
4373 | html += '<ul class="tags-list">';
4374 | data.tags.slice(0, 10).forEach(tag => {
4375 | const trendIcon = tag.trending ? '📈' : '';
4376 | const growthText = tag.growth_rate !== null ? ` (${tag.growth_rate > 0 ? '+' : ''}${tag.growth_rate}%)` : '';
4377 | html += `<li>
4378 | <div class="tag-header">
4379 | <strong>${tag.tag}</strong>${trendIcon}
4380 | <span class="tag-count">${tag.count} memories (${tag.percentage}%)${growthText}</span>
4381 | </div>`;
4382 | if (tag.co_occurring_tags && tag.co_occurring_tags.length > 0) {
4383 | html += '<div class="tag-cooccurrence">Often with: ';
4384 | html += tag.co_occurring_tags.slice(0, 3).map(co => `${co.tag} (${co.strength.toFixed(2)})`).join(', ');
4385 | html += '</div>';
4386 | }
4387 | html += '</li>';
4388 | });
4389 | html += '</ul></div>';
4390 |
4391 | container.innerHTML = html;
4392 | }
4393 |
4394 | /**
4395 | * Load recent activity report
4396 | */
4397 | async loadRecentActivityReport() {
4398 | const container = document.getElementById('recentActivityList');
4399 | const granularity = document.getElementById('activityGranularitySelect')?.value || 'daily';
4400 | if (!container) return;
4401 |
4402 | try {
4403 | const response = await fetch(`${this.apiBase}/analytics/activity-breakdown?granularity=${granularity}`);
4404 | if (!response.ok) throw new Error('Failed to load activity breakdown');
4405 |
4406 | const data = await response.json();
4407 | this.renderRecentActivityReport(container, data);
4408 | } catch (error) {
4409 | console.error('Failed to load recent activity:', error);
4410 | container.innerHTML = '<p class="error">Failed to load recent activity</p>';
4411 | }
4412 | }
4413 |
4414 | /**
4415 | * Render recent activity report
4416 | */
4417 | renderRecentActivityReport(container, data) {
4418 | let html = '<div class="activity-breakdown">';
4419 |
4420 | // Summary stats
4421 | html += '<div class="activity-summary">';
4422 | html += `<div class="activity-stat"><strong>Active Days:</strong> ${data.active_days}/${data.total_days}</div>`;
4423 | html += `<div class="activity-stat"><strong>Current Streak:</strong> ${data.current_streak} days</div>`;
4424 | html += `<div class="activity-stat"><strong>Longest Streak:</strong> ${data.longest_streak} days</div>`;
4425 | html += '</div>';
4426 |
4427 | if (data.peak_times && data.peak_times.length > 0) {
4428 | html += '<div class="peak-times">';
4429 | html += '<strong>Peak Times:</strong> ';
4430 | html += data.peak_times.join(', ');
4431 | html += '</div>';
4432 | }
4433 |
4434 | // Activity breakdown chart
4435 | if (data.breakdown && data.breakdown.length > 0) {
4436 | html += '<div class="activity-chart">';
4437 | // Calculate total count for percentage-based distribution
4438 | const totalCount = data.breakdown.reduce((sum, d) => sum + d.count, 0);
4439 | const maxCount = Math.max(...data.breakdown.map(d => d.count));
4440 |
4441 | data.breakdown.forEach(item => {
4442 | // Show percentage of total activity, with minimum width for visibility
4443 | const percentage = totalCount > 0 ? (item.count / totalCount * 100) : 0;
4444 | // Use percentage for bar width, but scale up for better visualization
4445 | // Use max count for scaling to ensure largest bar reaches reasonable width
4446 | const barWidth = totalCount > 0 ? (item.count / maxCount * 100) : 0;
4447 |
4448 | html += `<div class="activity-bar-row">
4449 | <span class="activity-label">${item.label}</span>
4450 | <div class="activity-bar" style="width: ${barWidth}%" title="${item.count} memories (${percentage.toFixed(1)}%)"></div>
4451 | <span class="activity-count">${item.count} (${percentage.toFixed(1)}%)</span>
4452 | </div>`;
4453 | });
4454 |
4455 | html += '</div>';
4456 | }
4457 |
4458 | html += '</div>';
4459 | container.innerHTML = html;
4460 | }
4461 |
4462 | /**
4463 | * Load activity heatmap chart
4464 | */
4465 | async loadActivityHeatmapChart() {
4466 | const container = document.getElementById('activityHeatmapChart');
4467 | const period = document.getElementById('heatmapPeriodSelect').value;
4468 |
4469 | if (!container) return;
4470 |
4471 | try {
4472 | const response = await fetch(`${this.apiBase}/analytics/activity-heatmap?days=${period}`);
4473 | if (!response.ok) throw new Error('Failed to load heatmap data');
4474 |
4475 | const data = await response.json();
4476 | this.renderActivityHeatmapChart(container, data);
4477 | } catch (error) {
4478 | console.error('Failed to load activity heatmap:', error);
4479 | container.innerHTML = '<p class="error">Failed to load activity heatmap</p>';
4480 | }
4481 | }
4482 |
4483 | /**
4484 | * Render activity heatmap chart
4485 | */
4486 | renderActivityHeatmapChart(container, data) {
4487 | if (!data.data || data.data.length === 0) {
4488 | container.innerHTML = '<p>No activity data available</p>';
4489 | return;
4490 | }
4491 |
4492 | // Create calendar grid
4493 | let html = '<div class="activity-heatmap">';
4494 | html += '<div class="heatmap-stats">';
4495 | html += `<span>${data.total_days} active days</span>`;
4496 | html += `<span>Max: ${data.max_count} memories/day</span>`;
4497 | html += '</div>';
4498 |
4499 | // Group by months
4500 | const months = {};
4501 | data.data.forEach(day => {
4502 | const date = new Date(day.date);
4503 | const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
4504 | if (!months[monthKey]) {
4505 | months[monthKey] = [];
4506 | }
4507 | months[monthKey].push(day);
4508 | });
4509 |
4510 | // Render each month
4511 | Object.keys(months).sort().reverse().forEach(monthKey => {
4512 | const [year, month] = monthKey.split('-');
4513 | const monthName = new Date(year, month - 1).toLocaleString('default', { month: 'short' });
4514 |
4515 | html += `<div class="heatmap-month">`;
4516 | html += `<div class="month-label">${monthName} ${year}</div>`;
4517 | html += '<div class="month-grid">';
4518 |
4519 | // Create 7x6 grid (weeks x days)
4520 | const monthData = months[monthKey];
4521 | // Parse date in local timezone to avoid day-of-week shifting near timezone boundaries
4522 | const [fYear, fMonth, fDay] = monthData[0].date.split('-').map(Number);
4523 | const firstDay = new Date(fYear, fMonth - 1, fDay).getDay();
4524 |
4525 | // Add empty cells for days before month starts
4526 | for (let i = 0; i < firstDay; i++) {
4527 | html += '<div class="heatmap-cell empty"></div>';
4528 | }
4529 |
4530 | // Add cells for each day
4531 | monthData.forEach(day => {
4532 | const level = day.level;
4533 | const tooltip = `${day.date}: ${day.count} memories`;
4534 | html += `<div class="heatmap-cell level-${level}" title="${tooltip}"></div>`;
4535 | });
4536 |
4537 | html += '</div></div>';
4538 | });
4539 |
4540 | // Legend
4541 | html += '<div class="heatmap-legend">';
4542 | html += '<span>Less</span>';
4543 | for (let i = 0; i <= 4; i++) {
4544 | html += `<div class="legend-cell level-${i}"></div>`;
4545 | }
4546 | html += '<span>More</span>';
4547 | html += '</div>';
4548 |
4549 | html += '</div>';
4550 | container.innerHTML = html;
4551 | }
4552 |
4553 | /**
4554 | * Handle heatmap period change
4555 | */
4556 | async handleHeatmapPeriodChange() {
4557 | await this.loadActivityHeatmapChart();
4558 | }
4559 |
4560 | /**
4561 | * Handle top tags period change
4562 | */
4563 | async handleTopTagsPeriodChange() {
4564 | await this.loadTopTagsReport();
4565 | }
4566 |
4567 | /**
4568 | * Handle activity granularity change
4569 | */
4570 | async handleActivityGranularityChange() {
4571 | await this.loadRecentActivityReport();
4572 | }
4573 |
4574 | /**
4575 | * Load storage report
4576 | */
4577 | async loadStorageReport() {
4578 | const container = document.getElementById('storageReport');
4579 | if (!container) return;
4580 |
4581 | try {
4582 | const response = await fetch(`${this.apiBase}/analytics/storage-stats`);
4583 | if (!response.ok) throw new Error('Failed to load storage stats');
4584 |
4585 | const data = await response.json();
4586 | this.renderStorageReport(container, data);
4587 | } catch (error) {
4588 | console.error('Failed to load storage report:', error);
4589 | container.innerHTML = '<p class="error">Failed to load storage report</p>';
4590 | }
4591 | }
4592 |
4593 | /**
4594 | * Render storage report
4595 | */
4596 | renderStorageReport(container, data) {
4597 | let html = '<div class="storage-report">';
4598 |
4599 | // Summary stats
4600 | html += '<div class="storage-summary">';
4601 | html += `<div class="storage-stat"><strong>Total Size:</strong> ${data.total_size_mb} MB</div>`;
4602 | html += `<div class="storage-stat"><strong>Average Memory:</strong> ${data.average_memory_size} chars</div>`;
4603 | html += `<div class="storage-stat"><strong>Efficiency:</strong> ${data.storage_efficiency}%</div>`;
4604 | html += '</div>';
4605 |
4606 | // Largest memories
4607 | if (data.largest_memories && data.largest_memories.length > 0) {
4608 | html += '<h4>Largest Memories</h4>';
4609 | html += '<ul class="largest-memories">';
4610 | data.largest_memories.slice(0, 5).forEach(memory => {
4611 | // Backend provides created_at as ISO string, not timestamp
4612 | const date = memory.created_at ? new Date(memory.created_at).toLocaleDateString() : 'Unknown';
4613 | // Backend provides size_kb and preview (not size and content_preview)
4614 | const sizeDisplay = memory.size_kb ? `${memory.size_kb} KB` : `${memory.size_bytes || 0} bytes`;
4615 | html += `<li>
4616 | <div class="memory-size">${sizeDisplay}</div>
4617 | <div class="memory-preview">${this.escapeHtml(memory.preview || '')}</div>
4618 | <div class="memory-meta">${date} • Tags: ${memory.tags.join(', ') || 'none'}</div>
4619 | </li>`;
4620 | });
4621 | html += '</ul>';
4622 | }
4623 |
4624 | html += '</div>';
4625 | container.innerHTML = html;
4626 | }
4627 |
4628 | /**
4629 | * Handle growth period change
4630 | */
4631 | async handleGrowthPeriodChange() {
4632 | await this.loadMemoryGrowthChart();
4633 | }
4634 |
4635 | // ===== QUALITY ANALYTICS METHODS =====
4636 |
4637 | /**
4638 | * Load quality analytics dashboard
4639 | */
4640 | async loadQualityAnalytics() {
4641 | try {
4642 | const response = await fetch(`${this.apiBase}/quality/distribution`);
4643 | if (!response.ok) throw new Error('Failed to load quality analytics');
4644 |
4645 | const data = await response.json();
4646 |
4647 | // Update summary stats
4648 | this.updateElementText('quality-total-memories', data.total_memories.toLocaleString());
4649 | this.updateElementText('quality-high-count', data.high_quality_count.toLocaleString());
4650 | this.updateElementText('quality-medium-count', data.medium_quality_count.toLocaleString());
4651 | this.updateElementText('quality-low-count', data.low_quality_count.toLocaleString());
4652 | this.updateElementText('quality-average-score', data.average_score.toFixed(2));
4653 |
4654 | // Render charts
4655 | this.renderQualityDistributionChart(data);
4656 | this.renderQualityProviderChart(data.provider_breakdown);
4657 |
4658 | // Render top/bottom memories
4659 | this.renderTopQualityMemories(data.top_memories);
4660 | this.renderBottomQualityMemories(data.bottom_memories);
4661 |
4662 | } catch (error) {
4663 | console.error('Failed to load quality analytics:', error);
4664 | this.showToast('Failed to load quality analytics', 'error');
4665 | }
4666 | }
4667 |
4668 | /**
4669 | * Render quality distribution bar chart
4670 | */
4671 | renderQualityDistributionChart(data) {
4672 | const canvas = document.getElementById('quality-distribution-chart');
4673 | if (!canvas) return;
4674 |
4675 | // Destroy existing chart if present
4676 | if (this.qualityDistributionChart) {
4677 | this.qualityDistributionChart.destroy();
4678 | }
4679 |
4680 | const ctx = canvas.getContext('2d');
4681 | const isDark = document.body.classList.contains('dark-mode');
4682 | const textColor = isDark ? '#f9fafb' : '#374151';
4683 | const gridColor = isDark ? '#374151' : '#e5e7eb';
4684 |
4685 | this.qualityDistributionChart = new Chart(ctx, {
4686 | type: 'bar',
4687 | data: {
4688 | labels: ['Low (<0.5)', 'Medium (0.5-0.7)', 'High (≥0.7)'],
4689 | datasets: [{
4690 | label: 'Number of Memories',
4691 | data: [
4692 | data.low_quality_count,
4693 | data.medium_quality_count,
4694 | data.high_quality_count
4695 | ],
4696 | backgroundColor: [
4697 | '#F8D7DA', // Low
4698 | '#FFF3CD', // Medium
4699 | '#D4EDDA' // High
4700 | ],
4701 | borderColor: [
4702 | '#F5C6CB',
4703 | '#FFEAA7',
4704 | '#C3E6CB'
4705 | ],
4706 | borderWidth: 1
4707 | }]
4708 | },
4709 | options: {
4710 | responsive: true,
4711 | maintainAspectRatio: true,
4712 | scales: {
4713 | y: {
4714 | beginAtZero: true,
4715 | title: {
4716 | display: true,
4717 | text: 'Count',
4718 | color: textColor
4719 | },
4720 | ticks: {
4721 | color: textColor
4722 | },
4723 | grid: {
4724 | color: gridColor
4725 | }
4726 | },
4727 | x: {
4728 | ticks: {
4729 | color: textColor
4730 | },
4731 | grid: {
4732 | color: gridColor
4733 | }
4734 | }
4735 | },
4736 | plugins: {
4737 | legend: {
4738 | display: false
4739 | }
4740 | }
4741 | }
4742 | });
4743 | }
4744 |
4745 | /**
4746 | * Render quality provider pie chart
4747 | */
4748 | renderQualityProviderChart(providerData) {
4749 | const canvas = document.getElementById('quality-provider-chart');
4750 | if (!canvas) return;
4751 |
4752 | // Destroy existing chart if present
4753 | if (this.qualityProviderChart) {
4754 | this.qualityProviderChart.destroy();
4755 | }
4756 |
4757 | const labels = Object.keys(providerData);
4758 | const values = Object.values(providerData);
4759 |
4760 | // Friendly provider names
4761 | const friendlyLabels = labels.map(label => {
4762 | switch(label) {
4763 | case 'ONNXRankerModel': return 'Local SLM';
4764 | case 'GroqEvaluator': return 'Groq API';
4765 | case 'GeminiEvaluator': return 'Gemini API';
4766 | case 'ImplicitSignalsEvaluator': return 'Implicit Only';
4767 | case 'local': return 'Local SLM';
4768 | case 'groq': return 'Groq API';
4769 | case 'gemini': return 'Gemini API';
4770 | case 'implicit': return 'Implicit Only';
4771 | default: return label;
4772 | }
4773 | });
4774 |
4775 | const ctx = canvas.getContext('2d');
4776 | const isDark = document.body.classList.contains('dark-mode');
4777 | const textColor = isDark ? '#f9fafb' : '#374151';
4778 |
4779 | this.qualityProviderChart = new Chart(ctx, {
4780 | type: 'pie',
4781 | data: {
4782 | labels: friendlyLabels,
4783 | datasets: [{
4784 | data: values,
4785 | backgroundColor: [
4786 | '#4CAF50', // Local (primary)
4787 | '#2196F3', // Groq
4788 | '#FF9800', // Gemini
4789 | '#9E9E9E' // Implicit
4790 | ]
4791 | }]
4792 | },
4793 | options: {
4794 | responsive: true,
4795 | maintainAspectRatio: true,
4796 | plugins: {
4797 | legend: {
4798 | position: 'bottom',
4799 | labels: {
4800 | color: textColor
4801 | }
4802 | }
4803 | }
4804 | }
4805 | });
4806 | }
4807 |
4808 | /**
4809 | * Attach click handlers to memory preview elements
4810 | * @param {HTMLElement} container - Container with .memory-preview elements
4811 | * @param {Array} memories - Array of memory objects corresponding to preview elements
4812 | */
4813 | _attachMemoryClickHandlers(container, memories) {
4814 | container.querySelectorAll('.memory-preview').forEach((el, index) => {
4815 | el.addEventListener('click', () => this.handleMemoryClick(memories[index]));
4816 | });
4817 | }
4818 |
4819 | /**
4820 | * Render top quality memories list
4821 | */
4822 | renderTopQualityMemories(memories) {
4823 | const container = document.getElementById('quality-top-memories-list');
4824 | if (!container) return;
4825 |
4826 | if (!memories || memories.length === 0) {
4827 | container.innerHTML = '<p class="text-muted">No memories available</p>';
4828 | return;
4829 | }
4830 |
4831 | const html = memories.map(memory => `
4832 | <div class="memory-preview" data-content-hash="${memory.content_hash}">
4833 | <div class="quality-badge quality-tier-high">
4834 | <span class="quality-star">★</span>
4835 | <span class="quality-score">${memory.quality_score.toFixed(2)}</span>
4836 | </div>
4837 | <div class="memory-content">${this.escapeHtml(memory.content)}</div>
4838 | </div>
4839 | `).join('');
4840 |
4841 | container.innerHTML = html;
4842 | this._attachMemoryClickHandlers(container, memories);
4843 | }
4844 |
4845 | /**
4846 | * Render bottom quality memories list (for improvement)
4847 | */
4848 | renderBottomQualityMemories(memories) {
4849 | const container = document.getElementById('quality-bottom-memories-list');
4850 | if (!container) return;
4851 |
4852 | if (!memories || memories.length === 0) {
4853 | container.innerHTML = '<p class="text-muted">No memories available</p>';
4854 | return;
4855 | }
4856 |
4857 | const html = memories.map(memory => `
4858 | <div class="memory-preview" data-content-hash="${memory.content_hash}">
4859 | <div class="quality-badge quality-tier-low">
4860 | <span class="quality-star">★</span>
4861 | <span class="quality-score">${memory.quality_score.toFixed(2)}</span>
4862 | </div>
4863 | <div class="memory-content">${this.escapeHtml(memory.content)}</div>
4864 | </div>
4865 | `).join('');
4866 |
4867 | container.innerHTML = html;
4868 | this._attachMemoryClickHandlers(container, memories);
4869 | }
4870 |
4871 | /**
4872 | * Render quality badge for a memory
4873 | */
4874 | renderQualityBadge(memory) {
4875 | const score = memory.quality_score || memory.metadata?.quality_score || 0.5;
4876 | const provider = memory.quality_provider || memory.metadata?.quality_provider || 'none';
4877 |
4878 | // Determine tier
4879 | let tier = 'low';
4880 | if (score >= 0.7) tier = 'high';
4881 | else if (score >= 0.5) tier = 'medium';
4882 |
4883 | return `
4884 | <div class="quality-badge quality-tier-${tier}"
4885 | data-quality-score="${score.toFixed(2)}"
4886 | title="Quality Score: ${score.toFixed(2)} (${provider})">
4887 | <span class="quality-star">★</span>
4888 | <span class="quality-score">${score.toFixed(2)}</span>
4889 | </div>
4890 | `;
4891 | }
4892 |
4893 | /**
4894 | * Rate a memory manually
4895 | */
4896 | async rateMemory(contentHash, rating) {
4897 | try {
4898 | const response = await fetch(`${this.apiBase}/quality/memories/${contentHash}/rate`, {
4899 | method: 'POST',
4900 | headers: { 'Content-Type': 'application/json' },
4901 | body: JSON.stringify({
4902 | rating: rating,
4903 | feedback: ''
4904 | })
4905 | });
4906 |
4907 | if (!response.ok) throw new Error('Failed to rate memory');
4908 |
4909 | const result = await response.json();
4910 | this.showToast(`Rating saved! Quality score updated to ${result.new_quality_score.toFixed(2)}`, 'success');
4911 |
4912 | // Refresh memory display if viewing details
4913 | // This would refresh the modal or current view
4914 |
4915 | } catch (error) {
4916 | console.error('Failed to rate memory:', error);
4917 | this.showToast('Failed to save rating', 'error');
4918 | }
4919 | }
4920 |
4921 | // ===== UTILITY METHODS =====
4922 |
4923 | /**
4924 | * Show confirmation dialog for bulk operations
4925 | */
4926 | async confirmBulkOperation(message) {
4927 | return confirm(`⚠️ WARNING: ${message}
4928 |
4929 | This action cannot be undone. Are you sure?`);
4930 | }
4931 |
4932 | /**
4933 | * Update element text content
4934 | */
4935 | updateElementText(elementId, text) {
4936 | const element = document.getElementById(elementId);
4937 | if (element) {
4938 | element.textContent = text;
4939 | }
4940 | }
4941 |
4942 | /**
4943 | * Cleanup when page unloads
4944 | */
4945 | destroy() {
4946 | if (this.eventSource) {
4947 | this.eventSource.close();
4948 | }
4949 | }
4950 | }
4951 |
4952 | // Initialize the application when DOM is ready
4953 | console.log('⚡ Registering DOMContentLoaded listener');
4954 | document.addEventListener('DOMContentLoaded', () => {
4955 | console.log('⚡ DOMContentLoaded fired - Creating MemoryDashboard instance');
4956 | window.app = new MemoryDashboard();
4957 | console.log('⚡ MemoryDashboard created, window.app =', window.app);
4958 | });
4959 |
4960 | // Cleanup on page unload
4961 | window.addEventListener('beforeunload', () => {
4962 | if (window.app) {
4963 | window.app.destroy();
4964 | }
4965 | });
4966 |
```