Compare commits
165 Commits
0e66c6e32f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdee77f459 | ||
| 8a6d9d3324 | |||
|
|
dc9f52fb7c | ||
|
|
b17458d153 | ||
|
|
b14ca1d243 | ||
|
|
4ee1cc4984 | ||
|
|
bbe1b12251 | ||
|
|
d569b74a20 | ||
|
|
a2d3d3170a | ||
|
|
3823407d49 | ||
|
|
496f2a5b43 | ||
|
|
20a4c71d02 | ||
|
|
dad5e5ea0c | ||
|
|
b689f24502 | ||
|
|
630fc1643e | ||
|
|
1d750307c4 | ||
| 05d957324a | |||
|
|
6160620e24 | ||
|
|
1d91dbf478 | ||
|
|
76694e003c | ||
|
|
28af56d6ef | ||
|
|
3d5d2506e9 | ||
|
|
6b09eb062f | ||
|
|
70fb012d63 | ||
|
|
2cb25d1dd6 | ||
|
|
bcd92af8a0 | ||
|
|
5ecbabea90 | ||
|
|
07c8f707df | ||
|
|
e63ec367a5 | ||
|
|
5c3c308225 | ||
|
|
dd26d45a21 | ||
|
|
afbd8d2cd3 | ||
|
|
8bba0eefa9 | ||
|
|
170638a5fa | ||
|
|
c60730b4aa | ||
|
|
b9964685f9 | ||
|
|
5d72549bb7 | ||
|
|
15d302031e | ||
|
|
48209c4639 | ||
|
|
6d08dbdcd0 | ||
|
|
77f09ed399 | ||
|
|
0c7c502b03 | ||
|
|
1d98dd3257 | ||
|
|
3ad0d8120a | ||
|
|
88cf682790 | ||
|
|
182b9d01f9 | ||
|
|
12368ed7c8 | ||
|
|
c4c52f6064 | ||
|
|
e93f394263 | ||
|
|
75410e2b84 | ||
|
|
88e79d7780 | ||
| 8283b71b8c | |||
|
|
b630668897 | ||
|
|
479075e485 | ||
|
|
b6b3dfce8d | ||
|
|
9c2e43af22 | ||
|
|
6656c28ae0 | ||
|
|
6e9b7b4ea1 | ||
|
|
be76f2be5a | ||
| 4fd257cbd6 | |||
|
|
d1c297189d | ||
|
|
e8daa37d08 | ||
|
|
27b94edcfa | ||
|
|
b291492e2d | ||
|
|
0e3d654a58 | ||
|
|
2d920391ad | ||
|
|
f159f904f0 | ||
|
|
d6760d0f84 | ||
|
|
a3736c9dbd | ||
|
|
653e8d03de | ||
|
|
a52c0781e1 | ||
|
|
d49b031eb9 | ||
|
|
2f17370938 | ||
|
|
6918df9348 | ||
|
|
20682ef682 | ||
|
|
d043bba17f | ||
|
|
9e42b2a98d | ||
|
|
4cc3e2c830 | ||
|
|
2fcc84991a | ||
|
|
e6cee2ab0e | ||
|
|
a816c0630f | ||
|
|
9a3122745d | ||
|
|
9ce2098439 | ||
|
|
050774ad99 | ||
|
|
f09f519f46 | ||
|
|
df6bda30b1 | ||
|
|
b1c31379b2 | ||
|
|
b8311e152d | ||
|
|
4d423eb403 | ||
|
|
d9636578bd | ||
|
|
562f3a08f1 | ||
|
|
5b9bd4525a | ||
| 6e9f37c1cd | |||
|
|
7607a73373 | ||
|
|
3f9d1860aa | ||
|
|
daa468c535 | ||
|
|
1d10bfca2c | ||
|
|
8aa9a9b082 | ||
|
|
87b04cffb4 | ||
|
|
3c6fb568d6 | ||
|
|
138a494730 | ||
|
|
d5a6fe7dec | ||
|
|
20b3bd44ca | ||
|
|
93783dde3d | ||
|
|
2aa741ed09 | ||
|
|
0f780e3ce2 | ||
|
|
987f737122 | ||
|
|
824d98b3f4 | ||
|
|
56f3840fd7 | ||
|
|
27021ab499 | ||
|
|
f3e2480182 | ||
|
|
4f879fa1f5 | ||
|
|
def89822f6 | ||
|
|
ad7dd5bc70 | ||
|
|
ee21ce225e | ||
|
|
b28f9b9213 | ||
|
|
8516a39fcb | ||
|
|
6f065a6e3b | ||
|
|
cc8343776d | ||
|
|
f9ec247d43 | ||
|
|
1beefb93bb | ||
|
|
fd721ce930 | ||
|
|
95ce48192b | ||
|
|
73ace5965f | ||
|
|
12d3b53fe2 | ||
|
|
1ff437b7e6 | ||
|
|
9d6ba83378 | ||
|
|
ec0ee12ae5 | ||
|
|
dff78cbe0a | ||
| 358a30072e | |||
|
|
5795f3269f | ||
|
|
5d05c62a55 | ||
|
|
7f5fdef445 | ||
| 5967a1435c | |||
|
|
3462f049e4 | ||
|
|
0cab3b6945 | ||
|
|
507e57cdd9 | ||
|
|
1b59f5585e | ||
| 5164aa640a | |||
|
|
b515a45e1e | ||
|
|
1c0025796a | ||
|
|
769c223d39 | ||
|
|
fe1be323bb | ||
|
|
27e64f05ca | ||
|
|
183e36b86d | ||
|
|
8c5dc36788 | ||
|
|
cba42962f6 | ||
|
|
99117e8e7a | ||
|
|
c267681e7d | ||
|
|
1eb49315fa | ||
|
|
f00e2d3cfd | ||
|
|
a0955c2e58 | ||
|
|
4b0d208ef5 | ||
|
|
7c3cc7ec9a | ||
|
|
3a5e8e88b2 | ||
|
|
ec1969b2e2 | ||
|
|
6c73a20dff | ||
|
|
fe5eb78353 | ||
|
|
6308c03709 | ||
|
|
57c507915f | ||
|
|
3973479ae4 | ||
|
|
1644f27d51 | ||
| f329955c62 | |||
|
|
d3c4d7ccc4 | ||
|
|
37edc1549e |
6
.astro/content.d.ts
vendored
6
.astro/content.d.ts
vendored
@@ -164,9 +164,11 @@ declare module 'astro:content' {
|
|||||||
type DataEntryMap = {
|
type DataEntryMap = {
|
||||||
"knowledgebase": Record<string, {
|
"knowledgebase": Record<string, {
|
||||||
id: string;
|
id: string;
|
||||||
body?: string;
|
render(): Render[".md"];
|
||||||
|
slug: string;
|
||||||
|
body: string;
|
||||||
collection: "knowledgebase";
|
collection: "knowledgebase";
|
||||||
data: any;
|
data: InferEntrySchema<"knowledgebase">;
|
||||||
rendered?: RenderedContent;
|
rendered?: RenderedContent;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1753528124767
|
"lastUpdateCheck": 1755901660216
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
257
.env.example
257
.env.example
@@ -1,79 +1,200 @@
|
|||||||
# ===========================================
|
# ============================================================================
|
||||||
# ForensicPathways Environment Configuration
|
# ForensicPathways Environment Configuration
|
||||||
# ===========================================
|
# ============================================================================
|
||||||
|
# Copy this file to .env and configure the REQUIRED values below.
|
||||||
|
# Optional features can be enabled by uncommenting and configuring them.
|
||||||
|
|
||||||
# === Authentication Configuration ===
|
# ============================================================================
|
||||||
AUTHENTICATION_NECESSARY=false
|
# 🔥 CRITICAL - REQUIRED FOR BASIC OPERATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Your application's public URL (used for redirects and links)
|
||||||
|
PUBLIC_BASE_URL=http://localhost:4321
|
||||||
|
|
||||||
|
# Secret key for session encryption (GENERATE A SECURE RANDOM STRING!)
|
||||||
|
AUTH_SECRET=your-secret-key-change-in-production-please
|
||||||
|
|
||||||
|
# Primary AI service for query processing (REQUIRED for core functionality)
|
||||||
|
AI_ANALYZER_ENDPOINT=https://api.mistral.ai/v1/chat/completions
|
||||||
|
AI_ANALYZER_API_KEY=your-ai-api-key-here
|
||||||
|
AI_ANALYZER_MODEL=mistral/mistral-small-latest
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ⚙️ IMPORTANT - CORE FEATURES CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Application environment
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# === AUTHENTICATION & SECURITY ===
|
||||||
|
# Set to true to require authentication (RECOMMENDED for production)
|
||||||
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=false
|
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=false
|
||||||
AUTHENTICATION_NECESSARY_AI=false
|
AUTHENTICATION_NECESSARY_AI=false
|
||||||
AUTH_SECRET=your-secret-key-change-in-production
|
AUTHENTICATION_NECESSARY_GATEDCONTENT=true
|
||||||
|
|
||||||
# OIDC Configuration (if authentication enabled)
|
# OIDC Provider Configuration - Server appends endpoint (e.g. auth/callback) automatically
|
||||||
OIDC_ENDPOINT=https://your-oidc-provider.com
|
OIDC_ENDPOINT=https://cloud.cc24.dev/index.php
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# ===================================================================
|
# === FILE HANDLING ===
|
||||||
# AI CONFIGURATION - Complete Reference for Improved Pipeline
|
# Nextcloud server for file uploads (knowledgebase contributions)
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
# === CORE AI ENDPOINTS & MODELS ===
|
|
||||||
AI_API_ENDPOINT=https://llm.mikoshi.de
|
|
||||||
AI_API_KEY=sREDACTED3w
|
|
||||||
AI_MODEL='mistral/mistral-small-latest'
|
|
||||||
|
|
||||||
# === IMPROVED PIPELINE: Use separate analyzer model (mistral-small is fine) ===
|
|
||||||
AI_ANALYZER_ENDPOINT=https://llm.mikoshi.de
|
|
||||||
AI_ANALYZER_API_KEY=skREDACTEDw3w
|
|
||||||
AI_ANALYZER_MODEL='mistral/mistral-small-latest'
|
|
||||||
|
|
||||||
# === EMBEDDINGS CONFIGURATION ===
|
|
||||||
AI_EMBEDDINGS_ENABLED=true
|
|
||||||
AI_EMBEDDINGS_ENDPOINT=https://api.mistral.ai/v1/embeddings
|
|
||||||
AI_EMBEDDINGS_API_KEY=ZREDACTED3wL
|
|
||||||
AI_EMBEDDINGS_MODEL=mistral-embed
|
|
||||||
AI_EMBEDDINGS_BATCH_SIZE=20
|
|
||||||
AI_EMBEDDINGS_BATCH_DELAY_MS=1000
|
|
||||||
|
|
||||||
# === PIPELINE: VectorIndex (HNSW) Configuration ===
|
|
||||||
AI_MAX_SELECTED_ITEMS=60 # Tools visible to each micro-task
|
|
||||||
AI_EMBEDDING_CANDIDATES=60 # VectorIndex candidates (HNSW is more efficient)
|
|
||||||
AI_SIMILARITY_THRESHOLD=0.3 # Not used by VectorIndex (uses cosine distance internally)
|
|
||||||
|
|
||||||
# === MICRO-TASK CONFIGURATION ===
|
|
||||||
AI_MICRO_TASK_DELAY_MS=500 # Delay between micro-tasks
|
|
||||||
AI_MICRO_TASK_TIMEOUT_MS=25000 # Timeout per micro-task (increased for full context)
|
|
||||||
|
|
||||||
# === RATE LIMITING ===
|
|
||||||
AI_RATE_LIMIT_DELAY_MS=3000 # Main rate limit delay
|
|
||||||
AI_RATE_LIMIT_MAX_REQUESTS=6 # Main requests per minute (reduced - fewer but richer calls)
|
|
||||||
AI_MICRO_TASK_RATE_LIMIT=15 # Micro-task requests per minute (was 30)
|
|
||||||
|
|
||||||
# === QUEUE MANAGEMENT ===
|
|
||||||
AI_QUEUE_MAX_SIZE=50
|
|
||||||
AI_QUEUE_CLEANUP_INTERVAL_MS=300000
|
|
||||||
|
|
||||||
# === PERFORMANCE & MONITORING ===
|
|
||||||
AI_MICRO_TASK_DEBUG=true
|
|
||||||
AI_PERFORMANCE_METRICS=true
|
|
||||||
AI_RESPONSE_CACHE_TTL_MS=3600000
|
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# LEGACY VARIABLES (still used but less important)
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
# These are still used by other parts of the system:
|
|
||||||
AI_RESPONSE_CACHE_TTL_MS=3600000 # For caching responses
|
|
||||||
AI_QUEUE_MAX_SIZE=50 # Queue management
|
|
||||||
AI_QUEUE_CLEANUP_INTERVAL_MS=300000 # Queue cleanup
|
|
||||||
|
|
||||||
# === Application Configuration ===
|
|
||||||
PUBLIC_BASE_URL=http://localhost:4321
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Nextcloud Integration (Optional)
|
|
||||||
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
|
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
|
||||||
NEXTCLOUD_USERNAME=your-username
|
NEXTCLOUD_USERNAME=your-username
|
||||||
NEXTCLOUD_PASSWORD=your-password
|
NEXTCLOUD_PASSWORD=your-app-password
|
||||||
NEXTCLOUD_UPLOAD_PATH=/kb-media
|
NEXTCLOUD_UPLOAD_PATH=/kb-media
|
||||||
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/
|
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/
|
||||||
|
|
||||||
|
# === COLLABORATION & CONTRIBUTIONS ===
|
||||||
|
# Git provider: gitea, github, or gitlab
|
||||||
|
GIT_PROVIDER=gitea
|
||||||
|
GIT_REPO_URL=https://git.example.com/owner/forensic-pathways.git
|
||||||
|
GIT_API_ENDPOINT=https://git.example.com/api/v1
|
||||||
|
GIT_API_TOKEN=your-git-api-token
|
||||||
|
|
||||||
|
# === AUDIT TRAIL (Important for forensic work) ===
|
||||||
|
FORENSIC_AUDIT_ENABLED=true
|
||||||
|
FORENSIC_AUDIT_DETAIL_LEVEL=standard
|
||||||
|
FORENSIC_AUDIT_RETENTION_HOURS=24
|
||||||
|
FORENSIC_AUDIT_MAX_ENTRIES=50
|
||||||
|
|
||||||
|
# === AI SEMANTIC SEARCH ===
|
||||||
|
# semantic search
|
||||||
|
AI_EMBEDDINGS_ENDPOINT=https://api.mistral.ai/v1/embeddings
|
||||||
|
AI_EMBEDDINGS_API_KEY=your-embeddings-api-key-here
|
||||||
|
AI_EMBEDDINGS_MODEL=mistral-embed
|
||||||
|
|
||||||
|
# User rate limiting (queries per minute)
|
||||||
|
AI_RATE_LIMIT_MAX_REQUESTS=4
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CACHING BEHAVIOR
|
||||||
|
# ============================================================================
|
||||||
|
# - Videos downloaded once, cached permanently
|
||||||
|
# - No time-based expiration
|
||||||
|
# - Dramatically improves loading times after first download
|
||||||
|
# - Emergency cleanup only when approaching disk space limit
|
||||||
|
# - Perfect for manually curated forensics training content
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🎛️ PERFORMANCE TUNING - SENSIBLE DEFAULTS PROVIDED
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# === AI Pipeline Configuration ===
|
||||||
|
# These values are pre-tuned for optimal performance - adjust only if needed
|
||||||
|
|
||||||
|
# Vector similarity search settings
|
||||||
|
AI_EMBEDDING_CANDIDATES=50
|
||||||
|
AI_SIMILARITY_THRESHOLD=0.3
|
||||||
|
AI_EMBEDDING_SELECTION_LIMIT=30
|
||||||
|
AI_EMBEDDING_CONCEPTS_LIMIT=15
|
||||||
|
|
||||||
|
# === METHOD/TOOL BALANCE CONFIGURATION ===
|
||||||
|
# Controls the ratio of methods vs software tools sent to AI
|
||||||
|
# Methods = procedural guidance, best practices, workflows
|
||||||
|
# Software = actual tools and applications
|
||||||
|
# Values should sum to less than 1.0 (remainder is buffer)
|
||||||
|
AI_METHOD_SELECTION_RATIO=0.4 # 40% methods (increase for more procedural guidance)
|
||||||
|
AI_SOFTWARE_SELECTION_RATIO=0.5 # 50% software tools (increase for more tool recommendations)
|
||||||
|
|
||||||
|
# AI selection limits
|
||||||
|
AI_MAX_SELECTED_ITEMS=25
|
||||||
|
|
||||||
|
# Efficiency thresholds
|
||||||
|
AI_EMBEDDINGS_MIN_TOOLS=8
|
||||||
|
AI_EMBEDDINGS_MAX_REDUCTION_RATIO=0.75
|
||||||
|
|
||||||
|
# === Rate Limiting & Timing ===
|
||||||
|
AI_MICRO_TASK_TOTAL_LIMIT=30
|
||||||
|
AI_MICRO_TASK_DELAY_MS=500
|
||||||
|
AI_RATE_LIMIT_DELAY_MS=2000
|
||||||
|
|
||||||
|
# === Embeddings Batch Processing ===
|
||||||
|
AI_EMBEDDINGS_BATCH_SIZE=10
|
||||||
|
AI_EMBEDDINGS_BATCH_DELAY_MS=1000
|
||||||
|
|
||||||
|
# === Confidence Scoring ===
|
||||||
|
CONFIDENCE_SEMANTIC_WEIGHT=0.5
|
||||||
|
CONFIDENCE_SUITABILITY_WEIGHT=0.5
|
||||||
|
CONFIDENCE_MINIMUM_THRESHOLD=50
|
||||||
|
CONFIDENCE_MEDIUM_THRESHOLD=70
|
||||||
|
CONFIDENCE_HIGH_THRESHOLD=80
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 📋 QUICK SETUP CHECKLIST
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# MINIMUM FOR DEVELOPMENT/TESTING:
|
||||||
|
# 1. ✅ Set PUBLIC_BASE_URL to your domain/localhost
|
||||||
|
# 2. ✅ Generate secure AUTH_SECRET (use: openssl rand -base64 32)
|
||||||
|
# 3. ✅ Configure AI_ANALYZER_ENDPOINT and API_KEY for your AI service
|
||||||
|
# 4. ✅ Test basic functionality
|
||||||
|
#
|
||||||
|
# PRODUCTION-READY DEPLOYMENT:
|
||||||
|
# 5. ✅ Enable authentication (configure AUTHENTICATION_* and OIDC_*)
|
||||||
|
# 6. ✅ Configure file handling (set NEXTCLOUD_* for uploads)
|
||||||
|
# 7. ✅ Enable collaboration (set GIT_* for contributions)
|
||||||
|
# 8. ✅ Enable audit trail (verify FORENSIC_AUDIT_ENABLED=true)
|
||||||
|
# 9. ✅ Configure embeddings for better search (AI_EMBEDDINGS_*)
|
||||||
|
# 10. ✅ Adjust rate limits based on expected usage
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🏃♂️ PERFORMANCE PRESETS - UNCOMMENT ONE IF NEEDED
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 🚀 SPEED OPTIMIZED (faster responses, less comprehensive):
|
||||||
|
# AI_EMBEDDING_CANDIDATES=25
|
||||||
|
# AI_MAX_SELECTED_ITEMS=15
|
||||||
|
# AI_MAX_TOOLS_TO_ANALYZE=10
|
||||||
|
# AI_MICRO_TASK_DELAY_MS=250
|
||||||
|
|
||||||
|
# 🎯 ACCURACY OPTIMIZED (slower responses, more comprehensive):
|
||||||
|
# AI_EMBEDDING_CANDIDATES=100
|
||||||
|
# AI_MAX_SELECTED_ITEMS=50
|
||||||
|
# AI_MAX_TOOLS_TO_ANALYZE=40
|
||||||
|
# AI_MICRO_TASK_DELAY_MS=1000
|
||||||
|
|
||||||
|
# 🔋 RESOURCE CONSTRAINED (for limited AI quotas):
|
||||||
|
# AI_RATE_LIMIT_MAX_REQUESTS=2
|
||||||
|
# AI_MICRO_TASK_TOTAL_LIMIT=15
|
||||||
|
# AI_MAX_TOOLS_TO_ANALYZE=10
|
||||||
|
# AI_EMBEDDINGS_ENABLED=false
|
||||||
|
|
||||||
|
# 🔬 METHOD-FOCUSED (more procedural guidance, less tools):
|
||||||
|
# AI_METHOD_SELECTION_RATIO=0.6
|
||||||
|
# AI_SOFTWARE_SELECTION_RATIO=0.3
|
||||||
|
|
||||||
|
# 🛠️ TOOL-FOCUSED (more software recommendations, less methods):
|
||||||
|
# AI_METHOD_SELECTION_RATIO=0.2
|
||||||
|
# AI_SOFTWARE_SELECTION_RATIO=0.7
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🌐 AI SERVICE EXAMPLES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# === OLLAMA (Local) ===
|
||||||
|
# AI_ANALYZER_ENDPOINT=http://localhost:11434/v1/chat/completions
|
||||||
|
# AI_ANALYZER_API_KEY=
|
||||||
|
# AI_ANALYZER_MODEL=llama3.1:8b
|
||||||
|
# AI_EMBEDDINGS_ENDPOINT=http://localhost:11434/v1/embeddings
|
||||||
|
# AI_EMBEDDINGS_API_KEY=
|
||||||
|
# AI_EMBEDDINGS_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# === OPENAI ===
|
||||||
|
# AI_ANALYZER_ENDPOINT=https://api.openai.com/v1/chat/completions
|
||||||
|
# AI_ANALYZER_API_KEY=sk-your-openai-key
|
||||||
|
# AI_ANALYZER_MODEL=gpt-4o-mini
|
||||||
|
# AI_EMBEDDINGS_ENDPOINT=https://api.openai.com/v1/embeddings
|
||||||
|
# AI_EMBEDDINGS_API_KEY=sk-your-openai-key
|
||||||
|
# AI_EMBEDDINGS_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# === MISTRAL (Default) ===
|
||||||
|
# AI_ANALYZER_ENDPOINT=https://api.mistral.ai/v1/chat/completions
|
||||||
|
# AI_ANALYZER_API_KEY=your-mistral-key
|
||||||
|
# AI_ANALYZER_MODEL=mistral-small-latest
|
||||||
|
# AI_EMBEDDINGS_ENDPOINT=https://api.mistral.ai/v1/embeddings
|
||||||
|
# AI_EMBEDDINGS_API_KEY=your-mistral-key
|
||||||
|
# AI_EMBEDDINGS_MODEL=mistral-embed
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,6 +11,7 @@ _site/
|
|||||||
dist/
|
dist/
|
||||||
|
|
||||||
.astro/
|
.astro/
|
||||||
|
.astro/*
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
@@ -85,4 +86,7 @@ temp/
|
|||||||
.astro/data-store.json
|
.astro/data-store.json
|
||||||
.astro/content.d.ts
|
.astro/content.d.ts
|
||||||
prompt.md
|
prompt.md
|
||||||
data/embeddings.json
|
.astro/settings.json
|
||||||
|
|
||||||
|
src/content/knowledgebase/
|
||||||
|
public/videos
|
||||||
687
README.md
687
README.md
@@ -1,232 +1,150 @@
|
|||||||
# ForensicPathways
|
# ForensicPathways
|
||||||
|
|
||||||
Ein kuratiertes Verzeichnis für Digital Forensics und Incident Response (DFIR) Tools, Methoden und Konzepte mit KI-gestützten Workflow-Empfehlungen.
|
Ein umfassendes Verzeichnis digitaler Forensik- und Incident-Response-Tools mit KI-gestützten Empfehlungen basierend auf der NIST SP 800-86 Methodik.
|
||||||
|
|
||||||
## ✨ Funktionen
|
## Lizenz
|
||||||
|
|
||||||
### 🎯 Hauptansichten
|
Dieses Projekt ist unter der BSD-3-Clause-Lizenz lizenziert.
|
||||||
- **Kachelansicht (Grid View):** Übersichtliche Kartenansicht aller Tools/Methoden
|
|
||||||
- **Matrix-Ansicht:** Interaktive Matrix nach forensischen Domänen und Untersuchungsphasen (NIST Framework)
|
|
||||||
- **Forensic-AI:** AI-gestützte Workflow-Empfehlungen basierend auf Szenario-Beschreibungen
|
|
||||||
|
|
||||||
### 🔍 Navigation & Filterung
|
## Funktionen
|
||||||
- **Tag-System:** Intelligente Filterung nach Kategorien und Eigenschaften
|
|
||||||
- **Volltext-Suche:** Durchsuchen von Namen, Beschreibungen und Tags
|
|
||||||
- **Domain/Phase-Filter:** Filterung nach forensischen Bereichen und Ermittlungsphasen
|
|
||||||
|
|
||||||
### 📚 Inhaltstypen
|
### Kernfunktionalität
|
||||||
- **Software/Tools:** Open Source und proprietäre forensische Software
|
- **Umfassende Tool-Datenbank**: 100+ forensische Tools kategorisiert nach Domänen, Phasen und Skill-Levels
|
||||||
- **Methoden:** Bewährte forensische Verfahren und Prozesse
|
- **NIST SP 800-86 Integration**: Vier-Phasen-Methodik (Sammlung → Auswertung → Analyse → Berichterstattung)
|
||||||
- **Konzepte:** Grundlegendes Fachwissen und theoretische Grundlagen
|
- **Multiple Ansichtsmodi**: Kachelansicht, Matrix-Übersicht und KI-gestützte Empfehlungen
|
||||||
|
- **Erweiterte Suche**: Textsuche, semantische Embedding-basierte Suche und Multi-Kriterien-Filterung
|
||||||
|
- **Responsive Design**: Dark/Light-Mode-Unterstützung, mobile-optimierte Benutzeroberfläche
|
||||||
|
|
||||||
### 📖 Knowledgebase
|
### KI-gestützte Analyse
|
||||||
- **Erweiterte Dokumentation:** Detaillierte Artikel zu Tools und Methoden
|
- **Micro-Task-Pipeline**: Intelligente Tool-Auswahl durch mehrere KI-Analyseschritte
|
||||||
- **Praktische Anleitungen:** Installation, Konfiguration und Best Practices
|
- **Semantische Suche**: Vector-Embeddings für natürlichsprachige Tool-Entdeckung
|
||||||
- **Markdown-basiert:** Einfache Erstellung und Wartung von Inhalten
|
- **Konfidenz-Bewertung**: Transparente Vertrauensmetriken für KI-Empfehlungen
|
||||||
|
- **Audit-Trail**: Vollständige Entscheidungstransparenz mit detaillierter Protokollierung
|
||||||
|
- **Rate Limiting**: Intelligente Warteschlangenverwaltung und nutzerbasierte Ratenbegrenzung
|
||||||
|
|
||||||
### 🤝 Contribution-System
|
### Zusammenarbeit & Beiträge
|
||||||
- **Tool/Methoden-Beiträge:** Webformular für neue Einträge
|
- **Tool-Beiträge**: Neue Tools einreichen oder bestehende über Git-Integration bearbeiten
|
||||||
- **Knowledgebase-Artikel:** Artikel-Editor mit Datei-Upload
|
- **Knowledgebase**: Community-beigetragene Artikel und Dokumentation
|
||||||
- **Git-Integration:** Automatische Issue-Erstellung für Review-Prozess
|
- **File-Upload-System**: Nextcloud-Integration für Medien-Anhänge
|
||||||
- **File-Management:** Nextcloud-Integration für Medien-Uploads
|
- **Authentifizierung**: OIDC-Integration mit konfigurierbaren Anbietern
|
||||||
|
|
||||||
### 🔐 Authentifizierung
|
### Enterprise-Funktionen
|
||||||
- **OIDC-Integration:** Single Sign-On mit OpenID Connect
|
- **Warteschlangenverwaltung**: Ratenbegrenzte KI-Verarbeitung mit Echtzeit-Status-Updates
|
||||||
- **Berechtigungssteuerung:** Schutz für AI-Features und Contribution-System
|
- **Audit-Protokollierung**: Umfassender forensischer Audit-Trail für KI-Entscheidungsfindung
|
||||||
- **Session-Management:** Sichere JWT-basierte Sessions
|
- **Multi-Provider-Unterstützung**: Konfigurierbare KI-Services (Mistral AI, Ollama, OpenAI)
|
||||||
|
- **Git-Integration**: Automatisierte Issue-Erstellung für Beiträge (Gitea, GitHub, GitLab)
|
||||||
|
|
||||||
## 🛠 Technische Grundlage
|
## Datenmodell
|
||||||
|
|
||||||
- **Framework:** Astro 4.x mit TypeScript
|
Das System verwendet eine YAML-basierte Konfiguration in `src/data/tools.yaml`:
|
||||||
- **Styling:** CSS Custom Properties mit Dark/Light Mode
|
|
||||||
- **API:** Node.js Backend mit Astro API Routes
|
|
||||||
- **Datenbank:** YAML-basierte Konfiguration (tools.yaml)
|
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
```yaml
|
||||||
|
tools:
|
||||||
|
- name: Tool Name
|
||||||
|
type: software|method|concept
|
||||||
|
description: Detaillierte Beschreibung
|
||||||
|
skillLevel: novice|beginner|intermediate|advanced|expert
|
||||||
|
url: https://tool-homepage.com
|
||||||
|
domains: [incident-response, static-investigations, ...]
|
||||||
|
phases: [data-collection, examination, analysis, reporting]
|
||||||
|
platforms: [Windows, Linux, macOS]
|
||||||
|
license: Lizenztyp
|
||||||
|
tags: [gui, commandline, ...]
|
||||||
|
related_concepts: [konzept1, konzept2]
|
||||||
|
# Optionale Felder
|
||||||
|
projectUrl: https://hosted-instance.com # Für CC24-Server gehostete Tools
|
||||||
|
knowledgebase: true # Hat KB-Artikel
|
||||||
|
accessType: download|hosted|cloud
|
||||||
|
|
||||||
- **Node.js:** Version 18.x oder höher
|
domains:
|
||||||
- **npm:** Version 8.x oder höher
|
- id: incident-response
|
||||||
- **Nginx:** Für Reverse Proxy (Produktion)
|
name: Incident Response & Breach-Untersuchung
|
||||||
|
|
||||||
|
phases:
|
||||||
|
- id: data-collection
|
||||||
|
name: Datensammlung
|
||||||
|
description: Imaging, Akquisition, Remote-Collection-Tools
|
||||||
|
|
||||||
## 🔧 Externe Abhängigkeiten (Optional)
|
scenarios:
|
||||||
|
- id: scenario:memory_dump
|
||||||
### OIDC Provider
|
icon: 🧠
|
||||||
- **Zweck:** Benutzerauthentifizierung
|
friendly_name: RAM-Analyse
|
||||||
- **Beispiel:** Nextcloud, Keycloak, Auth0
|
|
||||||
- **Konfiguration:** `OIDC_ENDPOINT`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`
|
|
||||||
|
|
||||||
### Nextcloud
|
|
||||||
- **Zweck:** File-Upload für Knowledgebase-Beiträge
|
|
||||||
- **Features:** Medien-Management, öffentliche Links
|
|
||||||
- **Konfiguration:** `NEXTCLOUD_ENDPOINT`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
|
|
||||||
|
|
||||||
### AI Service (Mistral/OpenAI-kompatibel)
|
|
||||||
- **Zweck:** KI-gestützte Tool-Empfehlungen
|
|
||||||
- **Konfiguration:** `AI_API_ENDPOINT`, `AI_API_KEY`, `AI_MODEL`
|
|
||||||
|
|
||||||
### Uptime Kuma
|
|
||||||
- **Zweck:** Status-Monitoring für gehostete Services
|
|
||||||
- **Integration:** Status-Badges in der Service-Übersicht
|
|
||||||
|
|
||||||
### Git Provider (Gitea/GitHub/GitLab)
|
|
||||||
- **Zweck:** Issue-Erstellung für Contributions
|
|
||||||
- **Konfiguration:** `GIT_PROVIDER`, `GIT_API_ENDPOINT`, `GIT_API_TOKEN`
|
|
||||||
|
|
||||||
## 🚀 Installation
|
|
||||||
|
|
||||||
### Lokale Entwicklung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Repository klonen
|
|
||||||
git clone https://git.cc24.dev/mstoeck3/forensic-pathways.git
|
|
||||||
cd forensic-pathways
|
|
||||||
|
|
||||||
# Dependencies installieren
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Umgebungsvariablen konfigurieren
|
|
||||||
cp .env.example .env
|
|
||||||
# .env bearbeiten (siehe Konfiguration unten)
|
|
||||||
|
|
||||||
npm run astro build
|
|
||||||
|
|
||||||
# Development Server starten
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Seite ist dann unter `http://localhost:4321` verfügbar.
|
## AI Concept
|
||||||
|
|
||||||
### Produktions-Deployment
|
### Micro-Task Architecture
|
||||||
|
The AI system uses a sophisticated pipeline that breaks complex analysis into focused micro-tasks:
|
||||||
|
|
||||||
#### 1. System vorbereiten
|
1. **Scenario Analysis**: Understanding the forensic context
|
||||||
|
2. **Investigation Approach**: Determining optimal methodology
|
||||||
|
3. **Critical Considerations**: Identifying potential challenges
|
||||||
|
4. **Tool Selection**: Phase-specific or problem-specific recommendations
|
||||||
|
5. **Background Knowledge**: Relevant concepts and prerequisites
|
||||||
|
6. **Final Synthesis**: Integrated recommendations with confidence scoring
|
||||||
|
|
||||||
|
### Confidence Scoring
|
||||||
|
Each recommendation includes transparent confidence metrics:
|
||||||
|
- **Semantic Relevance**: Vector similarity between query and tool descriptions
|
||||||
|
- **Task Suitability**: AI-assessed fitness for the specific scenario
|
||||||
|
- **Uncertainty Factors**: Potential limitations and considerations
|
||||||
|
- **Strength Indicators**: Why the tool is well-suited
|
||||||
|
|
||||||
|
## NIST SP 800-86 Phases
|
||||||
|
|
||||||
|
The system organizes tools according to the four-phase NIST methodology:
|
||||||
|
|
||||||
|
1. **Data Collection**: Imaging, acquisition, and evidence preservation
|
||||||
|
2. **Examination**: Parsing, extraction, and initial data processing
|
||||||
|
3. **Analysis**: Deep investigation, correlation, and insight generation
|
||||||
|
4. **Reporting**: Documentation, visualization, and presentation
|
||||||
|
|
||||||
|
Each tool is mapped to appropriate phases, enabling workflow-based recommendations.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
1. **Build and Deploy**:
|
||||||
```bash
|
```bash
|
||||||
# System-Updates
|
npm run build
|
||||||
sudo apt update && sudo apt upgrade -y
|
sudo ./deploy.sh # Copies dist/ to /var/www/forensic-pathways
|
||||||
|
|
||||||
# Node.js installieren (Ubuntu/Debian)
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
|
||||||
sudo apt-get install -y nodejs
|
|
||||||
|
|
||||||
# Nginx installieren
|
|
||||||
sudo apt install nginx -y
|
|
||||||
|
|
||||||
# Systemd für Service-Management
|
|
||||||
sudo systemctl enable nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Anwendung installieren
|
2. **Configuration**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Klonen des Repositorys
|
cd /var/www/forensic-pathways
|
||||||
sudo git clone https://git.cc24.dev/mstoeck3/forensic-pathways /opt/forensic-pathways
|
sudo cp .env.example .env
|
||||||
cd /opt/forensic-pathways
|
sudo nano .env # Configure AI services, authentication, etc.
|
||||||
|
|
||||||
# Abhängigkeiten installieren
|
|
||||||
sudo npm install
|
|
||||||
|
|
||||||
# Production-Build erstellen
|
|
||||||
sudo npm run build
|
|
||||||
npm run astro build
|
|
||||||
|
|
||||||
# Berechtigungen setzen
|
|
||||||
sudo chown -R www-data:www-data /opt/forensic-pathways
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Umgebungsvariablen konfigurieren
|
3. **Systemd Service** (`/etc/systemd/system/forensic-pathways.service`):
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=ForensicPathways
|
||||||
|
After=network.target
|
||||||
|
|
||||||
Erstelle `/opt/forensic-pathways/.env`:
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/forensic-pathways
|
||||||
|
ExecStart=/usr/bin/node server/entry.mjs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
```bash
|
[Install]
|
||||||
# ===========================================
|
WantedBy=multi-user.target
|
||||||
# ForensicPathways Environment Configuration
|
|
||||||
# ===========================================
|
|
||||||
|
|
||||||
# Authentication & OIDC (Required)
|
|
||||||
AUTH_SECRET=change-this-to-a-strong-secret-key-in-production
|
|
||||||
OIDC_ENDPOINT=https://your-oidc-provider.com
|
|
||||||
OIDC_CLIENT_ID=your-oidc-client-id
|
|
||||||
OIDC_CLIENT_SECRET=your-oidc-client-secret
|
|
||||||
|
|
||||||
# Auth Scopes - set to true in prod
|
|
||||||
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=true
|
|
||||||
AUTHENTICATION_NECESSARY_AI=true
|
|
||||||
|
|
||||||
# Application Configuration (Required)
|
|
||||||
PUBLIC_BASE_URL=https://your-domain.com
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# AI Service Configuration (Required for AI features)
|
|
||||||
AI_MODEL=mistral-large-latest
|
|
||||||
AI_API_ENDPOINT=https://api.mistral.ai
|
|
||||||
AI_API_KEY=your-mistral-api-key
|
|
||||||
AI_RATE_LIMIT_DELAY_MS=1000
|
|
||||||
|
|
||||||
# Git Integration (Required for contributions)
|
|
||||||
GIT_REPO_URL=https://git.cc24.dev/mstoeck3/forensic-pathways
|
|
||||||
GIT_PROVIDER=gitea
|
|
||||||
GIT_API_ENDPOINT=https://git.cc24.dev/api/v1
|
|
||||||
GIT_API_TOKEN=your-git-api-token
|
|
||||||
|
|
||||||
# File Upload Configuration (Optional)
|
|
||||||
LOCAL_UPLOAD_PATH=./public/uploads
|
|
||||||
|
|
||||||
# Nextcloud Integration (Optional)
|
|
||||||
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
|
|
||||||
NEXTCLOUD_USERNAME=your-username
|
|
||||||
NEXTCLOUD_PASSWORD=your-password
|
|
||||||
NEXTCLOUD_UPLOAD_PATH=/kb-media
|
|
||||||
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
4. **Nginx Configuration**:
|
||||||
# Berechtigungen sichern
|
|
||||||
sudo chmod 600 /opt/forensic-pathways/.env
|
|
||||||
sudo chown www-data:www-data /opt/forensic-pathways/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Nginx konfigurieren
|
|
||||||
|
|
||||||
Erstelle `/etc/nginx/sites-available/forensic-pathways`:
|
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name ihre-domain.de;
|
server_name forensic-pathways.yourdomain.com;
|
||||||
|
client_max_body_size 50M; # Important for uploads
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name ihre-domain.de;
|
|
||||||
|
|
||||||
# SSL Konfiguration (Let's Encrypt empfohlen)
|
|
||||||
ssl_certificate /etc/letsencrypt/live/ihre-domain.de/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/ihre-domain.de/privkey.pem;
|
|
||||||
|
|
||||||
# Security Headers
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
|
||||||
|
|
||||||
# Static Files
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ @nodejs;
|
|
||||||
root /opt/forensic-pathways/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# API Routes to Node.js
|
|
||||||
location @nodejs {
|
|
||||||
proxy_pass http://localhost:4321;
|
proxy_pass http://localhost:4321;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -236,251 +154,178 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 75s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Upload limit
|
|
||||||
client_max_body_size 50M;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
5. **Enable and Start**:
|
||||||
```bash
|
```bash
|
||||||
# Site aktivieren
|
sudo systemctl enable forensic-pathways
|
||||||
sudo ln -s /etc/nginx/sites-available/forensic-pathways /etc/nginx/sites-enabled/
|
sudo systemctl start forensic-pathways
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. Systemd Service einrichten
|
### Environment Configuration
|
||||||
|
|
||||||
Erstelle `/etc/systemd/system/forensic-pathways.service`:
|
Key configuration in `.env`:
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=ForensicPathways DFIR Guide
|
|
||||||
After=network.target nginx.service
|
|
||||||
Wants=nginx.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=exec
|
|
||||||
User=www-data
|
|
||||||
Group=www-data
|
|
||||||
WorkingDirectory=/opt/forensic-pathways
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
ExecStart=/usr/bin/node ./dist/server/entry.mjs
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
# Security
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
ReadWritePaths=/opt/forensic-pathways
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
|
|
||||||
# Resource Limits
|
|
||||||
LimitNOFILE=65536
|
|
||||||
MemoryMax=512M
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Service aktivieren und starten
|
# Core Application
|
||||||
sudo systemctl daemon-reload
|
PUBLIC_BASE_URL=https://forensic-pathways.yourdomain.com
|
||||||
sudo systemctl enable forensic-pathways
|
AUTH_SECRET=your-secure-random-secret
|
||||||
sudo systemctl start forensic-pathways
|
|
||||||
|
|
||||||
# Status prüfen
|
# AI Services (Required)
|
||||||
sudo systemctl status forensic-pathways
|
AI_ANALYZER_ENDPOINT=https://api.mistral.ai/v1/chat/completions
|
||||||
|
AI_ANALYZER_API_KEY=your-api-key
|
||||||
|
AI_ANALYZER_MODEL=mistral/mistral-small-latest
|
||||||
|
|
||||||
|
# Vector Embeddings (Recommended)
|
||||||
|
AI_EMBEDDINGS_ENABLED=true
|
||||||
|
AI_EMBEDDINGS_ENDPOINT=https://api.mistral.ai/v1/embeddings
|
||||||
|
AI_EMBEDDINGS_MODEL=mistral-embed
|
||||||
|
|
||||||
|
# Authentication (Optional)
|
||||||
|
AUTHENTICATION_NECESSARY_AI=false
|
||||||
|
OIDC_ENDPOINT=https://your-keycloak.com/auth/realms/your-realm
|
||||||
|
OIDC_CLIENT_ID=forensic-pathways
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Konfiguration
|
## Externe Abhängigkeiten (Optionale Features)
|
||||||
|
|
||||||
### Minimalkonfiguration (ohne Auth)
|
### File-Upload-System
|
||||||
|
- **Nextcloud**: Primärer Speicher für Beitrags-Anhänge
|
||||||
|
- **Lokaler Fallback**: Automatischer Fallback zu lokalem Speicher bei Nextcloud-Ausfall
|
||||||
|
|
||||||
|
### Authentifizierungsanbieter
|
||||||
|
- **Keycloak**: Empfohlener OIDC-Provider
|
||||||
|
- **Andere OIDC**: Jeder OIDC-konforme Provider (Auth0, Azure AD, etc.)
|
||||||
|
|
||||||
|
### Git-Integration
|
||||||
|
- **Gitea**: Primärer Git-Provider für Beiträge
|
||||||
|
- **GitHub/GitLab**: Alternative Git-Provider unterstützt
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- **Uptime Kuma**: Service-Monitoring und Gesundheitschecks (optional)
|
||||||
|
|
||||||
|
### KI-Services
|
||||||
|
- **Mistral AI**: Empfohlen für Produktion (API-Schlüssel erforderlich)
|
||||||
|
- **Ollama**: Lokale Deployment-Option (kein API-Schlüssel benötigt)
|
||||||
|
- **OpenAI**: Alternative kommerzielle Anbieter
|
||||||
|
|
||||||
|
## Knowledgebase-System
|
||||||
|
|
||||||
|
### Artikel hinzufügen
|
||||||
|
|
||||||
|
Knowledgebase-Artikel werden in `src/content/knowledgebase/` als Markdown-Dateien mit Frontmatter gespeichert:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Tool-Konfigurationsanleitung"
|
||||||
|
description: "Schritt-für-Schritt-Setup-Anweisungen"
|
||||||
|
last_updated: 2024-01-15
|
||||||
|
author: "Ihr Name"
|
||||||
|
difficulty: intermediate
|
||||||
|
|
||||||
|
# Tool-Zuordnung (optional)
|
||||||
|
tool_name: "Autopsy"
|
||||||
|
related_tools: ["Volatility 3", "YARA"]
|
||||||
|
|
||||||
|
# Kategorisierung
|
||||||
|
categories: ["konfiguration", "setup"]
|
||||||
|
tags: ["gui", "installation", "windows"]
|
||||||
|
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tool-Konfigurationsanleitung
|
||||||
|
|
||||||
|
Ihr Artikel-Inhalt hier...
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Systemanforderungen
|
||||||
|
- Abhängigkeiten
|
||||||
|
|
||||||
|
## Installationsschritte
|
||||||
|
1. Download von offizieller Quelle
|
||||||
|
2. Installer ausführen
|
||||||
|
3. Einstellungen konfigurieren
|
||||||
|
|
||||||
|
## Video-Demonstration
|
||||||
|
<video src="/videos/setup-tutorial.mp4" title="Setup-Tutorial" controls></video>
|
||||||
|
|
||||||
|
## Häufige Probleme
|
||||||
|
Lösungen für typische Probleme...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video-Integration
|
||||||
|
|
||||||
|
Knowledgebase-Artikel unterstützen eingebettete Videos für praktische Demonstrationen:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<video src="/videos/demo.mp4" title="Tool-Demonstration" controls></video>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtige Hinweise**:
|
||||||
|
- Videos müssen manuell in `public/videos/` bereitgestellt werden (nicht im Git-Repository enthalten)
|
||||||
|
- Firefox-kompatible Formate verwenden (MP4 H.264, WebM VP9)
|
||||||
|
- Detaillierte Video-Dokumentation: siehe `src/content/knowledgebase/README.md`
|
||||||
|
|
||||||
|
### Artikel-Struktur-Richtlinien
|
||||||
|
|
||||||
|
**Erforderliche Felder**:
|
||||||
|
- `title`: Klarer, beschreibender Titel
|
||||||
|
- `description`: Einzeilige Zusammenfassung für Auflistungen
|
||||||
|
- `last_updated`: Artikel-Änderungsdatum
|
||||||
|
- `published`: Boolean-Flag für Sichtbarkeit
|
||||||
|
|
||||||
|
**Optionale Felder**:
|
||||||
|
- `tool_name`: Zuordnung zu spezifischem Tool aus Datenbank
|
||||||
|
- `author`: Mitwirkender Name (Standard: "Anon")
|
||||||
|
- `difficulty`: Komplexitätslevel passend zu Tool-Skill-Levels
|
||||||
|
- `categories`: Breite Klassifizierungen
|
||||||
|
- `tags`: Spezifische Stichwörter für Entdeckung
|
||||||
|
- `related_tools`: Array verwandter Tool-Namen
|
||||||
|
|
||||||
|
**Inhalt-Richtlinien**:
|
||||||
|
- Standard-Markdown-Formatierung verwenden
|
||||||
|
- Praktische Beispiele und Code-Snippets einschließen
|
||||||
|
- Screenshots oder Diagramme bei Bedarf hinzufügen
|
||||||
|
- Zu verwandten Tools mit `[Tool Name](/tools/tool-slug)` Format verlinken
|
||||||
|
- Troubleshooting-Abschnitte für komplexe Tools einschließen
|
||||||
|
|
||||||
|
### Automatische Verarbeitung
|
||||||
|
|
||||||
|
1. Artikel werden automatisch beim Build indexiert
|
||||||
|
2. Tool-Zuordnungen erstellen bidirektionale Links
|
||||||
|
3. Suche umfasst Volltext-Inhalt und Metadaten
|
||||||
|
4. Verwandte Artikel erscheinen in Tool-Detail-Ansichten
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Nur für Tests geeignet
|
# Setup
|
||||||
AUTHENTICATION_NECESSARY=false
|
npm install
|
||||||
PUBLIC_BASE_URL=http://localhost:4321
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Entwicklung
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
sudo ./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tools-Datenbank
|
## Konfigurationsübersicht
|
||||||
|
|
||||||
Die Tools werden in `src/data/tools.yaml` verwaltet. Vollständiges Beispiel:
|
Die `.env.example`-Datei enthält umfassende Konfigurationsoptionen für alle Features. Die meisten Optionen haben sinnvolle Standardwerte, wobei nur die KI-Service-Konfiguration für volle Funktionalität erforderlich ist.
|
||||||
|
|
||||||
```yaml
|
## Architektur
|
||||||
tools:
|
|
||||||
- name: Autopsy
|
|
||||||
type: software # software|method|concept
|
|
||||||
description: >-
|
|
||||||
Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten mit
|
|
||||||
intuitiver grafischer Oberfläche. Besonders stark in der Timeline-Analyse,
|
|
||||||
Keyword-Suche und dem Carving gelöschter Dateien. Die modulare
|
|
||||||
Plugin-Architektur erlaubt Erweiterungen für spezielle
|
|
||||||
Untersuchungsszenarien.
|
|
||||||
icon: 📦
|
|
||||||
skillLevel: intermediate # novice|beginner|intermediate|advanced|expert
|
|
||||||
url: https://www.autopsy.com/
|
|
||||||
domains:
|
|
||||||
- incident-response
|
|
||||||
- static-investigations
|
|
||||||
- malware-analysis
|
|
||||||
- mobile-forensics
|
|
||||||
- cloud-forensics
|
|
||||||
phases:
|
|
||||||
- examination
|
|
||||||
- analysis
|
|
||||||
platforms:
|
|
||||||
- Windows
|
|
||||||
- Linux
|
|
||||||
related_concepts:
|
|
||||||
- SQL Query Fundamentals
|
|
||||||
- Hash Functions & Digital Signatures
|
|
||||||
accessType: download # download|web|api|cli|service
|
|
||||||
license: Apache 2.0
|
|
||||||
knowledgebase: false # true für erweiterte Dokumentation
|
|
||||||
tags:
|
|
||||||
- gui
|
|
||||||
- filesystem
|
|
||||||
- timeline-analysis
|
|
||||||
- carving
|
|
||||||
- artifact-extraction
|
|
||||||
- keyword-search
|
|
||||||
# Optional: Für gehostete Services
|
|
||||||
projectUrl: https://autopsy.ihre-domain.de
|
|
||||||
statusUrl: https://status.ihre-domain.de/api/badge/1/status
|
|
||||||
|
|
||||||
# Beispiel Methode
|
|
||||||
- name: Live Response Methodology
|
|
||||||
type: method
|
|
||||||
description: >-
|
|
||||||
Strukturierte Vorgehensweise zur Sammlung volatiler Daten
|
|
||||||
von laufenden Systemen ohne Shutdown.
|
|
||||||
icon: 📋
|
|
||||||
skillLevel: advanced
|
|
||||||
url: https://www.sans.org/white-papers/live-response/
|
|
||||||
domains:
|
|
||||||
- incident-response
|
|
||||||
phases:
|
|
||||||
- data-collection
|
|
||||||
related_concepts:
|
|
||||||
- Memory Forensics Fundamentals
|
|
||||||
tags:
|
|
||||||
- volatile-data
|
|
||||||
- live-analysis
|
|
||||||
- methodology
|
|
||||||
knowledgebase: true
|
|
||||||
|
|
||||||
# Beispiel Konzept
|
|
||||||
- name: Hash Functions & Digital Signatures
|
|
||||||
type: concept
|
|
||||||
description: >-
|
|
||||||
Kryptographische Grundlagen für Datenintegrität und
|
|
||||||
Authentifizierung in der digitalen Forensik.
|
|
||||||
icon: 🔐
|
|
||||||
skillLevel: intermediate
|
|
||||||
url: https://en.wikipedia.org/wiki/Cryptographic_hash_function
|
|
||||||
domains:
|
|
||||||
- incident-response
|
|
||||||
- static-investigations
|
|
||||||
- malware-analysis
|
|
||||||
phases:
|
|
||||||
- data-collection
|
|
||||||
- examination
|
|
||||||
tags:
|
|
||||||
- cryptography
|
|
||||||
- data-integrity
|
|
||||||
- evidence-preservation
|
|
||||||
knowledgebase: false
|
|
||||||
|
|
||||||
# Konfiguration der Domänen
|
- **Frontend**: Astro mit TypeScript, responsive CSS
|
||||||
domains:
|
- **Backend**: Node.js API-Routen mit intelligenter Ratenbegrenzung
|
||||||
- id: incident-response
|
- **KI-Pipeline**: Micro-Task-Architektur mit Audit-Protokollierung
|
||||||
name: Incident Response & Breach-Untersuchung
|
- **Daten**: YAML-basierte Tool-Datenbank mit Git-basierten Beiträgen
|
||||||
- id: static-investigations
|
- **Suche**: Dual-Mode Text- und semantische Vector-Suche
|
||||||
name: Datenträgerforensik & Ermittlungen
|
- **Auth**: OIDC-Integration mit Session-Management
|
||||||
- id: malware-analysis
|
|
||||||
name: Malware-Analyse & Reverse Engineering
|
|
||||||
- id: mobile-forensics
|
|
||||||
name: Mobile Geräte & App-Forensik
|
|
||||||
- id: cloud-forensics
|
|
||||||
name: Cloud & Virtuelle Umgebungen
|
|
||||||
|
|
||||||
# Konfiguration der Phasen (NIST Framework)
|
|
||||||
phases:
|
|
||||||
- id: data-collection
|
|
||||||
name: Datensammlung
|
|
||||||
description: Imaging, Acquisition, Remote Collection Tools
|
|
||||||
- id: examination
|
|
||||||
name: Auswertung
|
|
||||||
description: Parsing, Extraction, Initial Analysis Tools
|
|
||||||
- id: analysis
|
|
||||||
name: Analyse
|
|
||||||
description: Deep Analysis, Correlation, Visualization Tools
|
|
||||||
- id: reporting
|
|
||||||
name: Bericht & Präsentation
|
|
||||||
description: Documentation, Visualization, Presentation Tools
|
|
||||||
|
|
||||||
# Domänenübergreifende Kategorien
|
|
||||||
domain-agnostic-software:
|
|
||||||
- id: collaboration-general
|
|
||||||
name: Übergreifend & Kollaboration
|
|
||||||
description: Cross-cutting tools and collaboration platforms
|
|
||||||
- id: specific-os
|
|
||||||
name: Betriebssysteme
|
|
||||||
description: Operating Systems which focus on forensics
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Repository aktualisieren
|
|
||||||
cd /opt/forensic-pathways
|
|
||||||
sudo git pull
|
|
||||||
|
|
||||||
# Dependencies aktualisieren
|
|
||||||
sudo npm install
|
|
||||||
|
|
||||||
# Rebuild
|
|
||||||
sudo npm run build
|
|
||||||
|
|
||||||
# Service neustarten
|
|
||||||
sudo systemctl restart forensic-pathways
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💾 Backup
|
|
||||||
|
|
||||||
Wichtige Dateien für Backup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/opt/forensic-pathways/src/data/tools.yaml
|
|
||||||
/opt/forensic-pathways/.env
|
|
||||||
/etc/nginx/sites-available/forensic-pathways
|
|
||||||
/etc/systemd/system/forensic-pathways.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🤝 Beiträge
|
|
||||||
|
|
||||||
Contributions sind willkommen! Bitte:
|
|
||||||
|
|
||||||
1. Issue im Repository erstellen
|
|
||||||
2. Feature-Branch erstellen
|
|
||||||
3. Pull Request öffnen
|
|
||||||
4. Tests durchführen
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
Bei Problemen oder Fragen:
|
|
||||||
|
|
||||||
- **Issues:** [Repository Issues](https://git.cc24.dev/mstoeck3/forensic-pathways/issues)
|
|
||||||
- **Dokumentation:** Siehe `/knowledgebase` auf der Website
|
|
||||||
|
|
||||||
## 📄 Lizenz
|
|
||||||
|
|
||||||
Dieses Projekt steht unter der **BSD-3-Clause** Lizenz.
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
|
import { remarkVideoPlugin } from './src/utils/remarkVideoPlugin.ts';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
@@ -7,6 +8,13 @@ export default defineConfig({
|
|||||||
mode: 'standalone'
|
mode: 'standalone'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
markdown: {
|
||||||
|
remarkPlugins: [
|
||||||
|
remarkVideoPlugin
|
||||||
|
],
|
||||||
|
extendDefaultPlugins: true
|
||||||
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
assets: '_astro'
|
assets: '_astro'
|
||||||
},
|
},
|
||||||
@@ -16,4 +24,4 @@ export default defineConfig({
|
|||||||
host: true
|
host: true
|
||||||
},
|
},
|
||||||
allowImportingTsExtensions: true
|
allowImportingTsExtensions: true
|
||||||
});
|
});
|
||||||
337
context.md
337
context.md
@@ -1,337 +0,0 @@
|
|||||||
# ForensicPathways Architecture System Prompt
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
ForensicPathways is a curated directory of Digital Forensics and Incident Response (DFIR) tools, methods, and concepts built with Astro and TypeScript. It serves as an educational and professional resource for forensic investigators, following the NIST SP 800-86 framework (Kent, Chevalier, Grance & Dang).
|
|
||||||
|
|
||||||
## Core Technology Stack
|
|
||||||
- **Framework**: Astro (static site generator with islands architecture)
|
|
||||||
- **Language**: TypeScript with strict typing
|
|
||||||
- **Styling**: Vanilla CSS with custom properties (CSS variables)
|
|
||||||
- **Data Storage**: YAML files for tool catalog
|
|
||||||
- **Content**: Astro Content Collections for knowledge base articles
|
|
||||||
- **Authentication**: OIDC (OpenID Connect) with configurable requirements
|
|
||||||
- **AI Integration**: Mistral API for workflow recommendations
|
|
||||||
- **File Storage**: Nextcloud with local fallback
|
|
||||||
- **Version Control**: Git integration for community contributions
|
|
||||||
|
|
||||||
## Data Model Architecture
|
|
||||||
|
|
||||||
### Core Entity: Tool
|
|
||||||
```yaml
|
|
||||||
name: string # Tool identifier
|
|
||||||
icon: string? # Emoji icon
|
|
||||||
type: 'software'|'method'|'concept' # Tool classification
|
|
||||||
description: string # Detailed description
|
|
||||||
domains: string[] # Forensic domains (incident-response, malware-analysis, etc.)
|
|
||||||
phases: string[] # NIST framework phases (data-collection, examination, analysis, reporting)
|
|
||||||
platforms: string[] # Operating systems (for software only)
|
|
||||||
skillLevel: string # novice|beginner|intermediate|advanced|expert
|
|
||||||
url: string # Primary documentation/homepage
|
|
||||||
projectUrl: string? # Hosted instance URL (CC24 server)
|
|
||||||
license: string? # Software license
|
|
||||||
knowledgebase: boolean? # Has detailed documentation
|
|
||||||
tags: string[] # Searchable keywords
|
|
||||||
related_concepts: string[]? # Links to concept-type tools
|
|
||||||
related_software: string[]? #Links to software-type-tools
|
|
||||||
```
|
|
||||||
|
|
||||||
### Taxonomies
|
|
||||||
- **Domains**: Forensic specializations (7 main domains)
|
|
||||||
- **Phases**: NIST investigation phases (4 phases)
|
|
||||||
- **Domain-Agnostic-Software**: Cross-cutting tools and platforms
|
|
||||||
- **Skill Levels**: Standardized competency requirements
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
|
|
||||||
### Layout System
|
|
||||||
- **BaseLayout.astro**: Global layout with theme system, authentication setup, shared utilities
|
|
||||||
- **Navigation.astro**: Main navigation with active state management
|
|
||||||
- **Footer.astro**: Site footer with links and licensing info
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
- **ToolCard.astro**: Individual tool display with metadata, actions, and type-specific styling
|
|
||||||
- **ToolMatrix.astro**: Matrix view showing tools by domain/phase intersection
|
|
||||||
- **ToolFilters.astro**: Search, filtering, and view controls
|
|
||||||
- **AIQueryInterface.astro**: AI-powered workflow recommendation system
|
|
||||||
|
|
||||||
### Utility Components
|
|
||||||
- **ShareButton.astro**: URL sharing with multiple view options
|
|
||||||
- **ContributionButton.astro**: Authenticated contribution links
|
|
||||||
- **ThemeToggle.astro**: Light/dark/auto theme switching
|
|
||||||
|
|
||||||
## Feature Systems
|
|
||||||
|
|
||||||
### 1. Authentication System (`src/utils/auth.ts`)
|
|
||||||
- **OIDC Integration**: Uses environment-configured provider
|
|
||||||
- **Contextual Requirements**: Different auth requirements for contributions vs AI features
|
|
||||||
- **Session Management**: JWT-based with configurable expiration
|
|
||||||
- **Client-Side Utilities**: Window-level auth checking functions
|
|
||||||
|
|
||||||
### 2. AI Recommendation System
|
|
||||||
- **Dual Modes**:
|
|
||||||
- Workflow mode: Multi-phase recommendations for scenarios
|
|
||||||
- Tool mode: Specific tool recommendations for problems
|
|
||||||
- **Rate Limiting**: Queue-based system with status updates
|
|
||||||
- **Data Integration**: Uses compressed tool database for AI context
|
|
||||||
- **Response Validation**: Ensures AI only recommends existing tools
|
|
||||||
|
|
||||||
### 3. Contribution System
|
|
||||||
- **Git Integration**: Automated issue creation via Gitea/GitHub/GitLab APIs
|
|
||||||
- **Tool Contributions**: Form-based tool submissions
|
|
||||||
- **Knowledge Base**: Rich text with file upload support
|
|
||||||
- **Validation**: Client and server-side validation with Zod schemas
|
|
||||||
|
|
||||||
### 4. File Upload System
|
|
||||||
- **Primary**: Nextcloud integration with public link generation
|
|
||||||
- **Fallback**: Local file storage with public URL generation
|
|
||||||
- **Validation**: File type and size restrictions
|
|
||||||
- **Rate Limiting**: Per-user upload quotas
|
|
||||||
|
|
||||||
## Styling Architecture
|
|
||||||
|
|
||||||
### CSS Custom Properties System
|
|
||||||
```css
|
|
||||||
/* Core color system */
|
|
||||||
--color-primary: /* Adaptive based on theme */
|
|
||||||
--color-accent: /* Secondary brand color */
|
|
||||||
--color-bg: /* Main background */
|
|
||||||
--color-text: /* Primary text */
|
|
||||||
|
|
||||||
/* Component-specific colors */
|
|
||||||
--color-hosted: /* CC24 server hosted tools */
|
|
||||||
--color-oss: /* Open source tools */
|
|
||||||
--color-method: /* Methodology entries */
|
|
||||||
--color-concept: /* Knowledge concepts */
|
|
||||||
|
|
||||||
/* Theme system */
|
|
||||||
[data-theme="light"] { /* Light theme values */ }
|
|
||||||
[data-theme="dark"] { /* Dark theme values */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Styling Patterns
|
|
||||||
- **Card System**: Consistent `.card` base with type-specific variants
|
|
||||||
- **Badge System**: Status and metadata indicators
|
|
||||||
- **Button System**: Semantic button classes with size variants
|
|
||||||
- **Grid System**: CSS Grid with responsive breakpoints
|
|
||||||
|
|
||||||
## Data Flow Architecture
|
|
||||||
|
|
||||||
### 1. Data Loading (`src/utils/dataService.ts`)
|
|
||||||
```typescript
|
|
||||||
// Daily randomization with seeded shuffle
|
|
||||||
getToolsData() → shuffled tools array
|
|
||||||
getCompressedToolsDataForAI() → AI-optimized dataset
|
|
||||||
clearCache() → cache invalidation
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Client-Side State Management
|
|
||||||
- **Global Tools Data**: Attached to `window.toolsData`
|
|
||||||
- **Filter State**: Component-local state with event emission
|
|
||||||
- **View State**: Grid/Matrix/AI view switching
|
|
||||||
- **Modal State**: Tool detail overlays with multi-modal support
|
|
||||||
|
|
||||||
### 3. Search and Filtering
|
|
||||||
- **Text Search**: Name, description, tags
|
|
||||||
- **Faceted Filtering**: Domain, phase, skill level, license type
|
|
||||||
- **Tag Cloud**: Frequency-weighted tag selection
|
|
||||||
- **Real-time Updates**: Immediate filtering with event system
|
|
||||||
|
|
||||||
## API Architecture
|
|
||||||
|
|
||||||
### Endpoint Structure
|
|
||||||
```
|
|
||||||
/api/auth/ # Authentication endpoints
|
|
||||||
login.ts # OIDC initiation
|
|
||||||
process.ts # OIDC callback processing
|
|
||||||
status.ts # Auth status checking
|
|
||||||
|
|
||||||
/api/contribute/ # Contribution endpoints
|
|
||||||
tool.ts # Tool submissions
|
|
||||||
knowledgebase.ts # KB article submissions
|
|
||||||
|
|
||||||
/api/ai/ # AI features
|
|
||||||
query.ts # Workflow recommendations
|
|
||||||
queue-status.ts # Queue monitoring
|
|
||||||
|
|
||||||
/api/upload/ # File handling
|
|
||||||
media.ts # File upload processing
|
|
||||||
|
|
||||||
/api/health.ts # System health check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Patterns
|
|
||||||
All APIs use consolidated response utilities (`src/utils/api.ts`):
|
|
||||||
- **Success**: `apiResponse.success()`, `apiResponse.created()`
|
|
||||||
- **Errors**: `apiError.badRequest()`, `apiError.unauthorized()`, etc.
|
|
||||||
- **Server Errors**: `apiServerError.internal()`, etc.
|
|
||||||
|
|
||||||
## File Organization Patterns
|
|
||||||
|
|
||||||
### Page Structure
|
|
||||||
- **Static Pages**: About, impressum, status
|
|
||||||
- **Dynamic Pages**: Tool details, knowledge base articles
|
|
||||||
- **Authenticated Pages**: Contribution forms
|
|
||||||
- **API Routes**: RESTful endpoints with consistent naming
|
|
||||||
|
|
||||||
### Utility Organization
|
|
||||||
- **Tool Operations**: `toolHelpers.ts` - Core tool manipulation
|
|
||||||
- **Data Management**: `dataService.ts` - YAML loading and caching
|
|
||||||
- **Authentication**: `auth.ts` - OIDC flow and session management
|
|
||||||
- **External APIs**: `gitContributions.ts`, `nextcloud.ts`
|
|
||||||
- **Rate Limiting**: `rateLimitedQueue.ts` - AI request queuing
|
|
||||||
|
|
||||||
## Key Architectural Decisions
|
|
||||||
|
|
||||||
### 1. Static-First with Dynamic Islands
|
|
||||||
- Astro's islands architecture for interactivity
|
|
||||||
- Static generation for performance
|
|
||||||
- Selective hydration for complex components
|
|
||||||
|
|
||||||
### 2. YAML-Based Data Management
|
|
||||||
- Human-readable tool catalog
|
|
||||||
- Git-friendly versioning
|
|
||||||
- Type-safe loading with Zod validation
|
|
||||||
|
|
||||||
### 3. Contextual Authentication
|
|
||||||
- Optional authentication based on feature
|
|
||||||
- Graceful degradation for unauthenticated users
|
|
||||||
- Environment-configurable requirements
|
|
||||||
|
|
||||||
### 4. Multi-Modal UI Patterns
|
|
||||||
- Grid view for browsing
|
|
||||||
- Matrix view for relationship visualization
|
|
||||||
- AI interface for guided recommendations
|
|
||||||
|
|
||||||
### 5. Progressive Enhancement
|
|
||||||
- Core functionality works without JavaScript
|
|
||||||
- Enhanced features require client-side hydration
|
|
||||||
- Responsive design with mobile-first approach
|
|
||||||
|
|
||||||
## Development Patterns
|
|
||||||
|
|
||||||
### Component Design
|
|
||||||
- Props interfaces with TypeScript
|
|
||||||
- Consistent styling via CSS classes
|
|
||||||
- Event-driven communication between components
|
|
||||||
- Server-side rendering with client-side enhancement
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Comprehensive try-catch in API routes
|
|
||||||
- User-friendly error messages
|
|
||||||
- Graceful fallbacks for external services
|
|
||||||
- Logging for debugging and monitoring
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
- Daily data randomization with caching
|
|
||||||
- Compressed datasets for AI context
|
|
||||||
- Rate limiting for external API calls
|
|
||||||
- Efficient DOM updates with targeted selectors
|
|
||||||
|
|
||||||
This architecture emphasizes maintainability, user experience, and extensibility while managing the complexity of a multi-feature forensics tool directory.
|
|
||||||
|
|
||||||
|
|
||||||
## Absolutely Essential (Core Architecture - 8 files)
|
|
||||||
|
|
||||||
**File**: `src/data/tools.yaml.example`
|
|
||||||
**Why**: Defines the core data model - what a "tool" is, the schema, domains, phases, etc. Without this, an AI can't understand what the application manages.
|
|
||||||
|
|
||||||
**File**: `src/pages/index.astro`
|
|
||||||
**Why**: Main application entry point showing the core functionality (filters, matrix, AI interface, tool grid). Contains the primary application logic flow.
|
|
||||||
|
|
||||||
**File**: `src/layouts/BaseLayout.astro`
|
|
||||||
**Why**: Global layout with theme system, authentication setup, and shared utility functions. Shows the overall app structure.
|
|
||||||
|
|
||||||
**File**: `src/utils/dataService.ts`
|
|
||||||
**Why**: Core data loading and processing logic. Essential for understanding how data flows through the application.
|
|
||||||
|
|
||||||
**File**: `src/utils/toolHelpers.ts`
|
|
||||||
**Why**: Core utility functions used throughout the app (slug creation, tool identification, hosting checks).
|
|
||||||
|
|
||||||
**File**: `src/styles/global.css`
|
|
||||||
**Why**: Complete styling system - defines visual architecture, component styling, theme system, responsive design.
|
|
||||||
|
|
||||||
**File**: `src/env.d.ts`
|
|
||||||
**Why**: Global TypeScript definitions and window interface extensions. Shows the global API surface.
|
|
||||||
|
|
||||||
**File**: `src/components/ToolCard.astro`
|
|
||||||
**Why**: Shows how the core entity (tools) are rendered and structured. Represents the component architecture pattern.
|
|
||||||
|
|
||||||
## Secondary Priority (Major Features - 4 files)
|
|
||||||
|
|
||||||
**File**: `src/components/ToolMatrix.astro` - Matrix view functionality
|
|
||||||
**File**: `src/components/AIQueryInterface.astro` - AI recommendation system
|
|
||||||
**File**: `src/utils/auth.ts` - Authentication system
|
|
||||||
**File**: `src/content/config.ts` - Content collection schema
|
|
||||||
|
|
||||||
## Strategy for Context Management
|
|
||||||
|
|
||||||
1. **Always provide**: The 8 essential files above
|
|
||||||
2. **Add selectively**: Include 1-3 secondary files based on the specific development task
|
|
||||||
3. **Reference others**: Mention other relevant files by name/purpose without including full content
|
|
||||||
|
|
||||||
user01@altiera /v/h/u/P/forensic-pathways (main)> tree src
|
|
||||||
src
|
|
||||||
├── components
|
|
||||||
│ ├── AIQueryInterface.astro
|
|
||||||
│ ├── ContributionButton.astro
|
|
||||||
│ ├── Footer.astro
|
|
||||||
│ ├── Navigation.astro
|
|
||||||
│ ├── ShareButton.astro
|
|
||||||
│ ├── ThemeToggle.astro
|
|
||||||
│ ├── ToolCard.astro
|
|
||||||
│ ├── ToolFilters.astro
|
|
||||||
│ └── ToolMatrix.astro
|
|
||||||
├── content
|
|
||||||
│ ├── config.ts
|
|
||||||
│ └── knowledgebase
|
|
||||||
│ ├── android-logical-imaging.md
|
|
||||||
│ ├── kali-linux.md
|
|
||||||
│ ├── misp.md
|
|
||||||
│ ├── nextcloud.md
|
|
||||||
│ ├── regular-expressions-regex.md
|
|
||||||
│ └── velociraptor.md
|
|
||||||
├── data
|
|
||||||
│ ├── tools.yaml
|
|
||||||
│ └── tools.yaml.example
|
|
||||||
├── env.d.ts
|
|
||||||
├── layouts
|
|
||||||
│ └── BaseLayout.astro
|
|
||||||
├── pages
|
|
||||||
│ ├── about.astro
|
|
||||||
│ ├── api
|
|
||||||
│ │ ├── ai
|
|
||||||
│ │ │ ├── query.ts
|
|
||||||
│ │ │ └── queue-status.ts
|
|
||||||
│ │ ├── auth
|
|
||||||
│ │ │ ├── login.ts
|
|
||||||
│ │ │ ├── process.ts
|
|
||||||
│ │ │ └── status.ts
|
|
||||||
│ │ ├── contribute
|
|
||||||
│ │ │ ├── knowledgebase.ts
|
|
||||||
│ │ │ └── tool.ts
|
|
||||||
│ │ ├── health.ts
|
|
||||||
│ │ └── upload
|
|
||||||
│ │ └── media.ts
|
|
||||||
│ ├── auth
|
|
||||||
│ │ └── callback.astro
|
|
||||||
│ ├── contribute
|
|
||||||
│ │ ├── index.astro
|
|
||||||
│ │ ├── knowledgebase.astro
|
|
||||||
│ │ └── tool.astro
|
|
||||||
│ ├── impressum.astro
|
|
||||||
│ ├── index.astro
|
|
||||||
│ ├── knowledgebase
|
|
||||||
│ │ └── [slug].astro
|
|
||||||
│ ├── knowledgebase.astro
|
|
||||||
│ └── status.astro
|
|
||||||
├── styles
|
|
||||||
│ └── global.css
|
|
||||||
└── utils
|
|
||||||
├── api.ts
|
|
||||||
├── auth.ts
|
|
||||||
├── dataService.ts
|
|
||||||
├── gitContributions.ts
|
|
||||||
├── nextcloud.ts
|
|
||||||
├── rateLimitedQueue.ts
|
|
||||||
└── toolHelpers.ts
|
|
||||||
17 directories, 47 files
|
|
||||||
204586
data/embeddings.json
Normal file
204586
data/embeddings.json
Normal file
File diff suppressed because it is too large
Load Diff
863
deploy.sh
Executable file
863
deploy.sh
Executable file
@@ -0,0 +1,863 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ForensicPathways Deployment Script – *ownership-aware* + VISUAL ENHANCED
|
||||||
|
# Usage: sudo ./deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🎨 VISUAL ENHANCEMENT SYSTEM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Color palette
|
||||||
|
declare -r RED='\033[0;31m'
|
||||||
|
declare -r GREEN='\033[0;32m'
|
||||||
|
declare -r YELLOW='\033[0;33m'
|
||||||
|
declare -r BLUE='\033[0;34m'
|
||||||
|
declare -r MAGENTA='\033[0;35m'
|
||||||
|
declare -r CYAN='\033[0;36m'
|
||||||
|
declare -r WHITE='\033[0;37m'
|
||||||
|
declare -r BOLD='\033[1m'
|
||||||
|
declare -r DIM='\033[2m'
|
||||||
|
declare -r ITALIC='\033[3m'
|
||||||
|
declare -r UNDERLINE='\033[4m'
|
||||||
|
declare -r BLINK='\033[5m'
|
||||||
|
declare -r REVERSE='\033[7m'
|
||||||
|
declare -r RESET='\033[0m'
|
||||||
|
|
||||||
|
# Gradient colors
|
||||||
|
declare -r GRAD1='\033[38;5;196m' # Bright red
|
||||||
|
declare -r GRAD2='\033[38;5;202m' # Orange
|
||||||
|
declare -r GRAD3='\033[38;5;208m' # Dark orange
|
||||||
|
declare -r GRAD4='\033[38;5;214m' # Yellow orange
|
||||||
|
declare -r GRAD5='\033[38;5;220m' # Yellow
|
||||||
|
declare -r GRAD6='\033[38;5;118m' # Light green
|
||||||
|
declare -r GRAD7='\033[38;5;82m' # Green
|
||||||
|
declare -r GRAD8='\033[38;5;51m' # Cyan
|
||||||
|
declare -r GRAD9='\033[38;5;33m' # Blue
|
||||||
|
declare -r GRAD10='\033[38;5;129m' # Purple
|
||||||
|
|
||||||
|
# Background colors
|
||||||
|
declare -r BG_RED='\033[41m'
|
||||||
|
declare -r BG_GREEN='\033[42m'
|
||||||
|
declare -r BG_YELLOW='\033[43m'
|
||||||
|
declare -r BG_BLUE='\033[44m'
|
||||||
|
declare -r BG_MAGENTA='\033[45m'
|
||||||
|
declare -r BG_CYAN='\033[46m'
|
||||||
|
|
||||||
|
# Unicode box drawing
|
||||||
|
declare -r BOX_H='═'
|
||||||
|
declare -r BOX_V='║'
|
||||||
|
declare -r BOX_TL='╔'
|
||||||
|
declare -r BOX_TR='╗'
|
||||||
|
declare -r BOX_BL='╚'
|
||||||
|
declare -r BOX_BR='╝'
|
||||||
|
declare -r BOX_T='╦'
|
||||||
|
declare -r BOX_B='╩'
|
||||||
|
declare -r BOX_L='╠'
|
||||||
|
declare -r BOX_R='╣'
|
||||||
|
declare -r BOX_C='╬'
|
||||||
|
|
||||||
|
# Fancy Unicode characters
|
||||||
|
declare -r ARROW_R='▶'
|
||||||
|
declare -r ARROW_D='▼'
|
||||||
|
declare -r DIAMOND='◆'
|
||||||
|
declare -r STAR='★'
|
||||||
|
declare -r BULLET='●'
|
||||||
|
declare -r CIRCLE='◯'
|
||||||
|
declare -r SQUARE='▪'
|
||||||
|
declare -r TRIANGLE='▲'
|
||||||
|
|
||||||
|
# Animation frames
|
||||||
|
SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||||
|
PULSE_FRAMES=('●' '◐' '◑' '◒' '◓' '◔' '◕' '◖' '◗' '◘')
|
||||||
|
WAVE_FRAMES=('▁' '▂' '▃' '▄' '▅' '▆' '▇' '█' '▇' '▆' '▅' '▄' '▃' '▂')
|
||||||
|
|
||||||
|
# Terminal dimensions
|
||||||
|
COLS=$(tput cols 2>/dev/null || echo 80)
|
||||||
|
LINES=$(tput lines 2>/dev/null || echo 24)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🎯 VISUAL FUNCTIONS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
print_gradient_text() {
|
||||||
|
local text="$1"
|
||||||
|
local colors=("$GRAD1" "$GRAD2" "$GRAD3" "$GRAD4" "$GRAD5" "$GRAD6" "$GRAD7" "$GRAD8" "$GRAD9" "$GRAD10")
|
||||||
|
local length=${#text}
|
||||||
|
local color_count=${#colors[@]}
|
||||||
|
|
||||||
|
for ((i=0; i<length; i++)); do
|
||||||
|
local color_idx=$((i * color_count / length))
|
||||||
|
printf "${colors[$color_idx]}${text:$i:1}"
|
||||||
|
done
|
||||||
|
printf "$RESET"
|
||||||
|
}
|
||||||
|
|
||||||
|
animate_text() {
|
||||||
|
local text="$1"
|
||||||
|
local delay="${2:-0.03}"
|
||||||
|
|
||||||
|
for ((i=0; i<=${#text}; i++)); do
|
||||||
|
printf "\r${CYAN}${text:0:$i}${RESET}"
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_box() {
|
||||||
|
local title="$1"
|
||||||
|
local content="$2"
|
||||||
|
local width=${3:-$((COLS-4))}
|
||||||
|
local color="${4:-$CYAN}"
|
||||||
|
|
||||||
|
# Top border
|
||||||
|
printf "${color}${BOX_TL}"
|
||||||
|
for ((i=0; i<width-2; i++)); do printf "${BOX_H}"; done
|
||||||
|
printf "${BOX_TR}${RESET}\n"
|
||||||
|
|
||||||
|
# Title line
|
||||||
|
if [ -n "$title" ]; then
|
||||||
|
local title_len=${#title}
|
||||||
|
local padding=$(((width-title_len-2)/2))
|
||||||
|
printf "${color}${BOX_V}${RESET}"
|
||||||
|
for ((i=0; i<padding; i++)); do printf " "; done
|
||||||
|
printf "${BOLD}${WHITE}$title${RESET}"
|
||||||
|
for ((i=0; i<width-title_len-padding-2; i++)); do printf " "; done
|
||||||
|
printf "${color}${BOX_V}${RESET}\n"
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
printf "${color}${BOX_L}"
|
||||||
|
for ((i=0; i<width-2; i++)); do printf "${BOX_H}"; done
|
||||||
|
printf "${BOX_R}${RESET}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Content lines
|
||||||
|
while IFS= read -r line; do
|
||||||
|
local line_len=${#line}
|
||||||
|
printf "${color}${BOX_V}${RESET} %-$((width-4))s ${color}${BOX_V}${RESET}\n" "$line"
|
||||||
|
done <<< "$content"
|
||||||
|
|
||||||
|
# Bottom border
|
||||||
|
printf "${color}${BOX_BL}"
|
||||||
|
for ((i=0; i<width-2; i++)); do printf "${BOX_H}"; done
|
||||||
|
printf "${BOX_BR}${RESET}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
progress_bar() {
|
||||||
|
local current="$1"
|
||||||
|
local total="$2"
|
||||||
|
local width="${3:-50}"
|
||||||
|
local label="$4"
|
||||||
|
|
||||||
|
local percentage=$((current * 100 / total))
|
||||||
|
local filled=$((current * width / total))
|
||||||
|
local empty=$((width - filled))
|
||||||
|
|
||||||
|
printf "\r${BOLD}%s${RESET} [" "$label"
|
||||||
|
|
||||||
|
# Filled portion with gradient
|
||||||
|
for ((i=0; i<filled; i++)); do
|
||||||
|
local color_idx=$((i * 10 / width))
|
||||||
|
case $color_idx in
|
||||||
|
0) printf "${GRAD1}█${RESET}" ;;
|
||||||
|
1) printf "${GRAD2}█${RESET}" ;;
|
||||||
|
2) printf "${GRAD3}█${RESET}" ;;
|
||||||
|
3) printf "${GRAD4}█${RESET}" ;;
|
||||||
|
4) printf "${GRAD5}█${RESET}" ;;
|
||||||
|
5) printf "${GRAD6}█${RESET}" ;;
|
||||||
|
6) printf "${GRAD7}█${RESET}" ;;
|
||||||
|
7) printf "${GRAD8}█${RESET}" ;;
|
||||||
|
8) printf "${GRAD9}█${RESET}" ;;
|
||||||
|
*) printf "${GRAD10}█${RESET}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Empty portion
|
||||||
|
for ((i=0; i<empty; i++)); do
|
||||||
|
printf "${DIM}░${RESET}"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf "] ${BOLD}%d%%${RESET}" "$percentage"
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner() {
|
||||||
|
local pid=$1
|
||||||
|
local message="$2"
|
||||||
|
local frame=0
|
||||||
|
|
||||||
|
while kill -0 $pid 2>/dev/null; do
|
||||||
|
printf "\r${CYAN}${SPINNER_FRAMES[$frame]}${RESET} ${message}"
|
||||||
|
frame=$(((frame + 1) % ${#SPINNER_FRAMES[@]}))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
printf "\r${GREEN}✓${RESET} ${message}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
pulsing_dots() {
|
||||||
|
local count="${1:-5}"
|
||||||
|
local cycles="${2:-3}"
|
||||||
|
|
||||||
|
for ((c=0; c<cycles; c++)); do
|
||||||
|
for frame in "${PULSE_FRAMES[@]}"; do
|
||||||
|
printf "\r${MAGENTA}"
|
||||||
|
for ((i=0; i<count; i++)); do
|
||||||
|
printf "$frame "
|
||||||
|
done
|
||||||
|
printf "${RESET}"
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wave_animation() {
|
||||||
|
local width="${1:-$((COLS/2))}"
|
||||||
|
local cycles="${2:-2}"
|
||||||
|
|
||||||
|
for ((c=0; c<cycles; c++)); do
|
||||||
|
for frame in "${WAVE_FRAMES[@]}"; do
|
||||||
|
printf "\r${CYAN}"
|
||||||
|
for ((i=0; i<width; i++)); do
|
||||||
|
printf "$frame"
|
||||||
|
done
|
||||||
|
printf "${RESET}"
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
celebrate() {
|
||||||
|
local width=$((COLS-10))
|
||||||
|
|
||||||
|
# Fireworks effect
|
||||||
|
for ((i=0; i<5; i++)); do
|
||||||
|
printf "\r"
|
||||||
|
for ((j=0; j<width; j++)); do
|
||||||
|
case $((RANDOM % 10)) in
|
||||||
|
0) printf "${GRAD1}*${RESET}" ;;
|
||||||
|
1) printf "${GRAD3}•${RESET}" ;;
|
||||||
|
2) printf "${GRAD5}+${RESET}" ;;
|
||||||
|
3) printf "${GRAD7}×${RESET}" ;;
|
||||||
|
4) printf "${GRAD9}◆${RESET}" ;;
|
||||||
|
*) printf " " ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
typewriter() {
|
||||||
|
local text="$1"
|
||||||
|
local delay="${2:-0.02}"
|
||||||
|
local color="${3:-$GREEN}"
|
||||||
|
|
||||||
|
printf "${color}"
|
||||||
|
for ((i=0; i<${#text}; i++)); do
|
||||||
|
printf "${text:$i:1}"
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
printf "${RESET}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
fancy_header() {
|
||||||
|
local title="$1"
|
||||||
|
local subtitle="$2"
|
||||||
|
|
||||||
|
# Calculate centering
|
||||||
|
local title_len=${#title}
|
||||||
|
local subtitle_len=${#subtitle}
|
||||||
|
local box_width=$((COLS > 80 ? 80 : COLS-4))
|
||||||
|
local title_padding=$(((box_width-title_len)/2))
|
||||||
|
local subtitle_padding=$(((box_width-subtitle_len)/2))
|
||||||
|
|
||||||
|
echo
|
||||||
|
# Top gradient border
|
||||||
|
printf "${BOLD}"
|
||||||
|
for ((i=0; i<box_width; i++)); do
|
||||||
|
local color_idx=$((i * 10 / box_width))
|
||||||
|
case $color_idx in
|
||||||
|
0|1) printf "${GRAD1}${BOX_H}" ;;
|
||||||
|
2|3) printf "${GRAD3}${BOX_H}" ;;
|
||||||
|
4|5) printf "${GRAD5}${BOX_H}" ;;
|
||||||
|
6|7) printf "${GRAD7}${BOX_H}" ;;
|
||||||
|
*) printf "${GRAD9}${BOX_H}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf "${RESET}\n"
|
||||||
|
|
||||||
|
# Title line
|
||||||
|
printf "${GRAD1}${BOX_V}${RESET}"
|
||||||
|
for ((i=0; i<title_padding; i++)); do printf " "; done
|
||||||
|
print_gradient_text "$title"
|
||||||
|
for ((i=0; i<box_width-title_len-title_padding-2; i++)); do printf " "; done
|
||||||
|
printf "${GRAD1}${BOX_V}${RESET}\n"
|
||||||
|
|
||||||
|
# Subtitle line
|
||||||
|
if [ -n "$subtitle" ]; then
|
||||||
|
printf "${GRAD3}${BOX_V}${RESET}"
|
||||||
|
for ((i=0; i<subtitle_padding; i++)); do printf " "; done
|
||||||
|
printf "${ITALIC}${DIM}${subtitle}${RESET}"
|
||||||
|
for ((i=0; i<box_width-subtitle_len-subtitle_padding-2; i++)); do printf " "; done
|
||||||
|
printf "${GRAD3}${BOX_V}${RESET}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bottom gradient border
|
||||||
|
printf "${BOLD}"
|
||||||
|
for ((i=0; i<box_width; i++)); do
|
||||||
|
local color_idx=$((i * 10 / box_width))
|
||||||
|
case $color_idx in
|
||||||
|
0|1) printf "${GRAD1}${BOX_H}" ;;
|
||||||
|
2|3) printf "${GRAD3}${BOX_H}" ;;
|
||||||
|
4|5) printf "${GRAD5}${BOX_H}" ;;
|
||||||
|
6|7) printf "${GRAD7}${BOX_H}" ;;
|
||||||
|
*) printf "${GRAD9}${BOX_H}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf "${RESET}\n"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
section_header() {
|
||||||
|
local section_num="$1"
|
||||||
|
local title="$2"
|
||||||
|
local icon="$3"
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf "${BOLD}${BG_BLUE}${WHITE} PHASE %s ${RESET} " "$section_num"
|
||||||
|
printf "${BOLD}${BLUE}%s %s${RESET}\n" "$icon" "$title"
|
||||||
|
|
||||||
|
# Animated underline
|
||||||
|
printf "${BLUE}"
|
||||||
|
for ((i=0; i<$((${#title}+10)); i++)); do
|
||||||
|
printf "▄"
|
||||||
|
sleep 0.01
|
||||||
|
done
|
||||||
|
printf "${RESET}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_ok() {
|
||||||
|
printf "${GREEN}${BOLD}✓${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_error() {
|
||||||
|
printf "${RED}${BOLD}✗${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_warning() {
|
||||||
|
printf "${YELLOW}${BOLD}⚠${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_info() {
|
||||||
|
printf "${CYAN}${BOLD}ℹ${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_working() {
|
||||||
|
printf "${MAGENTA}${BOLD}◐${RESET} %s" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
animated_countdown() {
|
||||||
|
local seconds="$1"
|
||||||
|
local message="$2"
|
||||||
|
|
||||||
|
for ((i=seconds; i>0; i--)); do
|
||||||
|
printf "\r${YELLOW}${BOLD}⏳ $message in ${i}s...${RESET}"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf "\r${GREEN}${BOLD}🚀 $message${RESET} \n"
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix_rain() {
|
||||||
|
local duration="${1:-2}"
|
||||||
|
local chars="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#\$^&*()_+-=[]{}|;:,.<>?"
|
||||||
|
|
||||||
|
for ((i=0; i<duration*10; i++)); do
|
||||||
|
printf "\r${GREEN}"
|
||||||
|
for ((j=0; j<$((COLS/3)); j++)); do
|
||||||
|
printf "%s" "${chars:$((RANDOM % ${#chars})):1}"
|
||||||
|
done
|
||||||
|
printf "${RESET}"
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🚀 MAIN SCRIPT VARIABLES
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
WEBROOT="/var/www/forensic-pathways"
|
||||||
|
LOG_DIR="$WEBROOT/logs"
|
||||||
|
DATA_DIR="$WEBROOT/data"
|
||||||
|
UPLOADS_DIR="$WEBROOT/public/uploads"
|
||||||
|
|
||||||
|
# Get original user who called sudo
|
||||||
|
ORIGINAL_USER="${SUDO_USER:-$USER}"
|
||||||
|
ORIGINAL_HOME=$(eval echo "~$ORIGINAL_USER")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🎬 SPECTACULAR OPENING SEQUENCE
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Terminal setup
|
||||||
|
tput civis # Hide cursor
|
||||||
|
|
||||||
|
# ASCII Art Banner
|
||||||
|
fancy_header "FORENSIC PATHWAYS DEPLOYMENT" "Advanced Visual Enhancement System"
|
||||||
|
|
||||||
|
# Matrix effect intro
|
||||||
|
printf "${DIM}${GREEN}Initializing deployment matrix...${RESET}\n"
|
||||||
|
matrix_rain 1
|
||||||
|
|
||||||
|
# System information display
|
||||||
|
draw_box "DEPLOYMENT PARAMETERS" "$(cat << PARAMS
|
||||||
|
Timestamp: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
Original User: $ORIGINAL_USER
|
||||||
|
Working Directory: $(pwd)
|
||||||
|
Target Webroot: $WEBROOT
|
||||||
|
Terminal Size: ${COLS}x${LINES}
|
||||||
|
PARAMS
|
||||||
|
)" 60 "$MAGENTA"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Animated countdown
|
||||||
|
animated_countdown 3 "Starting deployment"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🔒 PHASE 0: SAFETY CHECKS
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "0" "SECURITY & SAFETY VALIDATION" "🔒"
|
||||||
|
|
||||||
|
status_working "Verifying root privileges"
|
||||||
|
pulsing_dots 3 1
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
status_error "Script must be run as root (use sudo)"
|
||||||
|
echo
|
||||||
|
printf "${RED}${BOLD}${BG_YELLOW} DEPLOYMENT TERMINATED ${RESET}\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
status_ok "Root privileges confirmed"
|
||||||
|
|
||||||
|
status_working "Validating project structure"
|
||||||
|
pulsing_dots 3 1
|
||||||
|
if [ ! -f "package.json" ] || [ ! -f "astro.config.mjs" ]; then
|
||||||
|
status_error "Must run from ForensicPathways project root"
|
||||||
|
status_info "Current directory: $(pwd)"
|
||||||
|
status_info "Files found: $(ls -la)"
|
||||||
|
echo
|
||||||
|
printf "${RED}${BOLD}${BG_YELLOW} DEPLOYMENT TERMINATED ${RESET}\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
status_ok "Project structure validated"
|
||||||
|
|
||||||
|
# Security scan animation
|
||||||
|
printf "${CYAN}${BOLD}🔍 Running security scan${RESET}"
|
||||||
|
for i in {1..20}; do
|
||||||
|
printf "${CYAN}.${RESET}"
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
status_ok "Security scan completed - all clear"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🔧 PHASE 1: NPM BUILD SYSTEM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
find_and_use_npm() {
|
||||||
|
section_header "1" "BUILD SYSTEM INITIALIZATION" "🔧"
|
||||||
|
|
||||||
|
printf "${CYAN}${BOLD}🔍 Scanning for npm installation...${RESET}\n"
|
||||||
|
wave_animation 30 1
|
||||||
|
|
||||||
|
# A) system-wide npm
|
||||||
|
if command -v npm &>/dev/null; then
|
||||||
|
status_ok "System npm located: $(which npm)"
|
||||||
|
|
||||||
|
printf "${MAGENTA}${BOLD}📦 Installing dependencies${RESET}"
|
||||||
|
{
|
||||||
|
sudo -u "$ORIGINAL_USER" npm install > /tmp/npm_install.log 2>&1 &
|
||||||
|
spinner $! "Installing dependencies"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf "${MAGENTA}${BOLD}🏗️ Building application${RESET}"
|
||||||
|
{
|
||||||
|
sudo -u "$ORIGINAL_USER" npm run build > /tmp/npm_build.log 2>&1 &
|
||||||
|
spinner $! "Building application"
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# B) nvm-managed npm
|
||||||
|
printf "${YELLOW}🔍 Scanning for nvm installation...${RESET}\n"
|
||||||
|
if sudo -u "$ORIGINAL_USER" bash -c "
|
||||||
|
export NVM_DIR='$ORIGINAL_HOME/.nvm'
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && source \"\$NVM_DIR/nvm.sh\"
|
||||||
|
[ -s '$ORIGINAL_HOME/.bashrc' ] && source '$ORIGINAL_HOME/.bashrc'
|
||||||
|
command -v npm &>/dev/null
|
||||||
|
"; then
|
||||||
|
status_ok "NVM-managed npm located"
|
||||||
|
|
||||||
|
printf "${MAGENTA}${BOLD}📦 Installing dependencies with nvm${RESET}"
|
||||||
|
{
|
||||||
|
sudo -u "$ORIGINAL_USER" bash -c "
|
||||||
|
export NVM_DIR='$ORIGINAL_HOME/.nvm'
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && source \"\$NVM_DIR/nvm.sh\"
|
||||||
|
[ -s '$ORIGINAL_HOME/.bashrc' ] && source '$ORIGINAL_HOME/.bashrc'
|
||||||
|
npm install > /tmp/npm_install.log 2>&1
|
||||||
|
npm run build > /tmp/npm_build.log 2>&1
|
||||||
|
" &
|
||||||
|
spinner $! "Building with nvm"
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# C) Installation instructions with fancy formatting
|
||||||
|
draw_box "NPM NOT FOUND" "$(cat << NPMHELP
|
||||||
|
Please install Node.js and npm first:
|
||||||
|
|
||||||
|
Option 1 (apt):
|
||||||
|
sudo apt update && sudo apt install nodejs npm
|
||||||
|
|
||||||
|
Option 2 (NodeSource – recommended):
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
Option 3 (nvm – as user):
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
source ~/.bashrc && nvm install 20
|
||||||
|
NPMHELP
|
||||||
|
)" 70 "$RED"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🏗️ PHASE 2: BUILD ORCHESTRATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "2" "BUILD ORCHESTRATION" "🏗️"
|
||||||
|
|
||||||
|
if [ ! -d "dist" ] || [ ! "$(ls -A dist 2>/dev/null)" ]; then
|
||||||
|
status_info "No dist/ directory found"
|
||||||
|
typewriter "Initiating build process..." 0.05 "$YELLOW"
|
||||||
|
find_and_use_npm || exit 1
|
||||||
|
else
|
||||||
|
status_ok "Existing dist/ directory detected"
|
||||||
|
echo
|
||||||
|
printf "${YELLOW}${BOLD}🤔 Rebuild application? ${RESET}${DIM}(y/N):${RESET} "
|
||||||
|
read -r REPLY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
typewriter "Rebuilding application..." 0.05 "$CYAN"
|
||||||
|
find_and_use_npm || {
|
||||||
|
status_warning "Build failed, using existing dist/"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
typewriter "Using existing build..." 0.05 "$GREEN"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build validation with dramatic effect
|
||||||
|
printf "${CYAN}${BOLD}🔍 Validating build output${RESET}"
|
||||||
|
pulsing_dots 8 2
|
||||||
|
if [ ! -d "dist" ] || [ ! "$(ls -A dist 2>/dev/null)" ]; then
|
||||||
|
echo
|
||||||
|
draw_box "BUILD FAILURE" "Build failed or dist/ directory is empty" 50 "$RED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build success celebration
|
||||||
|
echo
|
||||||
|
printf "${GREEN}${BOLD}${BG_GREEN}${WHITE} BUILD SUCCESS ${RESET}\n"
|
||||||
|
celebrate
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 📁 PHASE 3: INFRASTRUCTURE SETUP
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "3" "INFRASTRUCTURE PROVISIONING" "📁"
|
||||||
|
|
||||||
|
status_working "Creating directory structure"
|
||||||
|
{
|
||||||
|
mkdir -p "$WEBROOT" "$LOG_DIR" "$DATA_DIR" "$UPLOADS_DIR" "$WEBROOT/src/data" &
|
||||||
|
spinner $! "Provisioning directories"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Directory creation progress
|
||||||
|
DIRS=("$WEBROOT" "$LOG_DIR" "$DATA_DIR" "$UPLOADS_DIR" "$WEBROOT/src/data")
|
||||||
|
for i in "${!DIRS[@]}"; do
|
||||||
|
progress_bar $((i+1)) ${#DIRS[@]} 40 "Creating directories"
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
status_ok "Directory infrastructure ready"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🚀 PHASE 4: APPLICATION DEPLOYMENT
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "4" "APPLICATION DEPLOYMENT" "🚀"
|
||||||
|
|
||||||
|
# File copy with visual progress
|
||||||
|
status_working "Deploying application files"
|
||||||
|
{
|
||||||
|
cp -r dist/. "$WEBROOT/" &
|
||||||
|
PID=$!
|
||||||
|
|
||||||
|
# Simple animated progress while copying
|
||||||
|
frame=0
|
||||||
|
while kill -0 $PID 2>/dev/null; do
|
||||||
|
printf "\r${MAGENTA}%s${RESET} Copying files..." "${SPINNER_FRAMES[$frame]}"
|
||||||
|
frame=$(((frame + 1) % ${#SPINNER_FRAMES[@]}))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
wait $PID
|
||||||
|
printf "\r${GREEN}✓${RESET} Copying files... \n"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
SIZE=$(du -sh dist | cut -f1)
|
||||||
|
TOTAL_FILES=$(find dist -type f | wc -l)
|
||||||
|
status_ok "Application deployed ($SIZE, $TOTAL_FILES files)"
|
||||||
|
|
||||||
|
# Package.json copy with flair
|
||||||
|
printf "${MAGENTA}${BOLD}📋 Deploying package.json${RESET}"
|
||||||
|
pulsing_dots 3 1
|
||||||
|
cp package.json "$WEBROOT/"
|
||||||
|
status_ok "Package configuration deployed"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ⚙️ PHASE 5: RUNTIME DEPENDENCY MANAGEMENT
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "5" "RUNTIME DEPENDENCY RESOLUTION" "⚙️"
|
||||||
|
|
||||||
|
typewriter "Transferring ownership for dependency installation..." 0.03 "$YELLOW"
|
||||||
|
chown -R "$ORIGINAL_USER":"$ORIGINAL_USER" "$WEBROOT"
|
||||||
|
|
||||||
|
printf "${CYAN}${BOLD}📦 Installing runtime dependencies${RESET}\n"
|
||||||
|
{
|
||||||
|
sudo -u "$ORIGINAL_USER" bash -c '
|
||||||
|
set -e
|
||||||
|
cd "'"$WEBROOT"'"
|
||||||
|
if command -v npm &>/dev/null; then
|
||||||
|
npm install --production > /tmp/runtime_deps.log 2>&1
|
||||||
|
else
|
||||||
|
export NVM_DIR="'$ORIGINAL_HOME'/.nvm"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
|
||||||
|
[ -s "'$ORIGINAL_HOME'/.bashrc" ] && source "'$ORIGINAL_HOME'/.bashrc"
|
||||||
|
npm install --production > /tmp/runtime_deps.log 2>&1
|
||||||
|
fi
|
||||||
|
' &
|
||||||
|
spinner $! "Installing runtime dependencies"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dependency success effect
|
||||||
|
printf "${GREEN}${BOLD}🎯 Dependencies locked and loaded!${RESET}\n"
|
||||||
|
wave_animation 40 1
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🗃️ PHASE 6: DATA & CONTENT ORCHESTRATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "6" "DATA & CONTENT ORCHESTRATION" "🗃️"
|
||||||
|
|
||||||
|
status_working "Deploying core data structures"
|
||||||
|
if [ -f "src/data/tools.yaml" ]; then
|
||||||
|
cp src/data/tools.yaml "$WEBROOT/src/data/"
|
||||||
|
TOOL_COUNT=$(grep -c "^ - name:" "src/data/tools.yaml" || echo "unknown")
|
||||||
|
status_ok "Tools database deployed ($TOOL_COUNT tools)"
|
||||||
|
else
|
||||||
|
status_error "Critical file missing: src/data/tools.yaml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_working "Deploying knowledge base"
|
||||||
|
if [ -d "src/content/knowledgebase" ]; then
|
||||||
|
mkdir -p "$WEBROOT/src/content"
|
||||||
|
cp -r src/content/knowledgebase "$WEBROOT/src/content/"
|
||||||
|
KB_COUNT=$(find src/content/knowledgebase -name "*.md" 2>/dev/null | wc -l)
|
||||||
|
status_ok "Knowledge base deployed ($KB_COUNT articles)"
|
||||||
|
|
||||||
|
# Knowledge base visualization
|
||||||
|
printf "${BLUE}${BOLD}📚 Knowledge Base Structure:${RESET}\n"
|
||||||
|
find src/content/knowledgebase -name "*.md" | head -5 | while read -r file; do
|
||||||
|
printf " ${CYAN}${DIAMOND}${RESET} ${file#src/content/knowledgebase/}\n"
|
||||||
|
done
|
||||||
|
if [ $KB_COUNT -gt 5 ]; then
|
||||||
|
printf " ${DIM}... and $((KB_COUNT-5)) more articles${RESET}\n"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
status_warning "No knowledge base directory found (optional)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ⚙️ PHASE 7: ENVIRONMENT CONFIGURATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "7" "ENVIRONMENT CONFIGURATION" "⚙️"
|
||||||
|
|
||||||
|
printf "${YELLOW}${BOLD}🔧 Configuring environment${RESET}"
|
||||||
|
pulsing_dots 5 1
|
||||||
|
cp .env.example "$WEBROOT/.env"
|
||||||
|
status_ok "Environment template deployed"
|
||||||
|
|
||||||
|
draw_box "CONFIGURATION NOTICE" "IMPORTANT: Edit $WEBROOT/.env with your configuration" 60 "$YELLOW"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 📝 PHASE 8: LOGGING INFRASTRUCTURE
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "8" "LOGGING INFRASTRUCTURE" "📝"
|
||||||
|
|
||||||
|
LOG_FILES=("access.log" "error.log" "ai-pipeline.log")
|
||||||
|
for i in "${!LOG_FILES[@]}"; do
|
||||||
|
progress_bar $((i+1)) ${#LOG_FILES[@]} 30 "Creating log files"
|
||||||
|
touch "$LOG_DIR/${LOG_FILES[$i]}"
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
status_ok "Logging infrastructure established"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🔐 PHASE 9: PERMISSION MATRIX
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "9" "PERMISSION MATRIX CONFIGURATION" "🔐"
|
||||||
|
|
||||||
|
typewriter "Implementing security hardening..." 0.04 "$RED"
|
||||||
|
|
||||||
|
# Permission operations with progress
|
||||||
|
PERM_OPERATIONS=(
|
||||||
|
"chown -R www-data:www-data $WEBROOT"
|
||||||
|
"chmod -R 755 $WEBROOT"
|
||||||
|
"chmod 600 $WEBROOT/.env"
|
||||||
|
"chmod 755 $DATA_DIR $UPLOADS_DIR $LOG_DIR"
|
||||||
|
"chmod 644 $LOG_DIR/*.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in "${!PERM_OPERATIONS[@]}"; do
|
||||||
|
progress_bar $((i+1)) ${#PERM_OPERATIONS[@]} 40 "Setting permissions"
|
||||||
|
eval "${PERM_OPERATIONS[$i]}"
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ -f "$WEBROOT/server/entry.mjs" ]; then
|
||||||
|
chmod 755 "$WEBROOT/server/entry.mjs"
|
||||||
|
status_ok "Server entry point permissions configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_ok "Permission matrix locked down"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ✅ PHASE 10: DEPLOYMENT VALIDATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
section_header "10" "DEPLOYMENT VALIDATION MATRIX" "✅"
|
||||||
|
|
||||||
|
VALIDATION_ERRORS=0
|
||||||
|
VALIDATIONS=(
|
||||||
|
"$WEBROOT/.env|Environment configuration"
|
||||||
|
"$WEBROOT/src/data/tools.yaml|Tools database"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check application files (either index.html OR server directory)
|
||||||
|
if [ -f "$WEBROOT/index.html" ] || [ -d "$WEBROOT/server" ]; then
|
||||||
|
VALIDATIONS+=("APPLICATION_FILES_OK|Application files")
|
||||||
|
else
|
||||||
|
VALIDATIONS+=("APPLICATION_FILES_MISSING|Application files")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf "${CYAN}${BOLD}🔍 Running comprehensive validation suite...${RESET}\n"
|
||||||
|
|
||||||
|
for validation in "${VALIDATIONS[@]}"; do
|
||||||
|
IFS='|' read -r file desc <<< "$validation"
|
||||||
|
|
||||||
|
printf "${YELLOW}Testing: %s${RESET}" "$desc"
|
||||||
|
pulsing_dots 3 1
|
||||||
|
|
||||||
|
if [[ "$file" == "APPLICATION_FILES_OK" ]]; then
|
||||||
|
status_ok "$desc validated"
|
||||||
|
elif [[ "$file" == "APPLICATION_FILES_MISSING" ]]; then
|
||||||
|
status_error "$desc missing"
|
||||||
|
((VALIDATION_ERRORS++))
|
||||||
|
elif [ -f "$file" ] || [ -d "$file" ]; then
|
||||||
|
status_ok "$desc validated"
|
||||||
|
else
|
||||||
|
status_error "$desc missing"
|
||||||
|
((VALIDATION_ERRORS++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 🎊 FINAL RESULTS SPECTACULAR
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ $VALIDATION_ERRORS -eq 0 ]; then
|
||||||
|
# Success celebration sequence
|
||||||
|
printf "${GREEN}${BOLD}${BG_GREEN}${WHITE}"
|
||||||
|
for ((i=0; i<COLS; i++)); do printf "="; done
|
||||||
|
printf "${RESET}\n"
|
||||||
|
|
||||||
|
# Animated success banner
|
||||||
|
fancy_header "🎉 DEPLOYMENT SUCCESSFUL! 🎉" "All systems operational"
|
||||||
|
|
||||||
|
# Fireworks celebration
|
||||||
|
celebrate
|
||||||
|
|
||||||
|
# Next steps in a beautiful box
|
||||||
|
draw_box "🎯 MISSION BRIEFING - NEXT STEPS" "$(cat << STEPS
|
||||||
|
1. 🔧 Configure environment variables in $WEBROOT/.env
|
||||||
|
• Set PUBLIC_BASE_URL, AI service endpoints
|
||||||
|
• Configure AUTH_SECRET and database connections
|
||||||
|
|
||||||
|
2. 🔄 Restart system services:
|
||||||
|
sudo systemctl restart forensic-pathways
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
3. 🔍 Monitor system health:
|
||||||
|
sudo systemctl status forensic-pathways
|
||||||
|
sudo tail -f $LOG_DIR/error.log
|
||||||
|
|
||||||
|
🌐 Application fortress established at: $WEBROOT
|
||||||
|
🎯 Ready for production deployment!
|
||||||
|
STEPS
|
||||||
|
)" 70 "$GREEN"
|
||||||
|
|
||||||
|
# Final celebration
|
||||||
|
echo
|
||||||
|
printf "${BOLD}"
|
||||||
|
print_gradient_text "🚀 FORENSIC PATHWAYS DEPLOYMENT COMPLETE 🚀"
|
||||||
|
echo
|
||||||
|
|
||||||
|
else
|
||||||
|
# Error summary
|
||||||
|
draw_box "⚠️ DEPLOYMENT COMPLETED WITH WARNINGS" "$(cat << WARNINGS
|
||||||
|
Found $VALIDATION_ERRORS validation issues
|
||||||
|
Please review and resolve before proceeding
|
||||||
|
WARNINGS
|
||||||
|
)" 60 "$YELLOW"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final timestamp with style
|
||||||
|
echo
|
||||||
|
printf "${DIM}${ITALIC}Deployment completed at: "
|
||||||
|
printf "${BOLD}$(date '+%Y-%m-%d %H:%M:%S')${RESET}\n"
|
||||||
|
|
||||||
|
# Restore cursor
|
||||||
|
tput cnorm
|
||||||
|
|
||||||
|
# Final matrix effect fade-out
|
||||||
|
printf "${DIM}${GREEN}Deployment matrix shutting down...${RESET}\n"
|
||||||
|
matrix_rain 1
|
||||||
|
|
||||||
|
echo
|
||||||
File diff suppressed because it is too large
Load Diff
83
embedding-test-config.json
Normal file
83
embedding-test-config.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"toolsYamlPath": "./src/data/tools.yaml",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "granite-embedding:278m",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paraphrase-multilingual:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 128
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bge-large:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "snowflake-arctic-embed2:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 8192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "snowflake-arctic-embed:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "all-minilm:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bge-m3:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 8192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mxbai-embed-large:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nomic-embed-text:latest",
|
||||||
|
"type": "ollama",
|
||||||
|
"endpoint": "http://192.168.178.100:11434/api/embeddings",
|
||||||
|
"rateLimit": false,
|
||||||
|
"contextSize": 2048
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mistral-embed",
|
||||||
|
"type": "mistral",
|
||||||
|
"endpoint": "https://api.mistral.ai/v1/embeddings",
|
||||||
|
"apiKey": "${AI_EMBEDDINGS_API_KEY}",
|
||||||
|
"rateLimit": true,
|
||||||
|
"rateLimitDelayMs": 2000,
|
||||||
|
"contextSize": 8192
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"testSettings": {
|
||||||
|
"maxToolsPerCategory": 6,
|
||||||
|
"maxNegativeExamples": 4,
|
||||||
|
"contextSizeTests": true,
|
||||||
|
"performanceIterations": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
897
embeddings-comparison.js
Normal file
897
embeddings-comparison.js
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// efficient-embedding-comparison.js
|
||||||
|
// Proper embedding model evaluation with batch processing and vector search
|
||||||
|
// Run with: node efficient-embedding-comparison.js --config=config.json
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
class EmbeddingCache {
|
||||||
|
constructor(cacheDir = './embedding-cache') {
|
||||||
|
this.cacheDir = cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureCacheDir() {
|
||||||
|
try {
|
||||||
|
await fs.access(this.cacheDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheKey(model, text) {
|
||||||
|
const content = `${model.name}:${text}`;
|
||||||
|
return crypto.createHash('md5').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCachedEmbedding(model, text) {
|
||||||
|
await this.ensureCacheDir();
|
||||||
|
const key = this.getCacheKey(model, text);
|
||||||
|
const cachePath = path.join(this.cacheDir, `${key}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(cachePath, 'utf8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCachedEmbedding(model, text, embedding) {
|
||||||
|
await this.ensureCacheDir();
|
||||||
|
const key = this.getCacheKey(model, text);
|
||||||
|
const cachePath = path.join(this.cacheDir, `${key}.json`);
|
||||||
|
|
||||||
|
await fs.writeFile(cachePath, JSON.stringify(embedding));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCacheStats(model) {
|
||||||
|
await this.ensureCacheDir();
|
||||||
|
const files = await fs.readdir(this.cacheDir);
|
||||||
|
const modelFiles = files.filter(f => f.includes(model.name.replace(/[^a-zA-Z0-9]/g, '_')));
|
||||||
|
return { cached: modelFiles.length, total: files.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchEvaluator {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new EmbeddingCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async rateLimitedDelay(model) {
|
||||||
|
if (model.rateLimit && model.rateLimitDelayMs) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, model.rateLimitDelayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmbedding(text, model) {
|
||||||
|
// Check cache first
|
||||||
|
const cached = await this.cache.getCachedEmbedding(model, text);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
let body, endpoint;
|
||||||
|
|
||||||
|
if (model.type === 'mistral') {
|
||||||
|
if (model.apiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${model.apiKey.replace('${AI_EMBEDDINGS_API_KEY}', process.env.AI_EMBEDDINGS_API_KEY || '')}`;
|
||||||
|
}
|
||||||
|
body = { model: model.name, input: [text] };
|
||||||
|
endpoint = model.endpoint;
|
||||||
|
} else {
|
||||||
|
body = { model: model.name, prompt: text };
|
||||||
|
endpoint = model.endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429 && model.rateLimit) {
|
||||||
|
console.log(` ⚠️ Rate limited, waiting...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||||
|
return this.getEmbedding(text, model);
|
||||||
|
}
|
||||||
|
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const embedding = model.type === 'mistral' ? data.data[0].embedding : data.embedding;
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
await this.cache.setCachedEmbedding(model, text, embedding);
|
||||||
|
return embedding;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to get embedding: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructToolText(item, maxLength = null) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
// Even for string inputs, don't truncate to match real app behavior
|
||||||
|
return item.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXACT match to embeddings.ts createContentString() - NO TRUNCATION
|
||||||
|
const parts = [
|
||||||
|
item.name,
|
||||||
|
item.description || '',
|
||||||
|
...(item.tags || []),
|
||||||
|
...(item.domains || []),
|
||||||
|
...(item.phases || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const contentString = parts.filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
// CRITICAL: No truncation! Return full content like real app
|
||||||
|
return contentString;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateOptimalBatchSize(model) {
|
||||||
|
// Factors that ACTUALLY matter for batching individual API calls:
|
||||||
|
|
||||||
|
// 1. Rate limiting aggressiveness
|
||||||
|
if (model.rateLimit && model.rateLimitDelayMs > 2000) {
|
||||||
|
return 5; // Conservative batching for heavily rate-limited APIs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. API latency expectations
|
||||||
|
if (model.type === 'ollama') {
|
||||||
|
return 15; // Local APIs are fast, can handle larger batches
|
||||||
|
} else if (model.type === 'mistral') {
|
||||||
|
return 10; // Remote APIs might be slower, medium batches
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Progress reporting frequency preference
|
||||||
|
// For 185 tools:
|
||||||
|
// - Batch size 10 = 19 progress updates
|
||||||
|
// - Batch size 15 = 13 progress updates
|
||||||
|
// - Batch size 20 = 10 progress updates
|
||||||
|
|
||||||
|
return 15; // Good balance for ~13 progress updates
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBatchEmbeddings(items, model) {
|
||||||
|
const batchSize = this.calculateOptimalBatchSize(model);
|
||||||
|
const contextSize = model.contextSize || 2000; // Only for display/info
|
||||||
|
|
||||||
|
console.log(` 📦 Creating embeddings for ${items.length} items`);
|
||||||
|
console.log(` 📏 Model context: ${contextSize} chars (for reference - NOT truncating)`);
|
||||||
|
console.log(` 📋 Batch size: ${batchSize} (for progress reporting)`);
|
||||||
|
|
||||||
|
const embeddings = new Map();
|
||||||
|
let apiCalls = 0;
|
||||||
|
let cacheHits = 0;
|
||||||
|
const totalBatches = Math.ceil(items.length / batchSize);
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const batch = items.slice(i, i + batchSize);
|
||||||
|
const batchNum = Math.floor(i/batchSize) + 1;
|
||||||
|
|
||||||
|
console.log(` 📋 Processing batch ${batchNum}/${totalBatches} (${batch.length} tools)`);
|
||||||
|
|
||||||
|
for (const item of batch) {
|
||||||
|
// Get FULL content (no truncation)
|
||||||
|
const text = this.constructToolText(item);
|
||||||
|
|
||||||
|
// Show actual text length for first few tools (full length!)
|
||||||
|
if (i < batchSize && batch.indexOf(item) < 3) {
|
||||||
|
const truncatedDisplay = text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||||
|
console.log(` 📝 ${item.name}: ${text.length} chars (full) - "${truncatedDisplay}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const embedding = await this.getEmbedding(text, model);
|
||||||
|
embeddings.set(item.id || item.name || text, {
|
||||||
|
text,
|
||||||
|
embedding,
|
||||||
|
metadata: item
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await this.cache.getCachedEmbedding(model, text);
|
||||||
|
if (cached) cacheHits++; else apiCalls++;
|
||||||
|
|
||||||
|
await this.rateLimitedDelay(model);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(` ⚠️ Failed to embed: ${item.name || text.slice(0, 50)}...`);
|
||||||
|
// Log the error for debugging
|
||||||
|
if (text.length > 8000) {
|
||||||
|
console.warn(` 📏 Text was ${text.length} chars - may exceed model limits`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show content length statistics
|
||||||
|
const lengths = Array.from(embeddings.values()).map(e => e.text.length);
|
||||||
|
const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||||
|
const maxLength = Math.max(...lengths);
|
||||||
|
const minLength = Math.min(...lengths);
|
||||||
|
|
||||||
|
console.log(` 📊 Content stats: avg ${avgLength.toFixed(0)} chars, range ${minLength}-${maxLength} chars`);
|
||||||
|
console.log(` ✅ Created ${embeddings.size} embeddings (${apiCalls} API calls, ${cacheHits} cache hits)`);
|
||||||
|
|
||||||
|
return embeddings;
|
||||||
|
}
|
||||||
|
|
||||||
|
cosineSimilarity(a, b) {
|
||||||
|
if (!a || !b || a.length === 0 || b.length === 0) return 0;
|
||||||
|
|
||||||
|
let dotProduct = 0;
|
||||||
|
let normA = 0;
|
||||||
|
let normB = 0;
|
||||||
|
const minLength = Math.min(a.length, b.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < minLength; i++) {
|
||||||
|
dotProduct += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normA === 0 || normB === 0) return 0;
|
||||||
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchSimilar(queryEmbedding, toolEmbeddings, topK = 10) {
|
||||||
|
const similarities = [];
|
||||||
|
|
||||||
|
for (const [id, data] of toolEmbeddings) {
|
||||||
|
const similarity = this.cosineSimilarity(queryEmbedding, data.embedding);
|
||||||
|
similarities.push({
|
||||||
|
id,
|
||||||
|
similarity,
|
||||||
|
metadata: data.metadata,
|
||||||
|
text: data.text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return similarities
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateRetrievalMetrics(results, relevantIds, k = 10) {
|
||||||
|
const topK = results.slice(0, k);
|
||||||
|
const retrievedIds = new Set(topK.map(r => r.id));
|
||||||
|
const relevantSet = new Set(relevantIds);
|
||||||
|
|
||||||
|
// Precision@K
|
||||||
|
const relevantRetrieved = topK.filter(r => relevantSet.has(r.id)).length;
|
||||||
|
const precisionAtK = topK.length > 0 ? relevantRetrieved / topK.length : 0;
|
||||||
|
|
||||||
|
// Recall@K
|
||||||
|
const recallAtK = relevantIds.length > 0 ? relevantRetrieved / relevantIds.length : 0;
|
||||||
|
|
||||||
|
// F1@K
|
||||||
|
const f1AtK = (precisionAtK + recallAtK) > 0 ?
|
||||||
|
2 * (precisionAtK * recallAtK) / (precisionAtK + recallAtK) : 0;
|
||||||
|
|
||||||
|
// Mean Reciprocal Rank (MRR)
|
||||||
|
let mrr = 0;
|
||||||
|
for (let i = 0; i < topK.length; i++) {
|
||||||
|
if (relevantSet.has(topK[i].id)) {
|
||||||
|
mrr = 1 / (i + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NDCG@K (simplified binary relevance)
|
||||||
|
let dcg = 0;
|
||||||
|
let idcg = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
const rank = i + 1;
|
||||||
|
const discount = Math.log2(rank + 1);
|
||||||
|
|
||||||
|
// DCG
|
||||||
|
if (i < topK.length && relevantSet.has(topK[i].id)) {
|
||||||
|
dcg += 1 / discount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDCG (ideal ranking)
|
||||||
|
if (i < relevantIds.length) {
|
||||||
|
idcg += 1 / discount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ndcgAtK = idcg > 0 ? dcg / idcg : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
precisionAtK,
|
||||||
|
recallAtK,
|
||||||
|
f1AtK,
|
||||||
|
mrr,
|
||||||
|
ndcgAtK,
|
||||||
|
relevantRetrieved,
|
||||||
|
totalRelevant: relevantIds.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EfficientEmbeddingComparison {
|
||||||
|
constructor(configPath = './embedding-test-config.json') {
|
||||||
|
this.configPath = configPath;
|
||||||
|
this.config = null;
|
||||||
|
this.tools = [];
|
||||||
|
this.evaluator = new SearchEvaluator();
|
||||||
|
|
||||||
|
// Test queries tailored to the actual tools.yaml content
|
||||||
|
this.testQueries = [
|
||||||
|
{
|
||||||
|
query: "memory forensics RAM analysis",
|
||||||
|
keywords: ["memory", "forensics", "volatility", "ram", "dump", "analysis"],
|
||||||
|
category: "memory_analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "network packet capture traffic analysis",
|
||||||
|
keywords: ["network", "packet", "pcap", "wireshark", "traffic", "capture"],
|
||||||
|
category: "network_analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "malware reverse engineering binary analysis",
|
||||||
|
keywords: ["malware", "reverse", "engineering", "ghidra", "binary", "disassemble"],
|
||||||
|
category: "malware_analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "digital forensics disk imaging",
|
||||||
|
keywords: ["forensics", "disk", "imaging", "autopsy", "investigation", "evidence"],
|
||||||
|
category: "disk_forensics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "incident response threat hunting",
|
||||||
|
keywords: ["incident", "response", "threat", "hunting", "investigation", "compromise"],
|
||||||
|
category: "incident_response"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "mobile device smartphone forensics",
|
||||||
|
keywords: ["mobile", "smartphone", "android", "ios", "device", "cellebrite"],
|
||||||
|
category: "mobile_forensics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "timeline analysis event correlation",
|
||||||
|
keywords: ["timeline", "analysis", "correlation", "events", "plaso", "timesketch"],
|
||||||
|
category: "timeline_analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "registry analysis windows artifacts",
|
||||||
|
keywords: ["registry", "windows", "artifacts", "regripper", "hives", "keys"],
|
||||||
|
category: "registry_analysis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "cloud forensics container analysis",
|
||||||
|
keywords: ["cloud", "container", "docker", "virtualization", "aws", "azure"],
|
||||||
|
category: "cloud_forensics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "blockchain cryptocurrency investigation",
|
||||||
|
keywords: ["blockchain", "cryptocurrency", "bitcoin", "chainalysis", "transaction"],
|
||||||
|
category: "blockchain_analysis"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('[INIT] Efficient embedding comparison initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConfig() {
|
||||||
|
try {
|
||||||
|
const configData = await fs.readFile(this.configPath, 'utf8');
|
||||||
|
this.config = JSON.parse(configData);
|
||||||
|
console.log(`[CONFIG] Loaded ${this.config.models.length} models`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CONFIG] Failed to load configuration:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTools() {
|
||||||
|
try {
|
||||||
|
const yamlContent = await fs.readFile(this.config.toolsYamlPath, 'utf8');
|
||||||
|
const data = yaml.load(yamlContent);
|
||||||
|
|
||||||
|
// Extract tools (flexible - handle different YAML structures)
|
||||||
|
this.tools = data.tools || data.entries || data.applications || data;
|
||||||
|
if (!Array.isArray(this.tools)) {
|
||||||
|
this.tools = Object.values(this.tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out concepts and ensure required fields
|
||||||
|
this.tools = this.tools.filter(tool =>
|
||||||
|
tool &&
|
||||||
|
tool.type !== 'concept' &&
|
||||||
|
(tool.name || tool.title) &&
|
||||||
|
(tool.description || tool.summary)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normalize tool structure
|
||||||
|
this.tools = this.tools.map((tool, index) => ({
|
||||||
|
id: tool.id || tool.name || tool.title || `tool_${index}`,
|
||||||
|
name: tool.name || tool.title,
|
||||||
|
description: tool.description || tool.summary || '',
|
||||||
|
tags: tool.tags || [],
|
||||||
|
domains: tool.domains || tool.categories || [],
|
||||||
|
phases: tool.phases || [],
|
||||||
|
platforms: tool.platforms || [],
|
||||||
|
type: tool.type || 'tool',
|
||||||
|
skillLevel: tool.skillLevel,
|
||||||
|
license: tool.license
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`[DATA] Loaded ${this.tools.length} tools from ${this.config.toolsYamlPath}`);
|
||||||
|
|
||||||
|
// Show some statistics
|
||||||
|
const domainCounts = {};
|
||||||
|
const tagCounts = {};
|
||||||
|
|
||||||
|
this.tools.forEach(tool => {
|
||||||
|
(tool.domains || []).forEach(domain => {
|
||||||
|
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
|
||||||
|
});
|
||||||
|
(tool.tags || []).forEach(tag => {
|
||||||
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const topDomains = Object.entries(domainCounts)
|
||||||
|
.sort(([,a], [,b]) => b - a)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([domain, count]) => `${domain}(${count})`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
console.log(`[DATA] Top domains: ${topDomains}`);
|
||||||
|
console.log(`[DATA] Sample tools: ${this.tools.slice(0, 3).map(t => t.name).join(', ')}`);
|
||||||
|
|
||||||
|
if (this.tools.length === 0) {
|
||||||
|
throw new Error('No valid tools found in YAML file');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DATA] Failed to load tools:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findRelevantTools(query) {
|
||||||
|
const queryLower = query.query.toLowerCase();
|
||||||
|
const keywords = query.keywords.map(k => k.toLowerCase());
|
||||||
|
|
||||||
|
const relevantTools = this.tools.filter(tool => {
|
||||||
|
// Build searchable text from all tool metadata
|
||||||
|
const searchableFields = [
|
||||||
|
tool.name || '',
|
||||||
|
tool.description || '',
|
||||||
|
(tool.tags || []).join(' '),
|
||||||
|
(tool.domains || []).join(' '),
|
||||||
|
(tool.phases || []).join(' '),
|
||||||
|
(tool.platforms || []).join(' ')
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolText = searchableFields.join(' ').toLowerCase();
|
||||||
|
|
||||||
|
// Check for keyword matches
|
||||||
|
const hasKeywordMatch = keywords.some(keyword => toolText.includes(keyword));
|
||||||
|
|
||||||
|
// Check for query word matches (words longer than 3 chars)
|
||||||
|
const queryWords = queryLower.split(' ').filter(word => word.length > 3);
|
||||||
|
const hasQueryWordMatch = queryWords.some(word => toolText.includes(word));
|
||||||
|
|
||||||
|
// Check for domain-specific matches
|
||||||
|
const isDomainRelevant = query.category && tool.domains &&
|
||||||
|
tool.domains.some(domain => domain.includes(query.category.replace('_', '-')));
|
||||||
|
|
||||||
|
return hasKeywordMatch || hasQueryWordMatch || isDomainRelevant;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 🎯 Found ${relevantTools.length} relevant tools for "${query.query}"`);
|
||||||
|
|
||||||
|
// Log some examples for debugging
|
||||||
|
if (relevantTools.length > 0) {
|
||||||
|
console.log(` 📋 Examples: ${relevantTools.slice(0, 3).map(t => t.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relevantTools.map(tool => tool.id || tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async testSearchPerformance(model) {
|
||||||
|
console.log(` 🔍 Testing search performance...`);
|
||||||
|
|
||||||
|
// Create embeddings for all tools
|
||||||
|
const toolEmbeddings = await this.evaluator.createBatchEmbeddings(this.tools, model);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let totalApiCalls = 0;
|
||||||
|
|
||||||
|
for (const testQuery of this.testQueries) {
|
||||||
|
console.log(` 📋 Query: "${testQuery.query}"`);
|
||||||
|
|
||||||
|
// Get query embedding
|
||||||
|
const queryEmbedding = await this.evaluator.getEmbedding(testQuery.query, model);
|
||||||
|
totalApiCalls++;
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
|
||||||
|
// Find relevant tools for this query
|
||||||
|
const relevantIds = this.findRelevantTools(testQuery);
|
||||||
|
console.log(` 📊 Found ${relevantIds.length} relevant tools`);
|
||||||
|
|
||||||
|
if (relevantIds.length === 0) {
|
||||||
|
console.log(` ⚠️ No relevant tools found, skipping metrics calculation`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform search
|
||||||
|
const searchResults = this.evaluator.searchSimilar(queryEmbedding, toolEmbeddings, 20);
|
||||||
|
|
||||||
|
// Calculate metrics for different k values
|
||||||
|
const metrics = {};
|
||||||
|
for (const k of [1, 3, 5, 10]) {
|
||||||
|
metrics[`k${k}`] = this.evaluator.calculateRetrievalMetrics(searchResults, relevantIds, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
query: testQuery.query,
|
||||||
|
category: testQuery.category,
|
||||||
|
relevantCount: relevantIds.length,
|
||||||
|
searchResults: searchResults.slice(0, 5), // Top 5 for display
|
||||||
|
metrics
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
console.log(` 🎯 Top results:`);
|
||||||
|
searchResults.slice(0, 3).forEach((result, i) => {
|
||||||
|
const isRelevant = relevantIds.includes(result.id) ? '✓' : '✗';
|
||||||
|
console.log(` ${i+1}. ${isRelevant} ${result.metadata.name} (${(result.similarity*100).toFixed(1)}%)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 📈 P@5: ${(metrics.k5.precisionAtK*100).toFixed(1)}% | R@5: ${(metrics.k5.recallAtK*100).toFixed(1)}% | NDCG@5: ${(metrics.k5.ndcgAtK*100).toFixed(1)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, totalApiCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async testSemanticUnderstanding(model) {
|
||||||
|
console.log(` 🧠 Testing semantic understanding...`);
|
||||||
|
|
||||||
|
const semanticTests = [
|
||||||
|
{
|
||||||
|
primary: "memory forensics",
|
||||||
|
synonyms: ["RAM analysis", "volatile memory examination", "memory dump investigation"],
|
||||||
|
unrelated: ["file compression", "web browser", "text editor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
primary: "network analysis",
|
||||||
|
synonyms: ["packet inspection", "traffic monitoring", "protocol analysis"],
|
||||||
|
unrelated: ["image editing", "music player", "calculator"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
primary: "malware detection",
|
||||||
|
synonyms: ["virus scanning", "threat identification", "malicious code analysis"],
|
||||||
|
unrelated: ["video converter", "password manager", "calendar app"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let totalCorrect = 0;
|
||||||
|
let totalTests = 0;
|
||||||
|
let apiCalls = 0;
|
||||||
|
|
||||||
|
for (const test of semanticTests) {
|
||||||
|
console.log(` 🔤 Testing: "${test.primary}"`);
|
||||||
|
|
||||||
|
const primaryEmbedding = await this.evaluator.getEmbedding(test.primary, model);
|
||||||
|
apiCalls++;
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
|
||||||
|
// Test synonyms (should be similar)
|
||||||
|
for (const synonym of test.synonyms) {
|
||||||
|
const synonymEmbedding = await this.evaluator.getEmbedding(synonym, model);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
const synonymSim = this.evaluator.cosineSimilarity(primaryEmbedding, synonymEmbedding);
|
||||||
|
console.log(` ✓ "${synonym}": ${(synonymSim*100).toFixed(1)}%`);
|
||||||
|
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test unrelated terms (should be dissimilar)
|
||||||
|
for (const unrelated of test.unrelated) {
|
||||||
|
const unrelatedEmbedding = await this.evaluator.getEmbedding(unrelated, model);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
const unrelatedSim = this.evaluator.cosineSimilarity(primaryEmbedding, unrelatedEmbedding);
|
||||||
|
console.log(` ✗ "${unrelated}": ${(unrelatedSim*100).toFixed(1)}%`);
|
||||||
|
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate semantic coherence
|
||||||
|
const avgSynonymSim = await this.calculateAvgSimilarity(primaryEmbedding, test.synonyms, model);
|
||||||
|
const avgUnrelatedSim = await this.calculateAvgSimilarity(primaryEmbedding, test.unrelated, model);
|
||||||
|
|
||||||
|
const isCorrect = avgSynonymSim > avgUnrelatedSim;
|
||||||
|
if (isCorrect) totalCorrect++;
|
||||||
|
totalTests++;
|
||||||
|
|
||||||
|
console.log(` 📊 Synonyms: ${(avgSynonymSim*100).toFixed(1)}% | Unrelated: ${(avgUnrelatedSim*100).toFixed(1)}% ${isCorrect ? '✓' : '✗'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accuracy: totalCorrect / totalTests,
|
||||||
|
correctTests: totalCorrect,
|
||||||
|
totalTests,
|
||||||
|
apiCalls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateAvgSimilarity(baseEmbedding, terms, model) {
|
||||||
|
let totalSim = 0;
|
||||||
|
|
||||||
|
for (const term of terms) {
|
||||||
|
const embedding = await this.evaluator.getEmbedding(term, model);
|
||||||
|
const sim = this.evaluator.cosineSimilarity(baseEmbedding, embedding);
|
||||||
|
totalSim += sim;
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSim / terms.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async benchmarkPerformance(model) {
|
||||||
|
console.log(` ⚡ Benchmarking performance...`);
|
||||||
|
|
||||||
|
const testTexts = this.tools.slice(0, 10).map(tool => `${tool.name} ${tool.description}`.slice(0, 500));
|
||||||
|
const times = [];
|
||||||
|
let apiCalls = 0;
|
||||||
|
|
||||||
|
console.log(` 🏃 Processing ${testTexts.length} texts...`);
|
||||||
|
|
||||||
|
for (const text of testTexts) {
|
||||||
|
const start = Date.now();
|
||||||
|
await this.evaluator.getEmbedding(text, model);
|
||||||
|
const time = Date.now() - start;
|
||||||
|
times.push(time);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
await this.evaluator.rateLimitedDelay(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||||
|
const minTime = Math.min(...times);
|
||||||
|
const maxTime = Math.max(...times);
|
||||||
|
|
||||||
|
console.log(` 📊 Avg: ${avgTime.toFixed(0)}ms | Min: ${minTime}ms | Max: ${maxTime}ms`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgLatency: avgTime,
|
||||||
|
minLatency: minTime,
|
||||||
|
maxLatency: maxTime,
|
||||||
|
throughput: 1000 / avgTime, // requests per second
|
||||||
|
apiCalls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async testModel(model) {
|
||||||
|
console.log(`\n🧪 Testing ${model.name} (${model.type})...`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let totalApiCalls = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Search Performance Testing
|
||||||
|
const searchResults = await this.testSearchPerformance(model);
|
||||||
|
totalApiCalls += searchResults.totalApiCalls;
|
||||||
|
|
||||||
|
// 2. Semantic Understanding Testing
|
||||||
|
const semanticResults = await this.testSemanticUnderstanding(model);
|
||||||
|
totalApiCalls += semanticResults.apiCalls;
|
||||||
|
|
||||||
|
// 3. Performance Benchmarking
|
||||||
|
const perfResults = await this.benchmarkPerformance(model);
|
||||||
|
totalApiCalls += perfResults.apiCalls;
|
||||||
|
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
console.log(` ✅ ${model.name} completed in ${(totalTime/1000).toFixed(1)}s (${totalApiCalls} API calls)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchPerformance: searchResults.results,
|
||||||
|
semanticUnderstanding: semanticResults,
|
||||||
|
performance: perfResults,
|
||||||
|
totalTime,
|
||||||
|
totalApiCalls
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ ${model.name} failed:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateOverallScore(results) {
|
||||||
|
// Calculate average metrics across all queries
|
||||||
|
const searchMetrics = results.searchPerformance.filter(r => r.metrics && Object.keys(r.metrics).length > 0);
|
||||||
|
|
||||||
|
if (searchMetrics.length === 0) {
|
||||||
|
console.warn('⚠️ No search metrics available for scoring - may indicate relevance matching issues');
|
||||||
|
return {
|
||||||
|
overall: 0,
|
||||||
|
components: {
|
||||||
|
precision5: 0,
|
||||||
|
recall5: 0,
|
||||||
|
ndcg5: 0,
|
||||||
|
mrr: 0,
|
||||||
|
semanticAccuracy: results.semanticUnderstanding?.accuracy || 0,
|
||||||
|
throughput: results.performance?.throughput || 0
|
||||||
|
},
|
||||||
|
warning: 'No search metrics available'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Calculating score from ${searchMetrics.length} valid search results`);
|
||||||
|
|
||||||
|
const avgPrecision5 = searchMetrics.reduce((sum, r) => sum + (r.metrics.k5?.precisionAtK || 0), 0) / searchMetrics.length;
|
||||||
|
const avgRecall5 = searchMetrics.reduce((sum, r) => sum + (r.metrics.k5?.recallAtK || 0), 0) / searchMetrics.length;
|
||||||
|
const avgNDCG5 = searchMetrics.reduce((sum, r) => sum + (r.metrics.k5?.ndcgAtK || 0), 0) / searchMetrics.length;
|
||||||
|
const avgMRR = searchMetrics.reduce((sum, r) => sum + (r.metrics.k5?.mrr || 0), 0) / searchMetrics.length;
|
||||||
|
|
||||||
|
const semanticAccuracy = results.semanticUnderstanding?.accuracy || 0;
|
||||||
|
const throughput = results.performance?.throughput || 0;
|
||||||
|
|
||||||
|
// Weighted overall score
|
||||||
|
const weights = {
|
||||||
|
precision: 0.25,
|
||||||
|
recall: 0.25,
|
||||||
|
ndcg: 0.20,
|
||||||
|
semantic: 0.20,
|
||||||
|
speed: 0.10
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedThroughput = Math.min(throughput / 10, 1); // Normalize to 0-1 (10 req/s = 1.0)
|
||||||
|
|
||||||
|
const overall = (
|
||||||
|
avgPrecision5 * weights.precision +
|
||||||
|
avgRecall5 * weights.recall +
|
||||||
|
avgNDCG5 * weights.ndcg +
|
||||||
|
semanticAccuracy * weights.semantic +
|
||||||
|
normalizedThroughput * weights.speed
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overall,
|
||||||
|
components: {
|
||||||
|
precision5: avgPrecision5,
|
||||||
|
recall5: avgRecall5,
|
||||||
|
ndcg5: avgNDCG5,
|
||||||
|
mrr: avgMRR,
|
||||||
|
semanticAccuracy,
|
||||||
|
throughput
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
printResults(modelResults) {
|
||||||
|
console.log(`\n${'='.repeat(80)}`);
|
||||||
|
console.log("🏆 EFFICIENT EMBEDDING MODEL COMPARISON RESULTS");
|
||||||
|
console.log(`${'='.repeat(80)}`);
|
||||||
|
|
||||||
|
const scores = modelResults.map(mr => ({
|
||||||
|
model: mr.model,
|
||||||
|
score: this.calculateOverallScore(mr.results),
|
||||||
|
results: mr.results
|
||||||
|
})).sort((a, b) => b.score.overall - a.score.overall);
|
||||||
|
|
||||||
|
console.log(`\n🥇 OVERALL RANKINGS:`);
|
||||||
|
scores.forEach((score, index) => {
|
||||||
|
console.log(` ${index + 1}. ${score.model.name}: ${(score.score.overall * 100).toFixed(1)}% overall`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n📊 DETAILED METRICS:`);
|
||||||
|
|
||||||
|
console.log(`\n 🎯 Search Performance (Precision@5):`);
|
||||||
|
scores.forEach(score => {
|
||||||
|
console.log(` ${score.model.name}: ${(score.score.components.precision5 * 100).toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n 🔍 Search Performance (Recall@5):`);
|
||||||
|
scores.forEach(score => {
|
||||||
|
console.log(` ${score.model.name}: ${(score.score.components.recall5 * 100).toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n 📈 Search Quality (NDCG@5):`);
|
||||||
|
scores.forEach(score => {
|
||||||
|
console.log(` ${score.model.name}: ${(score.score.components.ndcg5 * 100).toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n 🧠 Semantic Understanding:`);
|
||||||
|
scores.forEach(score => {
|
||||||
|
console.log(` ${score.model.name}: ${(score.score.components.semanticAccuracy * 100).toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n ⚡ Performance (req/s):`);
|
||||||
|
scores.forEach(score => {
|
||||||
|
console.log(` ${score.model.name}: ${score.score.components.throughput.toFixed(1)} req/s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Winner analysis
|
||||||
|
const winner = scores[0];
|
||||||
|
console.log(`\n🏆 WINNER: ${winner.model.name}`);
|
||||||
|
console.log(` Overall Score: ${(winner.score.overall * 100).toFixed(1)}%`);
|
||||||
|
console.log(` Best for: ${this.getBestUseCase(winner.score.components)}`);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const totalQueries = modelResults[0]?.results.searchPerformance.length || 0;
|
||||||
|
const totalTools = this.tools.length;
|
||||||
|
|
||||||
|
console.log(`\n📋 Test Summary:`);
|
||||||
|
console.log(` Tools tested: ${totalTools}`);
|
||||||
|
console.log(` Search queries: ${totalQueries}`);
|
||||||
|
console.log(` Models compared: ${scores.length}`);
|
||||||
|
console.log(` Total API calls: ${modelResults.reduce((sum, mr) => sum + mr.results.totalApiCalls, 0)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBestUseCase(components) {
|
||||||
|
const strengths = [];
|
||||||
|
if (components.precision5 > 0.7) strengths.push("High precision");
|
||||||
|
if (components.recall5 > 0.7) strengths.push("High recall");
|
||||||
|
if (components.semanticAccuracy > 0.8) strengths.push("Semantic understanding");
|
||||||
|
if (components.throughput > 5) strengths.push("High performance");
|
||||||
|
|
||||||
|
return strengths.length > 0 ? strengths.join(", ") : "General purpose";
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
try {
|
||||||
|
console.log("🚀 EFFICIENT EMBEDDING MODEL COMPARISON");
|
||||||
|
console.log("=====================================");
|
||||||
|
|
||||||
|
await this.loadConfig();
|
||||||
|
await this.loadTools();
|
||||||
|
|
||||||
|
console.log(`\n📋 Test Overview:`);
|
||||||
|
console.log(` Models: ${this.config.models.length}`);
|
||||||
|
console.log(` Tools: ${this.tools.length}`);
|
||||||
|
console.log(` Search queries: ${this.testQueries.length}`);
|
||||||
|
console.log(` Cache: ${this.evaluator.cache.cacheDir}`);
|
||||||
|
|
||||||
|
const modelResults = [];
|
||||||
|
|
||||||
|
for (const model of this.config.models) {
|
||||||
|
try {
|
||||||
|
const results = await this.testModel(model);
|
||||||
|
modelResults.push({ model, results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Skipping ${model.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelResults.length === 0) {
|
||||||
|
throw new Error('No models completed testing successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.printResults(modelResults);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Test failed:', error.message);
|
||||||
|
console.log('\nDebugging steps:');
|
||||||
|
console.log('1. Verify tools.yaml exists and contains valid tool data');
|
||||||
|
console.log('2. Check model endpoints are accessible');
|
||||||
|
console.log('3. For Ollama: ensure models are pulled and ollama serve is running');
|
||||||
|
console.log('4. For Mistral: verify AI_EMBEDDINGS_API_KEY environment variable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const configArg = process.argv.find(arg => arg.startsWith('--config='));
|
||||||
|
const configPath = configArg ? configArg.split('=')[1] : './embedding-test-config.json';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const comparison = new EfficientEmbeddingComparison(configPath);
|
||||||
|
await comparison.run();
|
||||||
|
})().catch(console.error);
|
||||||
333
find-duplicates.mjs
Normal file
333
find-duplicates.mjs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// find-duplicate-functions.mjs
|
||||||
|
// Usage:
|
||||||
|
// node find-duplicate-functions.mjs [rootDir] [--mode exact|struct] [--min-lines N] [--json]
|
||||||
|
// Example:
|
||||||
|
// node find-duplicate-functions.mjs . --mode struct --min-lines 3
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import * as url from "url";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** -------- CLI OPTIONS -------- */
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let rootDir = ".";
|
||||||
|
let mode = "struct"; // "exact" | "struct"
|
||||||
|
let minLines = 3;
|
||||||
|
let outputJson = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (!a.startsWith("--") && rootDir === ".") {
|
||||||
|
rootDir = a;
|
||||||
|
} else if (a === "--mode") {
|
||||||
|
mode = (args[++i] || "struct").toLowerCase();
|
||||||
|
if (!["exact", "struct"].includes(mode)) {
|
||||||
|
console.error("Invalid --mode. Use 'exact' or 'struct'.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (a === "--min-lines") {
|
||||||
|
minLines = parseInt(args[++i] || "3", 10);
|
||||||
|
} else if (a === "--json") {
|
||||||
|
outputJson = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- FILE DISCOVERY -------- */
|
||||||
|
const DEFAULT_IGNORES = new Set([
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
".next",
|
||||||
|
".vercel",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".astro", // Astro's generated cache dir
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VALID_EXTS = new Set([".ts", ".tsx", ".astro", ".mts", ".cts"]);
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const out = [];
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (DEFAULT_IGNORES.has(e.name)) continue;
|
||||||
|
out.push(...walk(p));
|
||||||
|
} else if (e.isFile() && VALID_EXTS.has(path.extname(e.name))) {
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- ASTRO CODE EXTRACTION --------
|
||||||
|
* Extract TS/JS code from:
|
||||||
|
* - frontmatter: --- ... ---
|
||||||
|
* - <script ...> ... </script>
|
||||||
|
*/
|
||||||
|
function extractCodeFromAstro(source) {
|
||||||
|
/** @type {{code:string, offset:number}[]} */
|
||||||
|
const blocks = [];
|
||||||
|
|
||||||
|
// Frontmatter (must be at top in Astro)
|
||||||
|
// Match the FIRST pair of --- ... ---
|
||||||
|
const fm = source.startsWith("---")
|
||||||
|
? (() => {
|
||||||
|
const end = source.indexOf("\n---", 3);
|
||||||
|
if (end !== -1) {
|
||||||
|
const front = source.slice(3, end + 1); // include trailing \n
|
||||||
|
return { start: 0, end: end + 4, code: front };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
if (fm) {
|
||||||
|
// offset for line numbers is after the first '---\n'
|
||||||
|
blocks.push({ code: fm.code, offset: 4 }); // rough; we’ll fix line numbers via positions later
|
||||||
|
}
|
||||||
|
|
||||||
|
// <script ...> ... </script>
|
||||||
|
const scriptRe = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = scriptRe.exec(source))) {
|
||||||
|
const code = m[1] || "";
|
||||||
|
blocks.push({ code, offset: indexToLine(source, m.index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- UTIL: index -> 1-based line -------- */
|
||||||
|
function indexToLine(text, idx) {
|
||||||
|
let line = 1;
|
||||||
|
for (let i = 0; i < idx && i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 10) line++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- AST HELPERS -------- */
|
||||||
|
function createSourceFile(virtualPath, code) {
|
||||||
|
return ts.createSourceFile(
|
||||||
|
virtualPath,
|
||||||
|
code,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
/*setParentNodes*/ true,
|
||||||
|
virtualPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize AST to a structural signature string
|
||||||
|
function structuralSignature(node) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const parts = [];
|
||||||
|
const visit = (n) => {
|
||||||
|
// Skip trivia: comments/whitespace are already not in AST
|
||||||
|
const kindName = ts.SyntaxKind[n.kind] || `K${n.kind}`;
|
||||||
|
switch (n.kind) {
|
||||||
|
case ts.SyntaxKind.Identifier:
|
||||||
|
parts.push("Id");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.PrivateIdentifier:
|
||||||
|
parts.push("PrivId");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.StringLiteral:
|
||||||
|
case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
|
||||||
|
case ts.SyntaxKind.TemplateHead:
|
||||||
|
case ts.SyntaxKind.TemplateMiddle:
|
||||||
|
case ts.SyntaxKind.TemplateTail:
|
||||||
|
parts.push("Str");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.NumericLiteral:
|
||||||
|
parts.push("Num");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.TrueKeyword:
|
||||||
|
case ts.SyntaxKind.FalseKeyword:
|
||||||
|
parts.push("Bool");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.NullKeyword:
|
||||||
|
case ts.SyntaxKind.UndefinedKeyword:
|
||||||
|
parts.push("Nil");
|
||||||
|
return;
|
||||||
|
case ts.SyntaxKind.PropertyAssignment:
|
||||||
|
case ts.SyntaxKind.ShorthandPropertyAssignment:
|
||||||
|
case ts.SyntaxKind.MethodDeclaration:
|
||||||
|
case ts.SyntaxKind.MethodSignature:
|
||||||
|
parts.push("Prop");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
parts.push(kindName);
|
||||||
|
}
|
||||||
|
n.forEachChild(visit);
|
||||||
|
};
|
||||||
|
visit(node);
|
||||||
|
return parts.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFunctionInfo(sf, filePath) {
|
||||||
|
/** @type {Array<{
|
||||||
|
name: string,
|
||||||
|
bodyText: string,
|
||||||
|
structKey: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
startLine: number,
|
||||||
|
endLine: number
|
||||||
|
}>} */
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
const addFunc = (nameNode, bodyNode) => {
|
||||||
|
if (!bodyNode) return;
|
||||||
|
const bodyText = bodyNode.getText(sf).trim();
|
||||||
|
const start = bodyNode.getStart(sf);
|
||||||
|
const end = bodyNode.getEnd();
|
||||||
|
const { line: startLine } = sf.getLineAndCharacterOfPosition(start);
|
||||||
|
const { line: endLine } = sf.getLineAndCharacterOfPosition(end);
|
||||||
|
const name =
|
||||||
|
nameNode && ts.isIdentifier(nameNode) ? nameNode.escapedText.toString() : "(anonymous)";
|
||||||
|
|
||||||
|
// min-lines filter
|
||||||
|
const lines = bodyText.split(/\r?\n/).filter(Boolean);
|
||||||
|
if (lines.length < minLines) return;
|
||||||
|
|
||||||
|
// structural signature from the body
|
||||||
|
const structKey = structuralSignature(bodyNode);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
bodyText,
|
||||||
|
structKey,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
startLine: startLine + 1,
|
||||||
|
endLine: endLine + 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const visit = (node) => {
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.body) {
|
||||||
|
addFunc(node.name ?? null, node.body);
|
||||||
|
} else if (
|
||||||
|
ts.isFunctionExpression(node) ||
|
||||||
|
ts.isArrowFunction(node)
|
||||||
|
) {
|
||||||
|
// find name if it’s assigned: const foo = () => {}
|
||||||
|
let name = null;
|
||||||
|
if (node.parent && ts.isVariableDeclaration(node.parent) && node.parent.name) {
|
||||||
|
name = node.parent.name;
|
||||||
|
} else if (
|
||||||
|
node.parent &&
|
||||||
|
ts.isPropertyAssignment(node.parent) &&
|
||||||
|
ts.isIdentifier(node.parent.name)
|
||||||
|
) {
|
||||||
|
name = node.parent.name;
|
||||||
|
} else if (node.name) {
|
||||||
|
name = node.name;
|
||||||
|
}
|
||||||
|
if (node.body) addFunc(name, node.body);
|
||||||
|
} else if (ts.isMethodDeclaration(node) && node.body) {
|
||||||
|
addFunc(node.name, node.body);
|
||||||
|
}
|
||||||
|
node.forEachChild(visit);
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(sf);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- MAIN SCAN -------- */
|
||||||
|
const files = walk(path.resolve(process.cwd(), rootDir));
|
||||||
|
|
||||||
|
/** Maps from hash -> occurrences */
|
||||||
|
const groups = new Map();
|
||||||
|
/** Helper for exact hash */
|
||||||
|
import crypto from "crypto";
|
||||||
|
const exactHash = (text) => crypto.createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const raw = fs.readFileSync(file, "utf8");
|
||||||
|
|
||||||
|
/** @type {Array<{virtualPath:string, code:string, lineOffset:number}>} */
|
||||||
|
const codeUnits = [];
|
||||||
|
|
||||||
|
if (ext === ".astro") {
|
||||||
|
const blocks = extractCodeFromAstro(raw);
|
||||||
|
blocks.forEach((b, i) => {
|
||||||
|
codeUnits.push({
|
||||||
|
virtualPath: file + `#astro${i + 1}.ts`,
|
||||||
|
code: b.code,
|
||||||
|
lineOffset: b.offset || 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
codeUnits.push({ virtualPath: file, code: raw, lineOffset: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { virtualPath, code, lineOffset } of codeUnits) {
|
||||||
|
const sf = createSourceFile(virtualPath, code);
|
||||||
|
const funcs = getFunctionInfo(sf, file);
|
||||||
|
for (const f of funcs) {
|
||||||
|
const key =
|
||||||
|
mode === "exact" ? exactHash(f.bodyText) : crypto.createHash("sha1").update(f.structKey).digest("hex");
|
||||||
|
const item = {
|
||||||
|
file,
|
||||||
|
where:
|
||||||
|
ext === ".astro"
|
||||||
|
? `${path.relative(process.cwd(), file)}:${f.startLine + lineOffset - 1}-${f.endLine + lineOffset - 1}`
|
||||||
|
: `${path.relative(process.cwd(), file)}:${f.startLine}-${f.endLine}`,
|
||||||
|
name: f.name,
|
||||||
|
lines: f.endLine - f.startLine + 1,
|
||||||
|
preview: f.bodyText.split(/\r?\n/).slice(0, 5).join("\n") + (f.endLine - f.startLine + 1 > 5 ? "\n..." : ""),
|
||||||
|
};
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`⚠️ Skipping ${file}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------- REPORT -------- */
|
||||||
|
const dupes = [...groups.entries()]
|
||||||
|
.map(([key, arr]) => ({ key, items: arr }))
|
||||||
|
.filter((g) => g.items.length > 1)
|
||||||
|
.sort((a, b) => b.items.length - a.items.length);
|
||||||
|
|
||||||
|
if (outputJson) {
|
||||||
|
console.log(JSON.stringify({ mode, minLines, groups: dupes }, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dupes.length === 0) {
|
||||||
|
console.log(`✅ No duplicate functions found (mode=${mode}, min-lines=${minLines}).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFound ${dupes.length} duplicate group(s) (mode=${mode}, min-lines=${minLines}):\n`);
|
||||||
|
dupes.forEach((g, i) => {
|
||||||
|
console.log(`== Group ${i + 1} (${g.items.length} matches) ==`);
|
||||||
|
const example = g.items[0];
|
||||||
|
console.log(` Sample (${example.lines} lines) from ${example.where}${example.name ? ` [${example.name}]` : ""}`);
|
||||||
|
console.log(" ---");
|
||||||
|
console.log(indent(example.preview, " "));
|
||||||
|
console.log(" ---");
|
||||||
|
g.items.forEach((it) => {
|
||||||
|
console.log(` • ${it.where}${it.name ? ` [${it.name}]` : ""} (${it.lines} lines)`);
|
||||||
|
});
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
|
||||||
|
function indent(s, pre) {
|
||||||
|
return s
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => pre + l)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
198
helpful_prompts.md
Normal file
198
helpful_prompts.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# These Prompts can be used as system prompts for an AI model which supports drafting knowledgebase articles or quality-check the database, as well as generate new yaml entries
|
||||||
|
|
||||||
|
|
||||||
|
```md
|
||||||
|
You maintain a forensic tools database. **NEVER output complete YAML files** - only specific entries or updates requested.
|
||||||
|
|
||||||
|
## Database Structure
|
||||||
|
- `tools[]` - Software, methods, concepts with German names/descriptions
|
||||||
|
- `domains[]`, `phases[]`, `scenarios[]` - Forensic categories
|
||||||
|
- Tags must be English, relationships link existing entries
|
||||||
|
|
||||||
|
## Entry Format
|
||||||
|
```yaml
|
||||||
|
- name: "German Name"
|
||||||
|
type: software|method|concept
|
||||||
|
description: >- # German, 350-550 chars, embedding-optimized
|
||||||
|
skillLevel: novice|beginner|intermediate|advanced|expert
|
||||||
|
url: https://...
|
||||||
|
domains: [domain-ids]
|
||||||
|
phases: [phase-ids]
|
||||||
|
tags: [english-keywords]
|
||||||
|
related_concepts: [existing-concepts]
|
||||||
|
related_software: [existing-tools]
|
||||||
|
# Software only: platforms, license, accessType
|
||||||
|
```
|
||||||
|
|
||||||
|
## Description Rules (Critical for Semantic Search)
|
||||||
|
1. **Start with function** - what it does, not what it is
|
||||||
|
2. **Use forensic terminology** - RAM-dump, artifact-extraction, timeline-analysis
|
||||||
|
3. **Specify capabilities** - mention specific features and use cases
|
||||||
|
4. **Context matters** - when/why to use this tool
|
||||||
|
5. **Differentiate** - what makes it unique vs similar tools
|
||||||
|
|
||||||
|
**Bad**: "Ein Tool zur Analyse von Daten"
|
||||||
|
**Good**: "RAM-Dump-Analyse für versteckte Prozesse und Malware-Artefakte in Windows-Systemen"
|
||||||
|
|
||||||
|
## Tag Categories (English only)
|
||||||
|
- Functions: `artifact-extraction`, `timeline-analysis`, `memory-analysis`
|
||||||
|
- Interface: `gui`, `commandline`, `api`
|
||||||
|
- Scenarios: `scenario:memory_dump`, `scenario:file_recovery`
|
||||||
|
- Domains: `malware-analysis`, `incident-response`
|
||||||
|
|
||||||
|
## Quality Checks
|
||||||
|
Always flag: inconsistent naming, generic descriptions, broken relationships, missing metadata, poor embedding optimization.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
For additions: `# Addition to tools array` + YAML entry
|
||||||
|
For updates: `# Update existing: "Tool Name"` + changed fields only
|
||||||
|
Always explain changes and flag quality issues found.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
A method is the exact description of a reproducible process to archieve a specific result.
|
||||||
|
A Software is a computer program which processes data in some way to implement a process.
|
||||||
|
A concept is a set of high-level background knowledge which is needed to understand and properly execute a method and/or operate a software.
|
||||||
|
|
||||||
|
For the knowledgebase attribute:
|
||||||
|
If the entry (no matter of be it a software, method or concept) can be fully described in the description, this may not be needed. If there is more detailed action or knowledge necessary, the knowledgebase article would expand on the description here for deeper information. Nonetheless, the description should work with semantic search.
|
||||||
|
```
|
||||||
|
|
||||||
|
```md
|
||||||
|
You generate knowledgebase articles for a forensic tools database. Create practical, detailed documentation that helps users effectively use forensic tools and methods.
|
||||||
|
|
||||||
|
## Content Focus
|
||||||
|
- **Practical guides** - Installation, configuration, usage workflows
|
||||||
|
- **Real-world scenarios** - Case studies, investigation examples
|
||||||
|
- **Technical deep-dives** - Advanced features, troubleshooting
|
||||||
|
- **Best practices** - Methodology, evidence handling, efficiency tips
|
||||||
|
- **Integration guides** - How tools work together in investigations
|
||||||
|
|
||||||
|
## Entry Structure
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "German Title - Clear and Descriptive"
|
||||||
|
description: "German summary (150-300 chars) explaining what users will learn"
|
||||||
|
author: "Author Name"
|
||||||
|
last_updated: 2024-01-15
|
||||||
|
difficulty: novice|beginner|intermediate|advanced|expert
|
||||||
|
categories: ["installation", "configuration", "analysis", "troubleshooting"]
|
||||||
|
tags: ["english-keywords", "tool-specific", "technique-related"]
|
||||||
|
gated_content: false # content can be gated if still needing verification or is secret information
|
||||||
|
tool_name: "Exact Tool Name from YAML DB" # Optional - if related to specific tool
|
||||||
|
related_tools: ["Tool 1", "Tool 2"] # Optional - other relevant tools
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Article Content Here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content Guidelines
|
||||||
|
|
||||||
|
### Title & Description
|
||||||
|
- **German titles** - Clear, specific, actionable
|
||||||
|
- **Descriptions** optimize for search - mention key concepts, tools, outcomes
|
||||||
|
- Examples: "Volatility 3 Installation unter Windows 11", "Timeline-Analyse mit Autopsy für Incident Response"
|
||||||
|
|
||||||
|
### Categories (German, common patterns)
|
||||||
|
- `installation` - Setup and deployment guides
|
||||||
|
- `configuration` - Settings and customization
|
||||||
|
- `analysis` - Investigation techniques and workflows
|
||||||
|
- `troubleshooting` - Problem solving and debugging
|
||||||
|
- `integration` - Multi-tool workflows
|
||||||
|
- `case-study` - Real-world application examples
|
||||||
|
|
||||||
|
### Tags (English, specific)
|
||||||
|
- Tool names: `autopsy`, `volatility`, `wireshark`
|
||||||
|
- Techniques: `timeline-analysis`, `memory-forensics`, `network-analysis`
|
||||||
|
- Platforms: `windows`, `linux`, `macos`
|
||||||
|
- Scenarios: `malware-investigation`, `data-recovery`, `incident-response`
|
||||||
|
- File types: `registry`, `logs`, `disk-images`, `memory-dumps`
|
||||||
|
|
||||||
|
### Content Structure
|
||||||
|
1. **Problem/Context** - What investigation challenge this addresses
|
||||||
|
2. **Prerequisites** - Required knowledge, tools, system requirements
|
||||||
|
3. **Step-by-step process** - Clear, numbered instructions
|
||||||
|
4. **Screenshots/Examples** - Visual aids for complex procedures
|
||||||
|
5. **Common issues** - Troubleshooting section
|
||||||
|
6. **Next steps** - What to do with results, related techniques
|
||||||
|
|
||||||
|
## Quality Standards
|
||||||
|
|
||||||
|
### Technical Accuracy
|
||||||
|
- Verify all commands, file paths, and procedures
|
||||||
|
- Include version-specific information where relevant
|
||||||
|
- Test instructions on specified platforms
|
||||||
|
- Reference official documentation
|
||||||
|
|
||||||
|
### Practical Value
|
||||||
|
- Focus on real investigation scenarios
|
||||||
|
- Include time estimates for procedures
|
||||||
|
- Explain why each step is necessary
|
||||||
|
- Provide context for forensic methodology
|
||||||
|
|
||||||
|
### Documentation Quality
|
||||||
|
- Clear, concise German prose
|
||||||
|
- Consistent formatting and terminology
|
||||||
|
- Proper code blocks and syntax highlighting
|
||||||
|
- Logical information hierarchy
|
||||||
|
|
||||||
|
## Database Integration
|
||||||
|
|
||||||
|
**Tool Relationships**: When `tool_name` is specified, ensure:
|
||||||
|
- Exact match to YAML database entry name
|
||||||
|
- Consistent skill level alignment
|
||||||
|
- Complementary information to tool description
|
||||||
|
- Cross-references to related tools from database
|
||||||
|
|
||||||
|
**Semantic Consistency**: Use terminology that aligns with:
|
||||||
|
- YAML tool descriptions and tags
|
||||||
|
- Forensic domain vocabulary
|
||||||
|
- Investigation phase terminology
|
||||||
|
|
||||||
|
## Content Types
|
||||||
|
|
||||||
|
### Installation Guides
|
||||||
|
```markdown
|
||||||
|
# Volatility 3 Installation unter Ubuntu 22.04
|
||||||
|
|
||||||
|
Schritt-für-Schritt-Anleitung für die Installation von Volatility 3
|
||||||
|
auf Ubuntu-Systemen für forensische RAM-Analyse.
|
||||||
|
|
||||||
|
## Systemanforderungen
|
||||||
|
- Ubuntu 22.04 LTS oder neuer
|
||||||
|
- Python 3.8+
|
||||||
|
- 8GB RAM minimum für größere Memory-Dumps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analysis Workflows
|
||||||
|
```markdown
|
||||||
|
# Timeline-Analyse mit Autopsy: Von der Akquisition zur Ergebnispräsentation
|
||||||
|
|
||||||
|
Vollständiger Workflow für die chronologische Rekonstruktion von
|
||||||
|
Benutzeraktivitäten bei forensischen Untersuchungen.
|
||||||
|
|
||||||
|
## Szenario
|
||||||
|
Untersuchung eines verdächtigen Arbeitsplatz-PCs nach Datenleck...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting Guides
|
||||||
|
```markdown
|
||||||
|
# Autopsy Performance-Optimierung für große Datenträger
|
||||||
|
|
||||||
|
Lösungsansätze für häufige Performance-Probleme bei der Analyse
|
||||||
|
von Datenträgern über 1TB mit Autopsy.
|
||||||
|
|
||||||
|
## Häufige Symptome
|
||||||
|
- Langsame Indizierung bei großen Images...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Always provide complete markdown file content including:
|
||||||
|
- Full frontmatter with all required fields
|
||||||
|
- Well-structured content with headers
|
||||||
|
- Code blocks where appropriate
|
||||||
|
- Clear, actionable instructions
|
||||||
|
- German content with English technical terms preserved
|
||||||
|
- dont hallucinate links, only provide if considered verified, but mark any links which would need verification
|
||||||
|
```
|
||||||
15
package.json
15
package.json
@@ -10,17 +10,22 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.3.0",
|
"@astrojs/node": "^9.4.3",
|
||||||
"astro": "^5.12.3",
|
"astro": "^5.13.7",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.6.1",
|
||||||
"jose": "^5.2.0",
|
"jose": "^5.10.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"simple-boost": "^2.0.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-yaml": "^4.0.9"
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-safe-parser": "^7.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
405
public/videos/README.md
Normal file
405
public/videos/README.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# Video-Bereitstellung für ForensicPathways Knowledgebase
|
||||||
|
|
||||||
|
Videos müssen manuell in diesem Verzeichnis bereitgestellt werden, da sie aufgrund unterschiedlicher Lizenzierung nicht Bestandteil des Open-Source-Git-Repositorys sind.
|
||||||
|
|
||||||
|
## 🎥 Video-Quelle und Lizenzierung
|
||||||
|
|
||||||
|
**Video-Quelle:** https://cloud.cc24.dev/f/47971 (Interner Nextcloud-Share)
|
||||||
|
**Kontakt bei Fragen:** mstoeck3@hs-mittweida.de
|
||||||
|
|
||||||
|
### Lizenzhinweise
|
||||||
|
|
||||||
|
- Videos können proprietäre Lizenzen haben
|
||||||
|
- Nicht für öffentliche Redistribution geeignet
|
||||||
|
- Nur für den internen Gebrauch in ForensicPathways
|
||||||
|
- Urheberrechte beachten bei eigenen Video-Beiträgen
|
||||||
|
|
||||||
|
## 📁 Empfohlene Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
public/videos/
|
||||||
|
├── tools/ # Tool-spezifische Tutorials
|
||||||
|
│ ├── autopsy/
|
||||||
|
│ │ ├── autopsy-installation.mp4
|
||||||
|
│ │ ├── autopsy-basics.mp4
|
||||||
|
│ │ └── autopsy-advanced-analysis.webm
|
||||||
|
│ ├── volatility/
|
||||||
|
│ │ ├── volatility-setup.mp4
|
||||||
|
│ │ ├── volatility-pslist-demo.mp4
|
||||||
|
│ │ └── volatility-malfind-tutorial.webm
|
||||||
|
│ └── yara/
|
||||||
|
│ ├── yara-rules-basics.mp4
|
||||||
|
│ └── yara-advanced-hunting.mp4
|
||||||
|
├── methods/ # Methodologie-Videos
|
||||||
|
│ ├── timeline-analysis/
|
||||||
|
│ │ ├── timeline-fundamentals.mp4
|
||||||
|
│ │ └── timeline-correlation.webm
|
||||||
|
│ ├── disk-imaging/
|
||||||
|
│ │ ├── imaging-best-practices.mp4
|
||||||
|
│ │ └── imaging-verification.mp4
|
||||||
|
│ └── incident-response/
|
||||||
|
│ ├── ir-methodology.mp4
|
||||||
|
│ └── ir-documentation.webm
|
||||||
|
├── concepts/ # Konzeptuelle Erklärungen
|
||||||
|
│ ├── forensics-fundamentals/
|
||||||
|
│ │ ├── hash-functions-explained.mp4
|
||||||
|
│ │ ├── chain-of-custody.mp4
|
||||||
|
│ │ └── evidence-handling.webm
|
||||||
|
│ └── technical-concepts/
|
||||||
|
│ ├── regex-patterns.mp4
|
||||||
|
│ └── file-systems.webm
|
||||||
|
└── shared/ # Übergreifende Inhalte
|
||||||
|
├── nist-methodology.mp4
|
||||||
|
├── legal-considerations.webm
|
||||||
|
└── best-practices-overview.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🦊 Firefox-Kompatibilität (KRITISCH)
|
||||||
|
|
||||||
|
### **Wichtiger Hinweis**
|
||||||
|
Videos **müssen** in Firefox-kompatiblen Formaten bereitgestellt werden, da das System automatische Firefox-Unterstützung implementiert. Nicht-kompatible Formate führen zu Fehlern!
|
||||||
|
|
||||||
|
### Unterstützte Formate
|
||||||
|
|
||||||
|
#### ✅ Empfohlene Formate (höchste Kompatibilität)
|
||||||
|
|
||||||
|
**MP4 (H.264/AVC + AAC):**
|
||||||
|
```bash
|
||||||
|
# Konvertierung mit ffmpeg
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 \
|
||||||
|
-c:a aac \
|
||||||
|
-profile:v baseline \
|
||||||
|
-level 3.0 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**WebM (VP8/VP9 + Vorbis/Opus):**
|
||||||
|
```bash
|
||||||
|
# VP9 für beste Qualität
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libvpx-vp9 \
|
||||||
|
-c:a libopus \
|
||||||
|
-b:v 1M \
|
||||||
|
-b:a 128k \
|
||||||
|
output.webm
|
||||||
|
|
||||||
|
# VP8 für breitere Kompatibilität
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libvpx \
|
||||||
|
-c:a libvorbis \
|
||||||
|
-b:v 1M \
|
||||||
|
-b:a 128k \
|
||||||
|
output.webm
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ Fallback-Format
|
||||||
|
|
||||||
|
**OGG Theora (für ältere Firefox-Versionen):**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libtheora \
|
||||||
|
-c:a libvorbis \
|
||||||
|
-b:v 1M \
|
||||||
|
-b:a 128k \
|
||||||
|
output.ogv
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Nicht unterstützte Formate in Firefox
|
||||||
|
|
||||||
|
- **H.265/HEVC** (.mp4, .mov) - Wird nicht dekodiert
|
||||||
|
- **AV1** (.mp4, .webm) - Eingeschränkte Unterstützung
|
||||||
|
- **Proprietäre Codecs** (.wmv, .avi mit proprietären Codecs)
|
||||||
|
- **Apple-spezifische Formate** (.mov mit ProRes, .m4v)
|
||||||
|
|
||||||
|
### Multi-Format-Bereitstellung
|
||||||
|
|
||||||
|
Für maximale Kompatibilität mehrere Formate bereitstellen:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<video title="Autopsy Installation Tutorial" controls>
|
||||||
|
<source src="/videos/tools/autopsy/installation.mp4" type="video/mp4">
|
||||||
|
<source src="/videos/tools/autopsy/installation.webm" type="video/webm">
|
||||||
|
<source src="/videos/tools/autopsy/installation.ogv" type="video/ogg">
|
||||||
|
<p>Ihr Browser unterstützt das Video-Element nicht.</p>
|
||||||
|
</video>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Video-Konvertierung und -Optimierung
|
||||||
|
|
||||||
|
### Qualitätsrichtlinien
|
||||||
|
|
||||||
|
#### Auflösung und Bitrate
|
||||||
|
|
||||||
|
**720p (empfohlen für Tutorials):**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-vf scale=1280:720 \
|
||||||
|
-c:v libx264 \
|
||||||
|
-b:v 2M \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 128k \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**1080p (für detaillierte Demonstrationen):**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-vf scale=1920:1080 \
|
||||||
|
-c:v libx264 \
|
||||||
|
-b:v 4M \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 128k \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**480p (mobile-optimiert):**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-vf scale=854:480 \
|
||||||
|
-c:v libx264 \
|
||||||
|
-b:v 1M \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 96k \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimierung für Web-Streaming
|
||||||
|
|
||||||
|
#### Fast Start für progressive Download
|
||||||
|
```bash
|
||||||
|
# Metadata an Dateianfang verschieben
|
||||||
|
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Keyframe-Intervall optimieren
|
||||||
|
```bash
|
||||||
|
# Keyframes alle 2 Sekunden für bessere Suche
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 \
|
||||||
|
-g 60 \
|
||||||
|
-keyint_min 60 \
|
||||||
|
-sc_threshold 0 \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch-Konvertierung
|
||||||
|
|
||||||
|
**Alle Videos in einem Verzeichnis konvertieren:**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# convert-all.sh
|
||||||
|
for file in *.mov *.avi *.mkv; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
name=$(basename "$file" | cut -d. -f1)
|
||||||
|
|
||||||
|
# MP4 erstellen
|
||||||
|
ffmpeg -i "$file" \
|
||||||
|
-c:v libx264 \
|
||||||
|
-c:a aac \
|
||||||
|
-b:v 2M \
|
||||||
|
-b:a 128k \
|
||||||
|
-movflags +faststart \
|
||||||
|
"${name}.mp4"
|
||||||
|
|
||||||
|
# WebM erstellen
|
||||||
|
ffmpeg -i "$file" \
|
||||||
|
-c:v libvpx-vp9 \
|
||||||
|
-c:a libopus \
|
||||||
|
-b:v 1.5M \
|
||||||
|
-b:a 128k \
|
||||||
|
"${name}.webm"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Dateigröße und Performance
|
||||||
|
|
||||||
|
### Größenrichtlinien
|
||||||
|
|
||||||
|
**Streaming-optimiert:**
|
||||||
|
- 720p: 5-15 MB/Minute
|
||||||
|
- 1080p: 20-40 MB/Minute
|
||||||
|
- 480p: 2-8 MB/Minute
|
||||||
|
|
||||||
|
**Maximale Dateigröße:**
|
||||||
|
- Tutorial-Videos: < 100 MB
|
||||||
|
- Kurze Demos: < 50 MB
|
||||||
|
- Konzept-Erklärungen: < 30 MB
|
||||||
|
|
||||||
|
### Kompressionseinstellungen
|
||||||
|
|
||||||
|
**Ausgewogene Qualität/Größe:**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 \
|
||||||
|
-preset medium \
|
||||||
|
-crf 23 \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 128k \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hohe Kompression (kleinere Dateien):**
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 \
|
||||||
|
-preset slow \
|
||||||
|
-crf 28 \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 96k \
|
||||||
|
output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎬 Video-Thumbnail-Generierung
|
||||||
|
|
||||||
|
Automatische Thumbnail-Erstellung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Thumbnail nach 10 Sekunden
|
||||||
|
ffmpeg -i input.mp4 -ss 00:00:10 -vframes 1 -q:v 2 thumbnail.jpg
|
||||||
|
|
||||||
|
# Mehrere Thumbnails für Auswahl
|
||||||
|
ffmpeg -i input.mp4 -vf fps=1/30 thumb_%03d.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Thumbnails speichern in:
|
||||||
|
```
|
||||||
|
public/images/video-thumbnails/
|
||||||
|
├── autopsy-installation-thumb.jpg
|
||||||
|
├── volatility-basics-thumb.jpg
|
||||||
|
└── timeline-analysis-thumb.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Qualitätskontrolle
|
||||||
|
|
||||||
|
### Pre-Upload-Checkliste
|
||||||
|
|
||||||
|
**✅ Format-Kompatibilität:**
|
||||||
|
- [ ] MP4 mit H.264/AVC Video-Codec
|
||||||
|
- [ ] AAC Audio-Codec
|
||||||
|
- [ ] Fast Start aktiviert (`movflags +faststart`)
|
||||||
|
- [ ] Keyframe-Intervall ≤ 2 Sekunden
|
||||||
|
|
||||||
|
**✅ Firefox-Test:**
|
||||||
|
- [ ] Video lädt in Firefox ohne Fehler
|
||||||
|
- [ ] Audio synchron mit Video
|
||||||
|
- [ ] Controls funktionieren
|
||||||
|
- [ ] Seeking funktioniert flüssig
|
||||||
|
|
||||||
|
**✅ Technische Qualität:**
|
||||||
|
- [ ] Auflösung angemessen (720p+ für GUI-Demos)
|
||||||
|
- [ ] Audio klar und verständlich
|
||||||
|
- [ ] Keine Kompressionsartefakte
|
||||||
|
- [ ] Dateigröße < 100 MB
|
||||||
|
|
||||||
|
**✅ Inhaltliche Qualität:**
|
||||||
|
- [ ] Beschreibender Dateiname
|
||||||
|
- [ ] Angemessene Länge (< 10 Minuten für Tutorials)
|
||||||
|
- [ ] Klare Demonstration der Funktionalität
|
||||||
|
- [ ] Sichtbare UI-Elemente
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# video-check.sh - Basis-Validierung
|
||||||
|
for video in public/videos/**/*.mp4; do
|
||||||
|
echo "Checking: $video"
|
||||||
|
|
||||||
|
# Format prüfen
|
||||||
|
format=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 "$video")
|
||||||
|
if [ "$format" != "h264" ]; then
|
||||||
|
echo "❌ Wrong codec: $format (should be h264)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dateigröße prüfen
|
||||||
|
size=$(stat -c%s "$video")
|
||||||
|
if [ $size -gt 104857600 ]; then # 100MB
|
||||||
|
echo "⚠️ Large file: $(($size / 1048576))MB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ $video validated"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### Häufige Firefox-Probleme
|
||||||
|
|
||||||
|
**Problem: Video lädt nicht**
|
||||||
|
```
|
||||||
|
Lösung:
|
||||||
|
1. Codec überprüfen: ffprobe -v quiet -show_format -show_streams video.mp4
|
||||||
|
2. Fallback-Format hinzufügen
|
||||||
|
3. Fast Start aktivieren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem: Audio/Video out of sync**
|
||||||
|
```
|
||||||
|
Lösung:
|
||||||
|
ffmpeg -i input.mp4 -c:v copy -c:a aac -avoid_negative_ts make_zero output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem: Seeking funktioniert nicht**
|
||||||
|
```
|
||||||
|
Lösung:
|
||||||
|
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance-Probleme
|
||||||
|
|
||||||
|
**Problem: Lange Ladezeiten**
|
||||||
|
```
|
||||||
|
Lösungsansätze:
|
||||||
|
1. Bitrate reduzieren
|
||||||
|
2. Auflösung verringern
|
||||||
|
3. Keyframe-Intervall optimieren
|
||||||
|
4. Progressive Download aktivieren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem: Hohe Bandbreiten-Nutzung**
|
||||||
|
```
|
||||||
|
Lösungsansätze:
|
||||||
|
1. Adaptive Streaming implementieren
|
||||||
|
2. Multiple Qualitätsstufen bereitstellen
|
||||||
|
3. Preload="metadata" verwenden
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Deployment-Checkliste
|
||||||
|
|
||||||
|
**Nach Video-Upload:**
|
||||||
|
|
||||||
|
1. **✅ Dateistruktur prüfen**
|
||||||
|
```bash
|
||||||
|
ls -la public/videos/tools/autopsy/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **✅ Permissions setzen**
|
||||||
|
```bash
|
||||||
|
chmod 644 public/videos/**/*.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **✅ Artikel-Verlinkung testen**
|
||||||
|
- Video-Tags in Markdown funktionieren
|
||||||
|
- Responsive Container werden generiert
|
||||||
|
- Thumbnails laden korrekt
|
||||||
|
|
||||||
|
4. **✅ Browser-Kompatibilität**
|
||||||
|
- Firefox: Codec-Support prüfen
|
||||||
|
- Chrome: Performance testen
|
||||||
|
- Safari: Fallback-Formate testen
|
||||||
|
- Mobile: Touch-Controls funktionieren
|
||||||
|
|
||||||
|
5. **✅ Build-System**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Keine Video-bezogenen Fehler in Console
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei Problemen kontaktieren Sie mstoeck3@hs-mittweida.de mit:
|
||||||
|
- Browser und Version
|
||||||
|
- Video-Dateiname und -pfad
|
||||||
|
- Fehlermeldungen aus Browser-Console
|
||||||
|
- Screenshot des Problems
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/components/ContributionButton.astro - CLEANED: Removed duplicate auth script
|
// src/components/ContributionButton.astro
|
||||||
export interface Props {
|
export interface Props {
|
||||||
type: 'edit' | 'new' | 'write';
|
type: 'edit' | 'new' | 'write';
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { createToolSlug } from '../utils/toolHelpers.js';
|
import { createToolSlug } from '../utils/clientUtils.js';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getToolsData } from '../utils/dataService.js';
|
|||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const scenarios = data.scenarios || [];
|
const scenarios = data.scenarios || [];
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const maxDisplayed = 9;
|
const maxDisplayed = 9;
|
||||||
const displayedScenarios = scenarios.slice(0, maxDisplayed);
|
const displayedScenarios = scenarios.slice(0, maxDisplayed);
|
||||||
---
|
---
|
||||||
@@ -77,41 +76,33 @@ const displayedScenarios = scenarios.slice(0, maxDisplayed);
|
|||||||
|
|
||||||
if (!mainSearchInput) return;
|
if (!mainSearchInput) return;
|
||||||
|
|
||||||
// Check if this scenario is already active (allow deselection)
|
|
||||||
if (clickedChip && clickedChip.classList.contains('active')) {
|
if (clickedChip && clickedChip.classList.contains('active')) {
|
||||||
// Deselect: clear search and remove active state
|
|
||||||
mainSearchInput.value = '';
|
mainSearchInput.value = '';
|
||||||
document.querySelectorAll('.suggestion-chip').forEach(chip => {
|
document.querySelectorAll('.suggestion-chip').forEach(chip => {
|
||||||
chip.classList.remove('active');
|
chip.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the targeted search input too
|
|
||||||
const targetedInput = document.getElementById('targeted-search-input');
|
const targetedInput = document.getElementById('targeted-search-input');
|
||||||
if (targetedInput) {
|
if (targetedInput) {
|
||||||
targetedInput.value = '';
|
targetedInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger search to show all results
|
|
||||||
const inputEvent = new Event('input', { bubbles: true });
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
mainSearchInput.dispatchEvent(inputEvent);
|
mainSearchInput.dispatchEvent(inputEvent);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply new search
|
|
||||||
mainSearchInput.value = scenarioId;
|
mainSearchInput.value = scenarioId;
|
||||||
|
|
||||||
// Trigger existing search functionality
|
|
||||||
const inputEvent = new Event('input', { bubbles: true });
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
mainSearchInput.dispatchEvent(inputEvent);
|
mainSearchInput.dispatchEvent(inputEvent);
|
||||||
|
|
||||||
// Switch to grid view
|
|
||||||
const gridToggle = document.querySelector('.view-toggle[data-view="grid"]');
|
const gridToggle = document.querySelector('.view-toggle[data-view="grid"]');
|
||||||
if (gridToggle && !gridToggle.classList.contains('active')) {
|
if (gridToggle && !gridToggle.classList.contains('active')) {
|
||||||
gridToggle.click();
|
gridToggle.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
document.querySelectorAll('.suggestion-chip').forEach(chip => {
|
document.querySelectorAll('.suggestion-chip').forEach(chip => {
|
||||||
chip.classList.remove('active');
|
chip.classList.remove('active');
|
||||||
});
|
});
|
||||||
@@ -119,17 +110,14 @@ const displayedScenarios = scenarios.slice(0, maxDisplayed);
|
|||||||
clickedChip.classList.add('active');
|
clickedChip.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to results with better positioning
|
|
||||||
window.scrollToElementById('tools-grid');
|
window.scrollToElementById('tools-grid');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle showing all scenarios
|
|
||||||
window.toggleAllScenarios = function() {
|
window.toggleAllScenarios = function() {
|
||||||
const suggestionsContainer = document.getElementById('scenario-suggestions');
|
const suggestionsContainer = document.getElementById('scenario-suggestions');
|
||||||
const moreBtn = document.getElementById('more-scenarios-btn');
|
const moreBtn = document.getElementById('more-scenarios-btn');
|
||||||
|
|
||||||
if (!showingAllScenarios) {
|
if (!showingAllScenarios) {
|
||||||
// Show additional scenarios
|
|
||||||
const additionalScenarios = allScenarios.slice(maxDisplay);
|
const additionalScenarios = allScenarios.slice(maxDisplay);
|
||||||
additionalScenarios.forEach(scenario => {
|
additionalScenarios.forEach(scenario => {
|
||||||
const chip = document.createElement('div');
|
const chip = document.createElement('div');
|
||||||
@@ -146,14 +134,12 @@ const displayedScenarios = scenarios.slice(0, maxDisplayed);
|
|||||||
moreBtn.textContent = 'Weniger anzeigen';
|
moreBtn.textContent = 'Weniger anzeigen';
|
||||||
showingAllScenarios = true;
|
showingAllScenarios = true;
|
||||||
} else {
|
} else {
|
||||||
// Hide additional scenarios
|
|
||||||
document.querySelectorAll('.additional-scenario').forEach(chip => chip.remove());
|
document.querySelectorAll('.additional-scenario').forEach(chip => chip.remove());
|
||||||
moreBtn.textContent = `+ ${allScenarios.length - maxDisplay} weitere Szenarien`;
|
moreBtn.textContent = `+ ${allScenarios.length - maxDisplay} weitere Szenarien`;
|
||||||
showingAllScenarios = false;
|
showingAllScenarios = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle targeted search input
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const targetedInput = document.getElementById('targeted-search-input');
|
const targetedInput = document.getElementById('targeted-search-input');
|
||||||
if (targetedInput) {
|
if (targetedInput) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
|
//src/components/ToolFilters.astro
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const domains = data.domains;
|
const domains = data.domains;
|
||||||
const phases = data.phases;
|
const phases = data.phases;
|
||||||
|
|
||||||
// Extract unique values dynamically - NO HARD-CODING
|
|
||||||
const skillLevels = [...new Set(data.tools.map(tool => tool.skillLevel))].filter(Boolean).sort();
|
const skillLevels = [...new Set(data.tools.map(tool => tool.skillLevel))].filter(Boolean).sort();
|
||||||
const platforms = [...new Set(data.tools.flatMap(tool => tool.platforms || []))].filter(Boolean).sort();
|
const platforms = [...new Set(data.tools.flatMap(tool => tool.platforms || []))].filter(Boolean).sort();
|
||||||
const licenses = [...new Set(data.tools.map(tool => tool.license))].filter(Boolean).sort();
|
const licenses = [...new Set(data.tools.map(tool => tool.license))].filter(Boolean).sort();
|
||||||
@@ -26,33 +26,55 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
|
|
||||||
<div class="filters-container">
|
<div class="filters-container">
|
||||||
<!-- Search Section -->
|
<!-- Search Section -->
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-card-compact">
|
<div class="filter-card-compact">
|
||||||
<div class="filter-header-compact">
|
<div class="filter-header-compact">
|
||||||
<h3>🔍 Suche</h3>
|
<h3>🔍 Suche</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-wrapper">
|
<div class="search-row">
|
||||||
<div class="search-icon">
|
<div class="search-wrapper">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="search-icon">
|
||||||
<circle cx="11" cy="11" r="8"/>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
</svg>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-input"
|
||||||
|
placeholder="Software, Beschreibung oder Tags durchsuchen..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<button id="clear-search" class="search-clear hidden" title="Suche löschen">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Semantic Search Toggle - Inline -->
|
||||||
|
<div id="semantic-search-container" class="semantic-search-inline hidden">
|
||||||
|
<label class="semantic-toggle-wrapper" title="Semantische Suche verwendet Embeddings. Dadurch kann mit natürlicher Sprache/Begriffen gesucht werden, die Ergebnisse richten sich nach der cosinus-Distanz.">
|
||||||
|
<input type="checkbox" id="semantic-search-enabled" disabled/>
|
||||||
|
<div class="semantic-checkbox-custom"></div>
|
||||||
|
<span class="semantic-toggle-label">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
|
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Semantisch
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Display -->
|
||||||
|
<div id="semantic-status" class="semantic-status hidden">
|
||||||
|
<span class="semantic-results-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="search-input"
|
|
||||||
placeholder="Software, Beschreibung oder Tags durchsuchen..."
|
|
||||||
class="search-input"
|
|
||||||
/>
|
|
||||||
<button id="clear-search" class="search-clear hidden" title="Suche löschen">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Primary Filters Section - ONLY Domain and Phase -->
|
<!-- Primary Filters Section - ONLY Domain and Phase -->
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
@@ -284,13 +306,15 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
|
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
|
||||||
window.toolsData = toolsData;
|
window.toolsData = toolsData;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Cache DOM elements
|
|
||||||
const elements = {
|
const elements = {
|
||||||
searchInput: document.getElementById('search-input'),
|
searchInput: document.getElementById('search-input'),
|
||||||
clearSearch: document.getElementById('clear-search'),
|
clearSearch: document.getElementById('clear-search'),
|
||||||
|
semanticContainer: document.getElementById('semantic-search-container'),
|
||||||
|
semanticCheckbox: document.getElementById('semantic-search-enabled'),
|
||||||
|
semanticStatus: document.getElementById('semantic-status'),
|
||||||
|
semanticResultsCount: document.querySelector('.semantic-results-count'),
|
||||||
domainSelect: document.getElementById('domain-select'),
|
domainSelect: document.getElementById('domain-select'),
|
||||||
phaseSelect: document.getElementById('phase-select'),
|
phaseSelect: document.getElementById('phase-select'),
|
||||||
typeSelect: document.getElementById('type-select'),
|
typeSelect: document.getElementById('type-select'),
|
||||||
@@ -312,25 +336,70 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
tags: document.getElementById('reset-tags'),
|
tags: document.getElementById('reset-tags'),
|
||||||
all: document.getElementById('reset-all-filters')
|
all: document.getElementById('reset-all-filters')
|
||||||
},
|
},
|
||||||
// Collapsible elements
|
|
||||||
toggleAdvanced: document.getElementById('toggle-advanced'),
|
toggleAdvanced: document.getElementById('toggle-advanced'),
|
||||||
toggleTags: document.getElementById('toggle-tags'),
|
toggleTags: document.getElementById('toggle-tags'),
|
||||||
advancedContent: document.getElementById('advanced-filters-content'),
|
advancedContent: document.getElementById('advanced-filters-content'),
|
||||||
tagContent: document.getElementById('tag-filters-content')
|
tagContent: document.getElementById('tag-filters-content')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify critical elements exist
|
|
||||||
if (!elements.searchInput || !elements.domainSelect) {
|
if (!elements.searchInput || !elements.domainSelect) {
|
||||||
console.error('Critical filter elements not found');
|
console.error('Critical filter elements not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State management
|
|
||||||
let selectedTags = new Set();
|
let selectedTags = new Set();
|
||||||
let selectedPhase = '';
|
let selectedPhase = '';
|
||||||
let isTagCloudExpanded = false;
|
let isTagCloudExpanded = false;
|
||||||
|
let semanticSearchEnabled = false;
|
||||||
|
let semanticSearchAvailable = false;
|
||||||
|
let lastSemanticResults = null;
|
||||||
|
|
||||||
|
async function checkEmbeddingsAvailability() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/embeddings-status');
|
||||||
|
const { embeddings } = await res.json();
|
||||||
|
semanticSearchAvailable = embeddings?.initialized;
|
||||||
|
|
||||||
|
if (semanticSearchAvailable) {
|
||||||
|
elements.semanticContainer.classList.remove('hidden');
|
||||||
|
elements.semanticCheckbox.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EMBEDDINGS] Status check failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSemanticSearch(query) {
|
||||||
|
if (!semanticSearchAvailable || !query.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/search/semantic', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: query.trim() })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SEMANTIC] Search failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolHosted(tool) {
|
||||||
|
return tool.projectUrl !== undefined &&
|
||||||
|
tool.projectUrl !== null &&
|
||||||
|
tool.projectUrl !== "" &&
|
||||||
|
tool.projectUrl.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
// Collapsible functionality
|
|
||||||
function toggleCollapsible(toggleBtn, content, storageKey) {
|
function toggleCollapsible(toggleBtn, content, storageKey) {
|
||||||
const isCollapsed = toggleBtn.getAttribute('data-collapsed') === 'true';
|
const isCollapsed = toggleBtn.getAttribute('data-collapsed') === 'true';
|
||||||
const newState = !isCollapsed;
|
const newState = !isCollapsed;
|
||||||
@@ -338,22 +407,17 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
toggleBtn.setAttribute('data-collapsed', newState.toString());
|
toggleBtn.setAttribute('data-collapsed', newState.toString());
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
// Collapse
|
|
||||||
content.classList.add('hidden');
|
content.classList.add('hidden');
|
||||||
toggleBtn.style.transform = 'rotate(0deg)';
|
toggleBtn.style.transform = 'rotate(0deg)';
|
||||||
} else {
|
} else {
|
||||||
// Expand
|
|
||||||
content.classList.remove('hidden');
|
content.classList.remove('hidden');
|
||||||
toggleBtn.style.transform = 'rotate(180deg)';
|
toggleBtn.style.transform = 'rotate(180deg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store state in sessionStorage
|
|
||||||
sessionStorage.setItem(storageKey, newState.toString());
|
sessionStorage.setItem(storageKey, newState.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize collapsible sections (collapsed by default)
|
|
||||||
function initializeCollapsible() {
|
function initializeCollapsible() {
|
||||||
// Advanced filters
|
|
||||||
const advancedCollapsed = sessionStorage.getItem('advanced-collapsed') !== 'false';
|
const advancedCollapsed = sessionStorage.getItem('advanced-collapsed') !== 'false';
|
||||||
elements.toggleAdvanced.setAttribute('data-collapsed', advancedCollapsed.toString());
|
elements.toggleAdvanced.setAttribute('data-collapsed', advancedCollapsed.toString());
|
||||||
if (advancedCollapsed) {
|
if (advancedCollapsed) {
|
||||||
@@ -364,7 +428,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
elements.toggleAdvanced.style.transform = 'rotate(180deg)';
|
elements.toggleAdvanced.style.transform = 'rotate(180deg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag filters
|
|
||||||
const tagsCollapsed = sessionStorage.getItem('tags-collapsed') !== 'false';
|
const tagsCollapsed = sessionStorage.getItem('tags-collapsed') !== 'false';
|
||||||
elements.toggleTags.setAttribute('data-collapsed', tagsCollapsed.toString());
|
elements.toggleTags.setAttribute('data-collapsed', tagsCollapsed.toString());
|
||||||
if (tagsCollapsed) {
|
if (tagsCollapsed) {
|
||||||
@@ -376,15 +439,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to check if tool is hosted
|
|
||||||
function isToolHosted(tool) {
|
|
||||||
return tool.projectUrl !== undefined &&
|
|
||||||
tool.projectUrl !== null &&
|
|
||||||
tool.projectUrl !== "" &&
|
|
||||||
tool.projectUrl.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize tag cloud
|
|
||||||
function initTagCloud() {
|
function initTagCloud() {
|
||||||
const visibleCount = 20;
|
const visibleCount = 20;
|
||||||
elements.tagCloudItems.forEach((item, index) => {
|
elements.tagCloudItems.forEach((item, index) => {
|
||||||
@@ -394,7 +448,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle tag cloud expansion
|
|
||||||
function toggleTagCloud() {
|
function toggleTagCloud() {
|
||||||
isTagCloudExpanded = !isTagCloudExpanded;
|
isTagCloudExpanded = !isTagCloudExpanded;
|
||||||
const visibleCount = 20;
|
const visibleCount = 20;
|
||||||
@@ -428,7 +481,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tag cloud based on search
|
|
||||||
function filterTagCloud() {
|
function filterTagCloud() {
|
||||||
const searchTerm = elements.searchInput.value.toLowerCase();
|
const searchTerm = elements.searchInput.value.toLowerCase();
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
@@ -458,7 +510,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
elements.tagCloudToggle.style.display = hasHiddenTags ? 'block' : 'none';
|
elements.tagCloudToggle.style.display = hasHiddenTags ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update selected tags display
|
|
||||||
function updateSelectedTags() {
|
function updateSelectedTags() {
|
||||||
if (selectedTags.size === 0) {
|
if (selectedTags.size === 0) {
|
||||||
elements.selectedTags.style.display = 'none';
|
elements.selectedTags.style.display = 'none';
|
||||||
@@ -478,7 +529,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add event listeners for remove buttons
|
|
||||||
elements.selectedTags.querySelectorAll('.remove-tag').forEach(btn => {
|
elements.selectedTags.querySelectorAll('.remove-tag').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const tag = btn.getAttribute('data-tag');
|
const tag = btn.getAttribute('data-tag');
|
||||||
@@ -487,10 +537,8 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add/remove tags - FIXED: Update ALL matching elements
|
|
||||||
function addTag(tag) {
|
function addTag(tag) {
|
||||||
selectedTags.add(tag);
|
selectedTags.add(tag);
|
||||||
// FIXED: Use querySelectorAll to update ALL matching tag elements
|
|
||||||
document.querySelectorAll(`[data-tag="${tag}"]`).forEach(element => {
|
document.querySelectorAll(`[data-tag="${tag}"]`).forEach(element => {
|
||||||
element.classList.add('active');
|
element.classList.add('active');
|
||||||
});
|
});
|
||||||
@@ -500,7 +548,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
|
|
||||||
function removeTag(tag) {
|
function removeTag(tag) {
|
||||||
selectedTags.delete(tag);
|
selectedTags.delete(tag);
|
||||||
// FIXED: Use querySelectorAll to update ALL matching tag elements
|
|
||||||
document.querySelectorAll(`[data-tag="${tag}"]`).forEach(element => {
|
document.querySelectorAll(`[data-tag="${tag}"]`).forEach(element => {
|
||||||
element.classList.remove('active');
|
element.classList.remove('active');
|
||||||
});
|
});
|
||||||
@@ -508,7 +555,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
filterTools();
|
filterTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update results counter
|
|
||||||
function updateResultsCounter(count) {
|
function updateResultsCounter(count) {
|
||||||
const total = window.toolsData.length;
|
const total = window.toolsData.length;
|
||||||
elements.resultsCounter.textContent = count === total
|
elements.resultsCounter.textContent = count === total
|
||||||
@@ -516,8 +562,18 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
: `${count} von ${total} Tools`;
|
: `${count} von ${total} Tools`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main filter function
|
function updateSemanticStatus(results) {
|
||||||
function filterTools() {
|
if (!elements.semanticStatus || !elements.semanticResultsCount) return;
|
||||||
|
|
||||||
|
if (semanticSearchEnabled && results?.length > 0) {
|
||||||
|
elements.semanticStatus.classList.remove('hidden');
|
||||||
|
elements.semanticResultsCount.textContent = `${results.length} semantische Treffer`;
|
||||||
|
} else {
|
||||||
|
elements.semanticStatus.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterTools() {
|
||||||
const searchTerm = elements.searchInput.value.trim().toLowerCase();
|
const searchTerm = elements.searchInput.value.trim().toLowerCase();
|
||||||
const selectedDomain = elements.domainSelect.value;
|
const selectedDomain = elements.domainSelect.value;
|
||||||
const selectedPhaseFromSelect = elements.phaseSelect.value;
|
const selectedPhaseFromSelect = elements.phaseSelect.value;
|
||||||
@@ -529,65 +585,67 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
const hostedOnly = elements.hostedOnly.checked;
|
const hostedOnly = elements.hostedOnly.checked;
|
||||||
const knowledgebaseOnly = elements.knowledgebaseOnly.checked;
|
const knowledgebaseOnly = elements.knowledgebaseOnly.checked;
|
||||||
|
|
||||||
// Use phase from either dropdown or button selection
|
|
||||||
const activePhase = selectedPhaseFromSelect || selectedPhase;
|
const activePhase = selectedPhaseFromSelect || selectedPhase;
|
||||||
|
|
||||||
const filtered = window.toolsData.filter(tool => {
|
let filteredTools = window.toolsData;
|
||||||
// Search filter
|
let semanticResults = null;
|
||||||
if (searchTerm && !(
|
|
||||||
tool.name.toLowerCase().includes(searchTerm) ||
|
if (semanticSearchEnabled && semanticSearchAvailable && searchTerm) {
|
||||||
tool.description.toLowerCase().includes(searchTerm) ||
|
semanticResults = await performSemanticSearch(searchTerm);
|
||||||
(tool.tags || []).some(tag => tag.toLowerCase().includes(searchTerm))
|
lastSemanticResults = semanticResults;
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain filter
|
if (semanticResults?.length > 0) {
|
||||||
|
filteredTools = [...semanticResults];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSemanticResults = null;
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
filteredTools = window.toolsData.filter(tool =>
|
||||||
|
tool.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
tool.description.toLowerCase().includes(searchTerm) ||
|
||||||
|
(tool.tags || []).some(tag => tag.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredTools = filteredTools.filter(tool => {
|
||||||
if (selectedDomain && !(tool.domains || []).includes(selectedDomain)) {
|
if (selectedDomain && !(tool.domains || []).includes(selectedDomain)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase filter
|
|
||||||
if (activePhase && !(tool.phases || []).includes(activePhase)) {
|
if (activePhase && !(tool.phases || []).includes(activePhase)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type filter
|
|
||||||
if (selectedType && tool.type !== selectedType) {
|
if (selectedType && tool.type !== selectedType) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill level filter
|
|
||||||
if (selectedSkill && tool.skillLevel !== selectedSkill) {
|
if (selectedSkill && tool.skillLevel !== selectedSkill) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform filter
|
|
||||||
if (selectedPlatform && !(tool.platforms || []).includes(selectedPlatform)) {
|
if (selectedPlatform && !(tool.platforms || []).includes(selectedPlatform)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// License filter - NO MORE HARD-CODED LOGIC
|
|
||||||
if (selectedLicense && tool.license !== selectedLicense) {
|
if (selectedLicense && tool.license !== selectedLicense) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access type filter
|
|
||||||
if (selectedAccess && tool.accessType !== selectedAccess) {
|
if (selectedAccess && tool.accessType !== selectedAccess) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hosted only filter (CC24-Server tools)
|
|
||||||
if (hostedOnly && !isToolHosted(tool)) {
|
if (hostedOnly && !isToolHosted(tool)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Knowledgebase only filter
|
|
||||||
if (knowledgebaseOnly && !tool.knowledgebase) {
|
if (knowledgebaseOnly && !tool.knowledgebase) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag filter
|
|
||||||
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => (tool.tags || []).includes(tag))) {
|
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => (tool.tags || []).includes(tag))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -595,18 +653,31 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply search prioritization if there's a search term
|
if (semanticSearchEnabled && lastSemanticResults) {
|
||||||
const finalResults = searchTerm && window.prioritizeSearchResults
|
filteredTools.sort(
|
||||||
? window.prioritizeSearchResults(filtered, searchTerm)
|
(a, b) => (b._semanticSimilarity || 0) - (a._semanticSimilarity || 0)
|
||||||
: filtered;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResults = semanticSearchEnabled && lastSemanticResults
|
||||||
|
? filteredTools
|
||||||
|
: (searchTerm && window.prioritizeSearchResults
|
||||||
|
? window.prioritizeSearchResults(filteredTools, searchTerm)
|
||||||
|
: filteredTools);
|
||||||
|
|
||||||
updateResultsCounter(finalResults.length);
|
updateResultsCounter(finalResults.length);
|
||||||
|
updateSemanticStatus(lastSemanticResults);
|
||||||
|
|
||||||
// Dispatch event for other components
|
window.dispatchEvent(
|
||||||
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: finalResults }));
|
new CustomEvent('toolsFiltered', {
|
||||||
|
detail: {
|
||||||
|
tools: finalResults,
|
||||||
|
semanticSearch: semanticSearchEnabled && !!lastSemanticResults,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset functions
|
|
||||||
function resetPrimaryFilters() {
|
function resetPrimaryFilters() {
|
||||||
elements.domainSelect.value = '';
|
elements.domainSelect.value = '';
|
||||||
elements.phaseSelect.value = '';
|
elements.phaseSelect.value = '';
|
||||||
@@ -627,7 +698,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
|
|
||||||
function resetTags() {
|
function resetTags() {
|
||||||
selectedTags.clear();
|
selectedTags.clear();
|
||||||
// FIXED: Update ALL tag elements
|
|
||||||
document.querySelectorAll('.tag-cloud-item').forEach(item => {
|
document.querySelectorAll('.tag-cloud-item').forEach(item => {
|
||||||
item.classList.remove('active');
|
item.classList.remove('active');
|
||||||
});
|
});
|
||||||
@@ -638,13 +708,16 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
function resetAllFilters() {
|
function resetAllFilters() {
|
||||||
elements.searchInput.value = '';
|
elements.searchInput.value = '';
|
||||||
elements.clearSearch.classList.add('hidden');
|
elements.clearSearch.classList.add('hidden');
|
||||||
|
elements.semanticCheckbox.checked = false;
|
||||||
|
semanticSearchEnabled = false;
|
||||||
|
lastSemanticResults = null;
|
||||||
|
updateSemanticStatus(null);
|
||||||
resetPrimaryFilters();
|
resetPrimaryFilters();
|
||||||
resetAdvancedFilters();
|
resetAdvancedFilters();
|
||||||
resetTags();
|
resetTags();
|
||||||
filterTagCloud();
|
filterTagCloud();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
elements.searchInput.addEventListener('input', (e) => {
|
elements.searchInput.addEventListener('input', (e) => {
|
||||||
const hasValue = e.target.value.length > 0;
|
const hasValue = e.target.value.length > 0;
|
||||||
elements.clearSearch.classList.toggle('hidden', !hasValue);
|
elements.clearSearch.classList.toggle('hidden', !hasValue);
|
||||||
@@ -659,16 +732,29 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
filterTools();
|
filterTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (elements.semanticCheckbox) {
|
||||||
|
elements.semanticCheckbox.addEventListener('change', (e) => {
|
||||||
|
semanticSearchEnabled = e.target.checked;
|
||||||
|
filterTools();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[elements.domainSelect, elements.phaseSelect, elements.typeSelect, elements.skillSelect,
|
[elements.domainSelect, elements.phaseSelect, elements.typeSelect, elements.skillSelect,
|
||||||
elements.platformSelect, elements.licenseSelect, elements.accessSelect].forEach(select => {
|
elements.platformSelect, elements.licenseSelect, elements.accessSelect].forEach(select => {
|
||||||
select.addEventListener('change', filterTools);
|
if (select) {
|
||||||
|
select.addEventListener('change', filterTools);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
[elements.hostedOnly, elements.knowledgebaseOnly].forEach(checkbox => {
|
[elements.hostedOnly, elements.knowledgebaseOnly].forEach(checkbox => {
|
||||||
checkbox.addEventListener('change', filterTools);
|
if (checkbox) {
|
||||||
|
checkbox.addEventListener('change', filterTools);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.tagCloudToggle.addEventListener('click', toggleTagCloud);
|
if (elements.tagCloudToggle) {
|
||||||
|
elements.tagCloudToggle.addEventListener('click', toggleTagCloud);
|
||||||
|
}
|
||||||
|
|
||||||
elements.tagCloudItems.forEach(item => {
|
elements.tagCloudItems.forEach(item => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
@@ -685,29 +771,36 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const view = btn.getAttribute('data-view');
|
const view = btn.getAttribute('data-view');
|
||||||
|
|
||||||
// Simple toggle like the old version
|
|
||||||
elements.viewToggles.forEach(b => {
|
elements.viewToggles.forEach(b => {
|
||||||
b.classList.toggle('active', b.getAttribute('data-view') === view);
|
b.classList.toggle('active', b.getAttribute('data-view') === view);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('viewChanged', { detail: view }));
|
if (window.switchToView) {
|
||||||
|
window.switchToView(view);
|
||||||
if (view === 'hosted') {
|
|
||||||
const hosted = window.toolsData.filter(tool => isToolHosted(tool));
|
|
||||||
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: hosted }));
|
|
||||||
} else {
|
} else {
|
||||||
filterTools();
|
console.error('switchToView function not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('viewChanged', {
|
||||||
|
detail: view,
|
||||||
|
triggeredByButton: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (view === 'matrix') {
|
||||||
|
filterTools();
|
||||||
|
} else if (view === 'grid') {
|
||||||
|
filterTools();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset button listeners
|
|
||||||
elements.resetButtons.primary.addEventListener('click', resetPrimaryFilters);
|
elements.resetButtons.primary.addEventListener('click', resetPrimaryFilters);
|
||||||
elements.resetButtons.advanced.addEventListener('click', resetAdvancedFilters);
|
elements.resetButtons.advanced.addEventListener('click', resetAdvancedFilters);
|
||||||
elements.resetButtons.tags.addEventListener('click', resetTags);
|
elements.resetButtons.tags.addEventListener('click', resetTags);
|
||||||
elements.resetButtons.all.addEventListener('click', resetAllFilters);
|
elements.resetButtons.all.addEventListener('click', resetAllFilters);
|
||||||
|
|
||||||
// Collapsible toggle listeners
|
|
||||||
elements.toggleAdvanced.addEventListener('click', () => {
|
elements.toggleAdvanced.addEventListener('click', () => {
|
||||||
toggleCollapsible(elements.toggleAdvanced, elements.advancedContent, 'advanced-collapsed');
|
toggleCollapsible(elements.toggleAdvanced, elements.advancedContent, 'advanced-collapsed');
|
||||||
});
|
});
|
||||||
@@ -716,11 +809,10 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
toggleCollapsible(elements.toggleTags, elements.tagContent, 'tags-collapsed');
|
toggleCollapsible(elements.toggleTags, elements.tagContent, 'tags-collapsed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose functions globally for backwards compatibility
|
|
||||||
window.clearTagFilters = resetTags;
|
window.clearTagFilters = resetTags;
|
||||||
window.clearAllFilters = resetAllFilters;
|
window.clearAllFilters = resetAllFilters;
|
||||||
|
|
||||||
// Initialize
|
checkEmbeddingsAvailability();
|
||||||
initializeCollapsible();
|
initializeCollapsible();
|
||||||
initTagCloud();
|
initTagCloud();
|
||||||
filterTagCloud();
|
filterTagCloud();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
//src/components/ToolMatrix.astro
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
import ShareButton from './ShareButton.astro';
|
|
||||||
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
@@ -193,6 +193,15 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
||||||
|
if (!window.isToolHosted) {
|
||||||
|
window.isToolHosted = function(tool) {
|
||||||
|
return tool.projectUrl !== undefined &&
|
||||||
|
tool.projectUrl !== null &&
|
||||||
|
tool.projectUrl !== "" &&
|
||||||
|
tool.projectUrl.trim() !== "";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectedPhase() {
|
function getSelectedPhase() {
|
||||||
const activePhaseChip = document.querySelector('.phase-chip.active');
|
const activePhaseChip = document.querySelector('.phase-chip.active');
|
||||||
return activePhaseChip ? activePhaseChip.getAttribute('data-phase') : '';
|
return activePhaseChip ? activePhaseChip.getAttribute('data-phase') : '';
|
||||||
@@ -216,9 +225,7 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
if (selectedDomain) {
|
if (selectedDomain) {
|
||||||
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
||||||
if (domainRow) {
|
if (domainRow) domainRow.classList.add('highlight-row');
|
||||||
domainRow.classList.add('highlight-row');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedPhase) {
|
if (selectedPhase) {
|
||||||
@@ -231,9 +238,7 @@ domains.forEach((domain: any) => {
|
|||||||
const columnIndex = phaseIndex + 1;
|
const columnIndex = phaseIndex + 1;
|
||||||
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
||||||
const cell = row.children[columnIndex];
|
const cell = row.children[columnIndex];
|
||||||
if (cell) {
|
if (cell) cell.classList.add('highlight-column');
|
||||||
cell.classList.add('highlight-column');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,9 +272,7 @@ domains.forEach((domain: any) => {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('tool', toolSlug);
|
params.set('tool', toolSlug);
|
||||||
params.set('view', view);
|
params.set('view', view);
|
||||||
if (modal) {
|
if (modal) params.set('modal', modal);
|
||||||
params.set('modal', modal);
|
|
||||||
}
|
|
||||||
return `${baseUrl}?${params.toString()}`;
|
return `${baseUrl}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +304,7 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showShareDialog = function(shareButton) {
|
function showShareDialog(shareButton) {
|
||||||
const toolName = shareButton.getAttribute('data-tool-name');
|
const toolName = shareButton.getAttribute('data-tool-name');
|
||||||
const context = shareButton.getAttribute('data-context');
|
const context = shareButton.getAttribute('data-context');
|
||||||
|
|
||||||
@@ -438,16 +441,11 @@ domains.forEach((domain: any) => {
|
|||||||
copyToClipboard(url, btn);
|
copyToClipboard(url, btn);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
function showToolDetails(toolName, modalType = 'primary') {
|
||||||
|
|
||||||
window.showToolDetails = function(toolName, modalType = 'primary') {
|
|
||||||
const tool = toolsData.find(t => t.name === toolName);
|
const tool = toolsData.find(t => t.name === toolName);
|
||||||
if (!tool) {
|
if (!tool) return;
|
||||||
console.error('Tool not found:', toolName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMethod = tool.type === 'method';
|
const isMethod = tool.type === 'method';
|
||||||
const isConcept = tool.type === 'concept';
|
const isConcept = tool.type === 'concept';
|
||||||
@@ -462,10 +460,7 @@ domains.forEach((domain: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, element] of Object.entries(elements)) {
|
for (const [key, element] of Object.entries(elements)) {
|
||||||
if (!element) {
|
if (!element) return;
|
||||||
console.error(`Element not found: tool-${key}-${modalType}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
|
const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
|
||||||
@@ -709,14 +704,13 @@ domains.forEach((domain: any) => {
|
|||||||
if (primaryActive && secondaryActive) {
|
if (primaryActive && secondaryActive) {
|
||||||
document.body.classList.add('modals-side-by-side');
|
document.body.classList.add('modals-side-by-side');
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window.hideToolDetails = function(modalType = 'both') {
|
function hideToolDetails(modalType = 'both') {
|
||||||
const overlay = document.getElementById('modal-overlay');
|
const overlay = document.getElementById('modal-overlay');
|
||||||
const primaryModal = document.getElementById('tool-details-primary');
|
const primaryModal = document.getElementById('tool-details-primary');
|
||||||
const secondaryModal = document.getElementById('tool-details-secondary');
|
const secondaryModal = document.getElementById('tool-details-secondary');
|
||||||
|
|
||||||
// Debounce rapid calls
|
|
||||||
if (window.modalHideInProgress) return;
|
if (window.modalHideInProgress) return;
|
||||||
window.modalHideInProgress = true;
|
window.modalHideInProgress = true;
|
||||||
|
|
||||||
@@ -753,43 +747,53 @@ domains.forEach((domain: any) => {
|
|||||||
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
|
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consolidated state checking with safety checks
|
|
||||||
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
||||||
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
||||||
|
|
||||||
// Update overlay and body classes atomically
|
|
||||||
if (!primaryActive && !secondaryActive) {
|
if (!primaryActive && !secondaryActive) {
|
||||||
if (overlay) overlay.classList.remove('active');
|
if (overlay) overlay.classList.remove('active');
|
||||||
document.body.classList.remove('modals-side-by-side');
|
document.body.classList.remove('modals-side-by-side');
|
||||||
} else if (primaryActive && secondaryActive) {
|
} else if (primaryActive && secondaryActive) {
|
||||||
// Both active - ensure side-by-side class
|
|
||||||
document.body.classList.add('modals-side-by-side');
|
document.body.classList.add('modals-side-by-side');
|
||||||
} else {
|
} else {
|
||||||
// Only one active - remove side-by-side class
|
|
||||||
document.body.classList.remove('modals-side-by-side');
|
document.body.classList.remove('modals-side-by-side');
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window.hideAllToolDetails = function() {
|
function hideAllToolDetails() {
|
||||||
window.hideToolDetails('both');
|
hideToolDetails('both');
|
||||||
};
|
}
|
||||||
|
|
||||||
|
window.showToolDetails = showToolDetails;
|
||||||
|
window.hideToolDetails = hideToolDetails;
|
||||||
|
window.hideAllToolDetails = hideAllToolDetails;
|
||||||
|
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
||||||
|
window.showShareDialog = showShareDialog;
|
||||||
|
|
||||||
|
window.matrixShowToolDetails = showToolDetails;
|
||||||
|
window.matrixHideToolDetails = hideToolDetails;
|
||||||
|
|
||||||
window.addEventListener('viewChanged', (event) => {
|
window.addEventListener('viewChanged', (event) => {
|
||||||
const view = event.detail;
|
const view = event.detail;
|
||||||
if (view === 'matrix') {
|
if (view === 'matrix') {
|
||||||
setTimeout(updateMatrixHighlighting, 100);
|
setTimeout(() => {
|
||||||
|
if (window.filterTools && typeof window.filterTools === 'function') {
|
||||||
|
window.filterTools();
|
||||||
|
} else {
|
||||||
|
const allTools = window.toolsData || [];
|
||||||
|
window.dispatchEvent(new CustomEvent('toolsFiltered', {
|
||||||
|
detail: {
|
||||||
|
tools: allTools,
|
||||||
|
semanticSearch: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('toolsFiltered', (event) => {
|
window.addEventListener('toolsFiltered', (event) => {
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const { tools: filtered, semanticSearch } = event.detail;
|
||||||
if (currentView === 'matrix') {
|
|
||||||
setTimeout(updateMatrixHighlighting, 50);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('toolsFiltered', (event) => {
|
|
||||||
const filtered = event.detail;
|
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
||||||
|
|
||||||
if (currentView === 'matrix') {
|
if (currentView === 'matrix') {
|
||||||
@@ -798,13 +802,6 @@ domains.forEach((domain: any) => {
|
|||||||
const domainAgnosticPhaseIds = domainAgnosticSoftware.map(section => section.id);
|
const domainAgnosticPhaseIds = domainAgnosticSoftware.map(section => section.id);
|
||||||
const isDomainAgnosticPhase = domainAgnosticPhaseIds.includes(selectedPhase);
|
const isDomainAgnosticPhase = domainAgnosticPhaseIds.includes(selectedPhase);
|
||||||
|
|
||||||
domainAgnosticSoftware.forEach(sectionData => {
|
|
||||||
const section = document.getElementById(`domain-agnostic-section-${sectionData.id}`);
|
|
||||||
const container = document.getElementById(`domain-agnostic-tools-${sectionData.id}`);
|
|
||||||
|
|
||||||
if (!section || !container) return;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isDomainAgnosticPhase) {
|
if (!isDomainAgnosticPhase) {
|
||||||
document.getElementById('dfir-matrix-section').style.display = 'block';
|
document.getElementById('dfir-matrix-section').style.display = 'block';
|
||||||
|
|
||||||
@@ -813,9 +810,7 @@ domains.forEach((domain: any) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
filtered.forEach(tool => {
|
filtered.forEach(tool => {
|
||||||
if (tool.type === 'concept') {
|
if (tool.type === 'concept') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMethod = tool.type === 'method';
|
const isMethod = tool.type === 'method';
|
||||||
const hasValidProjectUrl = window.isToolHosted(tool);
|
const hasValidProjectUrl = window.isToolHosted(tool);
|
||||||
@@ -832,6 +827,7 @@ domains.forEach((domain: any) => {
|
|||||||
hasValidProjectUrl ? 'tool-chip-hosted' :
|
hasValidProjectUrl ? 'tool-chip-hosted' :
|
||||||
tool.license !== 'Proprietary' ? 'tool-chip-oss' : '';
|
tool.license !== 'Proprietary' ? 'tool-chip-oss' : '';
|
||||||
chip.className = `tool-chip ${chipClass}`;
|
chip.className = `tool-chip ${chipClass}`;
|
||||||
|
chip.setAttribute('data-tool-name', tool.name);
|
||||||
chip.setAttribute('title', `${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`);
|
chip.setAttribute('title', `${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`);
|
||||||
chip.innerHTML = `${tool.name}${tool.knowledgebase === true ? '<span style="margin-left: 0.25rem; font-size: 0.6875rem;">📖</span>' : ''}`;
|
chip.innerHTML = `${tool.name}${tool.knowledgebase === true ? '<span style="margin-left: 0.25rem; font-size: 0.6875rem;">📖</span>' : ''}`;
|
||||||
chip.onclick = () => window.showToolDetails(tool.name);
|
chip.onclick = () => window.showToolDetails(tool.name);
|
||||||
|
|||||||
364
src/config/prompts.ts
Normal file
364
src/config/prompts.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// src/config/prompts.ts
|
||||||
|
|
||||||
|
const RELEVANCE_RUBRIC = `
|
||||||
|
TASK RELEVANCE (INTEGER 0–100, NO %):
|
||||||
|
- 55–65 = Basis/ok
|
||||||
|
- 66–75 = Gut geeignet
|
||||||
|
- 76–85 = Sehr gut geeignet
|
||||||
|
- >85 = Nur bei nahezu perfekter Übereinstimmung
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const STRICTNESS = `
|
||||||
|
STRICTNESS:
|
||||||
|
- Output MUST be pure JSON (no prose, no code fences, no trailing commas).
|
||||||
|
- Use EXACT item names as provided (casing/spelling must match).
|
||||||
|
- Do NOT invent items or fields. If unsure, select fewer.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const AI_PROMPTS = {
|
||||||
|
enhancementQuestions: (input: string) => {
|
||||||
|
return `Sie sind DFIR-Experte. Ein Nutzer beschreibt unten ein Szenario/Problem.
|
||||||
|
|
||||||
|
ZIEL:
|
||||||
|
- Stellen Sie NUR dann 1–3 präzise Rückfragen, wenn entscheidende forensische Lücken die weitere Analyse/Toolauswahl PHASENREIHENFOLGE oder EVIDENCE-STRATEGIE wesentlich beeinflussen würden.
|
||||||
|
- Wenn ausreichend abgedeckt: Geben Sie eine leere Liste [] zurück.
|
||||||
|
|
||||||
|
PRIORITÄT DER THEMEN (in dieser Reihenfolge prüfen):
|
||||||
|
1) Available Evidence & Artefakte (z.B. RAM-Dump, Disk-Image, Logs, PCAP, Registry, Cloud/Audit-Logs)
|
||||||
|
2) Scope/Systems (konkrete Plattformen/Assets/Identitäten/Netzsegmente)
|
||||||
|
3) Investigation Objectives (Ziele: IOC-Extraktion, Timeline, Impact, Attribution)
|
||||||
|
4) Timeline/Timeframe (kritische Zeitfenster, Erhalt flüchtiger Daten)
|
||||||
|
5) Legal & Compliance (Chain of Custody, Aufbewahrung, DSGVO/Branchenvorgaben)
|
||||||
|
6) Technical Constraints (Ressourcen, Zugriffsrechte, Tooling/EDR)
|
||||||
|
|
||||||
|
FRAGEN-QUALITÄT:
|
||||||
|
- Forensisch spezifisch und entscheidungsrelevant (keine Allgemeinplätze).
|
||||||
|
- Eine Frage pro Thema, keine Dopplungen.
|
||||||
|
- Antwortbar vom Nutzer (keine Spekulation, keine “Beweise senden”-Aufforderungen).
|
||||||
|
- Maximal 18 Wörter, endet mit "?".
|
||||||
|
|
||||||
|
VALIDIERUNG:
|
||||||
|
- Stellen Sie NUR Fragen zu Themen, die im Nutzertext NICHT hinreichend konkret beantwortet sind (keine Wiederholung bereits gegebener Details).
|
||||||
|
- Wenn alle priorisierten Themen ausreichend sind → [].
|
||||||
|
|
||||||
|
ANTWORTFORMAT (NUR JSON, KEIN ZUSÄTZLICHER TEXT):
|
||||||
|
[
|
||||||
|
"präzise Frage 1?",
|
||||||
|
"präzise Frage 2?",
|
||||||
|
"präzise Frage 3?"
|
||||||
|
]
|
||||||
|
|
||||||
|
NUTZER-EINGABE:
|
||||||
|
${input}`.trim();
|
||||||
|
},
|
||||||
|
toolSelection: (mode: string, userQuery: string, maxSelectedItems: number) => {
|
||||||
|
const modeInstruction =
|
||||||
|
mode === 'workflow'
|
||||||
|
? 'Workflow mit 15–25 Items über alle Phasen. Pflicht: ~40% Methoden, Rest Software/Konzepte (falls verfügbar).'
|
||||||
|
: 'Spezifische Lösung mit 4–10 Items. Pflicht: ≥30% Methoden (falls verfügbar).';
|
||||||
|
|
||||||
|
return `Du bist DFIR-Experte. Wähle die BESTEN Items aus dem bereits semantisch vorgefilterten Set für die konkrete Aufgabe.
|
||||||
|
|
||||||
|
${modeInstruction}
|
||||||
|
|
||||||
|
ANFRAGE: "${userQuery}"
|
||||||
|
|
||||||
|
ITEM-TYPEN:
|
||||||
|
- TOOLS (type: "software" | "method")
|
||||||
|
- KONZEPTE (type: "concept")
|
||||||
|
|
||||||
|
AUSWAHLPRINZIPIEN:
|
||||||
|
1) Relevanz zur Anfrage (direkt anwendbar, adressiert Kernproblem)
|
||||||
|
2) Ausgewogene Mischung (Praxis: selectedTools; Methodik: selectedConcepts)
|
||||||
|
3) Qualität > Quantität (lieber weniger, dafür passgenau)
|
||||||
|
4) Keine Erfindungen. Wenn etwas nicht passt, wähle weniger.
|
||||||
|
|
||||||
|
AUSWAHLREGELN:
|
||||||
|
- Wähle ${mode === 'workflow' ? '15–25' : '4–10'} Items total (max ${maxSelectedItems})
|
||||||
|
- Fülle BEIDE Arrays: selectedTools UND selectedConcepts
|
||||||
|
- Mindestens 1–2 Konzepte (falls verfügbar)
|
||||||
|
- Bevorzugt ~40% Methoden (Workflow) bzw. ≥30% Methoden (Tool-Modus), sofern vorhanden
|
||||||
|
- Sortiere selectedTools grob nach Eignung (bestes zuerst)
|
||||||
|
|
||||||
|
Skalenhinweis (für spätere Schritte – einheitlich):
|
||||||
|
${RELEVANCE_RUBRIC}
|
||||||
|
|
||||||
|
${STRICTNESS}
|
||||||
|
|
||||||
|
ANTWORT (NUR JSON):
|
||||||
|
{
|
||||||
|
"selectedTools": ["ToolName1", "MethodName1", "..."],
|
||||||
|
"selectedConcepts": ["ConceptName1", "ConceptName2", "..."],
|
||||||
|
"reasoning": "Sehr kurz: Balance/Abdeckung begründen"
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
toolSelectionWithData: (basePrompt: string, toolsToSend: any[], conceptsToSend: any[]) => {
|
||||||
|
return `${basePrompt}
|
||||||
|
|
||||||
|
VERFÜGBARE TOOLS (${toolsToSend.length}):
|
||||||
|
${JSON.stringify(toolsToSend, null, 2)}
|
||||||
|
|
||||||
|
VERFÜGBARE KONZEPTE (${conceptsToSend.length}):
|
||||||
|
${JSON.stringify(conceptsToSend, null, 2)}
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
- Wähle nur aus obigen Listen. Keine neuen Namen.
|
||||||
|
- Nutze exakte Namen. Keine Synonyme/Varianten.
|
||||||
|
|
||||||
|
Hinweis zur einheitlichen Relevanz-Skala:
|
||||||
|
${RELEVANCE_RUBRIC}
|
||||||
|
|
||||||
|
${STRICTNESS}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
scenarioAnalysis: (isWorkflow: boolean, userQuery: string) => {
|
||||||
|
const analysisType = isWorkflow ? 'Szenario' : 'Problem';
|
||||||
|
const focus = isWorkflow
|
||||||
|
? 'Angriffsvektoren, betroffene Systeme, Zeitkritikalität'
|
||||||
|
: 'Kernherausforderung, verfügbare Daten, methodische Anforderungen';
|
||||||
|
|
||||||
|
return `DFIR-Experte: Analysiere das ${analysisType}.
|
||||||
|
|
||||||
|
${isWorkflow ? 'SZENARIO' : 'PROBLEM'}: "${userQuery}"
|
||||||
|
Fokus: ${focus}
|
||||||
|
|
||||||
|
Antwort: Fließtext, max 100 Wörter. Keine Liste, keine Einleitung.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
investigationApproach: (isWorkflow: boolean, userQuery: string) => {
|
||||||
|
const approachType = isWorkflow ? 'Untersuchungsansatz' : 'Lösungsansatz';
|
||||||
|
const focus = isWorkflow
|
||||||
|
? 'Triage-Prioritäten, Phasenabfolge, Kontaminationsvermeidung'
|
||||||
|
: 'Methodenauswahl, Validierung, Integration';
|
||||||
|
|
||||||
|
return `Entwickle einen ${approachType}.
|
||||||
|
|
||||||
|
${isWorkflow ? 'SZENARIO' : 'PROBLEM'}: "${userQuery}"
|
||||||
|
Fokus: ${focus}
|
||||||
|
|
||||||
|
Antwort: Fließtext, max 100 Wörter.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
criticalConsiderations: (isWorkflow: boolean, userQuery: string) => {
|
||||||
|
const focus = isWorkflow
|
||||||
|
? 'Beweissicherung vs. Gründlichkeit, Chain of Custody'
|
||||||
|
: 'Tool-Validierung, False Positives/Negatives, Qualifikationen';
|
||||||
|
|
||||||
|
return `Identifiziere kritische Überlegungen.
|
||||||
|
|
||||||
|
${isWorkflow ? 'SZENARIO' : 'PROBLEM'}: "${userQuery}"
|
||||||
|
Fokus: ${focus}
|
||||||
|
|
||||||
|
Antwort: Fließtext, max 100 Wörter.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
phaseToolSelection: (userQuery: string, phase: any, phaseTools: any[]) => {
|
||||||
|
const methods = phaseTools.filter(t => t.type === 'method');
|
||||||
|
const tools = phaseTools.filter(t => t.type === 'software');
|
||||||
|
|
||||||
|
if (phaseTools.length === 0) {
|
||||||
|
return `Keine Methoden/Tools für Phase "${phase.name}" verfügbar. Antworte mit leerem Array: []`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Wähle die 2–3 BESTEN Items für Phase "${phase.name}".
|
||||||
|
|
||||||
|
SZENARIO: "${userQuery}"
|
||||||
|
PHASE: ${phase.name} — ${phase.description || ''}
|
||||||
|
|
||||||
|
VERFÜGBARE ITEMS:
|
||||||
|
${methods.length > 0 ? `
|
||||||
|
METHODEN (${methods.length}):
|
||||||
|
${methods.map((m: any) =>
|
||||||
|
`- ${m.name}
|
||||||
|
Typ: ${m.type}
|
||||||
|
Beschreibung: ${m.description}
|
||||||
|
Domains: ${m.domains?.join(', ') || 'N/A'}
|
||||||
|
Skill Level: ${m.skillLevel}`
|
||||||
|
).join('\n\n')}
|
||||||
|
` : 'Keine Methoden verfügbar'}
|
||||||
|
|
||||||
|
${tools.length > 0 ? `
|
||||||
|
SOFTWARE (${tools.length}):
|
||||||
|
${tools.map((t: any) =>
|
||||||
|
`- ${t.name}
|
||||||
|
Typ: ${t.type}
|
||||||
|
Beschreibung: ${t.description}
|
||||||
|
Plattformen: ${t.platforms?.join(', ') || 'N/A'}
|
||||||
|
Skill Level: ${t.skillLevel}`
|
||||||
|
).join('\n\n')}
|
||||||
|
` : 'Keine Software-Tools verfügbar'}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
1) 2–3 Items, direkt phasenrelevant; mind. 1 Methode, falls verfügbar
|
||||||
|
2) Begründung pro Item (präzise, anwendungsbezogen)
|
||||||
|
3) Verwende EXAKTE Namen aus den Listen. Keine Erfindungen.
|
||||||
|
|
||||||
|
${RELEVANCE_RUBRIC}
|
||||||
|
|
||||||
|
${STRICTNESS}
|
||||||
|
|
||||||
|
ANTWORT (NUR JSON):
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"toolName": "Exakter Name",
|
||||||
|
"taskRelevance": 0,
|
||||||
|
"justification": "60–80 Wörter zur phasenspezifischen Eignung",
|
||||||
|
"limitations": ["Optionale spezifische Einschränkung"]
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
},
|
||||||
|
|
||||||
|
toolEvaluation: (userQuery: string, tool: any, rank: number) => {
|
||||||
|
const itemType = tool.type === 'method' ? 'Methode' : 'Tool';
|
||||||
|
|
||||||
|
return `Bewerte diese/diesen ${itemType} ausschließlich bzgl. des PROBLEMS.
|
||||||
|
|
||||||
|
PROBLEM: "${userQuery}"
|
||||||
|
${itemType.toUpperCase()}: ${tool.name}
|
||||||
|
TYP: ${tool.type}
|
||||||
|
|
||||||
|
ANWEISUNGEN:
|
||||||
|
- Nur vorhandene Metadaten nutzen (keine Annahmen, keine Websuche).
|
||||||
|
- "taskRelevance" als GANZZAHL 0–100 nach einheitlicher Skala vergeben.
|
||||||
|
- Realistische Scores i.d.R. 60–80, >85 nur bei nahezu perfektem Fit.
|
||||||
|
- Keine Texte außerhalb des JSON.
|
||||||
|
|
||||||
|
${RELEVANCE_RUBRIC}
|
||||||
|
${STRICTNESS}
|
||||||
|
|
||||||
|
ANTWORT (NUR JSON):
|
||||||
|
{
|
||||||
|
"detailed_explanation": "Warum und wie einsetzen",
|
||||||
|
"implementation_approach": "Konkrete Schritte",
|
||||||
|
"pros": ["Vorteil 1", "Vorteil 2"],
|
||||||
|
"limitations": ["Einschränkung 1"],
|
||||||
|
"alternatives": "Kurz zu sinnvollen Alternativen",
|
||||||
|
"taskRelevance": 0
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
backgroundKnowledgeSelection: (userQuery: string, mode: string, selectedToolNames: string[], availableConcepts: any[]) => {
|
||||||
|
return `Wähle 2–4 Konzepte, die das Verständnis/den Einsatz der ausgewählten Tools verbessern.
|
||||||
|
|
||||||
|
${mode === 'workflow' ? 'SZENARIO' : 'PROBLEM'}: "${userQuery}"
|
||||||
|
AUSGEWÄHLTE TOOLS: ${selectedToolNames.join(', ')}
|
||||||
|
|
||||||
|
VERFÜGBARE KONZEPTE (${availableConcepts.length}):
|
||||||
|
${availableConcepts.map((c: any) => `- ${c.name}: ${c.description}...`).join('\n')}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Nur Konzepte aus obiger Liste wählen.
|
||||||
|
- Relevanz kurz und konkret begründen.
|
||||||
|
|
||||||
|
${STRICTNESS}
|
||||||
|
|
||||||
|
ANTWORT (NUR JSON):
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"conceptName": "Exakter Name",
|
||||||
|
"relevance": "Warum dieses Konzept hier methodisch wichtig ist"
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
},
|
||||||
|
|
||||||
|
phaseCompletionReasoning: (
|
||||||
|
originalQuery: string,
|
||||||
|
phase: any,
|
||||||
|
selectedToolName: string,
|
||||||
|
tool: any,
|
||||||
|
completionContext: string
|
||||||
|
) => {
|
||||||
|
return `Begründe knapp die Nachergänzung für Phase "${phase.name}".
|
||||||
|
|
||||||
|
URSPRÜNGLICHE ANFRAGE: "${originalQuery}"
|
||||||
|
PHASE: ${phase.name} — ${phase.description || ''}
|
||||||
|
HINZUGEFÜGTES TOOL: ${selectedToolName} (${tool.type})
|
||||||
|
KONTEXT: ${completionContext}
|
||||||
|
|
||||||
|
Antwort: Prägnanter Fließtext, max 40 Wörter, keine Einleitung, keine Liste.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePhaseCompletionPrompt(
|
||||||
|
originalQuery: string,
|
||||||
|
phase: any,
|
||||||
|
candidateTools: any[],
|
||||||
|
candidateConcepts: any[]
|
||||||
|
): string {
|
||||||
|
return `Unterrepräsentierte Phase: "${phase.name}". Ergänze 1–2 passende Items aus der semantischen Nachsuche.
|
||||||
|
|
||||||
|
ORIGINALANFRAGE: "${originalQuery}"
|
||||||
|
PHASE: ${phase.name} — ${phase.description || ''}
|
||||||
|
|
||||||
|
KANDIDATEN — TOOLS (${candidateTools.length}):
|
||||||
|
${candidateTools.map((t: any) => `
|
||||||
|
- ${t.name} (${t.type})
|
||||||
|
Beschreibung: ${t.description}
|
||||||
|
Skill Level: ${t.skillLevel}
|
||||||
|
`).join('')}
|
||||||
|
|
||||||
|
${candidateConcepts.length > 0 ? `
|
||||||
|
KANDIDATEN — KONZEPTE (${candidateConcepts.length}):
|
||||||
|
${candidateConcepts.map((c: any) => `
|
||||||
|
- ${c.name}
|
||||||
|
Beschreibung: ${c.description}
|
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Wähle 1–2 Tools/Methoden, die ${phase.name} sinnvoll ergänzen (keine Ersetzung).
|
||||||
|
- Nur aus obigen Kandidaten wählen; exakte Namen verwenden.
|
||||||
|
- Kurze Begründung, warum diese Ergänzung nötig ist.
|
||||||
|
|
||||||
|
Skalenhinweis (einheitlich):
|
||||||
|
${RELEVANCE_RUBRIC}
|
||||||
|
|
||||||
|
${STRICTNESS}
|
||||||
|
|
||||||
|
ANTWORT (NUR JSON):
|
||||||
|
{
|
||||||
|
"selectedTools": ["ToolName1", "ToolName2"],
|
||||||
|
"selectedConcepts": ["ConceptName1"],
|
||||||
|
"completionReasoning": "Kurze Erklärung zur Ergänzung der ${phase.name}-Phase"
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
finalRecommendations: (isWorkflow: boolean, userQuery: string, selectedToolNames: string[]) => {
|
||||||
|
const focus = isWorkflow
|
||||||
|
? 'Knappe Workflow-Schritte & Best Practices; neutral formulieren'
|
||||||
|
: 'Methodische Überlegungen, Validierung, Qualitätssicherung';
|
||||||
|
|
||||||
|
return `Erstelle ${isWorkflow ? 'Workflow-Empfehlung' : 'methodische Überlegungen'}.
|
||||||
|
|
||||||
|
${isWorkflow ? 'SZENARIO' : 'PROBLEM'}: "${userQuery}"
|
||||||
|
AUSGEWÄHLT: ${selectedToolNames.join(', ')}${selectedToolNames.length > 5 ? '...' : ''}
|
||||||
|
|
||||||
|
Fokus: ${focus}
|
||||||
|
Antwort: Fließtext, max ${isWorkflow ? '100' : '80'} Wörter. Keine Liste.`;
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getPrompt(key: 'enhancementQuestions', input: string): string;
|
||||||
|
export function getPrompt(key: 'toolSelection', mode: string, userQuery: string, maxSelectedItems: number): string;
|
||||||
|
export function getPrompt(key: 'toolSelectionWithData', basePrompt: string, toolsToSend: any[], conceptsToSend: any[]): string;
|
||||||
|
export function getPrompt(key: 'scenarioAnalysis', isWorkflow: boolean, userQuery: string): string;
|
||||||
|
export function getPrompt(key: 'investigationApproach', isWorkflow: boolean, userQuery: string): string;
|
||||||
|
export function getPrompt(key: 'criticalConsiderations', isWorkflow: boolean, userQuery: string): string;
|
||||||
|
export function getPrompt(key: 'phaseToolSelection', userQuery: string, phase: any, phaseTools: any[]): string;
|
||||||
|
export function getPrompt(key: 'toolEvaluation', userQuery: string, tool: any, rank: number): string;
|
||||||
|
export function getPrompt(key: 'backgroundKnowledgeSelection', userQuery: string, mode: string, selectedToolNames: string[], availableConcepts: any[]): string;
|
||||||
|
export function getPrompt(key: 'phaseCompletionReasoning', originalQuery: string, phase: any, selectedToolName: string, tool: any, completionContext: string): string;
|
||||||
|
export function getPrompt(key: 'finalRecommendations', isWorkflow: boolean, userQuery: string, selectedToolNames: string[]): string;
|
||||||
|
export function getPrompt(key: 'generatePhaseCompletionPrompt', originalQuery: string, phase: any, candidateTools: any[], candidateConcepts: any[]): string;
|
||||||
|
|
||||||
|
export function getPrompt(promptKey: keyof typeof AI_PROMPTS, ...args: any[]): string {
|
||||||
|
try {
|
||||||
|
const f = AI_PROMPTS[promptKey];
|
||||||
|
if (typeof f === 'function') return (f as (...a: any[]) => string)(...args);
|
||||||
|
console.error(`[PROMPTS] Invalid prompt key: ${promptKey}`);
|
||||||
|
return 'Error: Invalid prompt configuration';
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PROMPTS] Error generating prompt ${promptKey}:`, err);
|
||||||
|
return 'Error: Failed to generate prompt';
|
||||||
|
}
|
||||||
|
}
|
||||||
501
src/content/README.md
Normal file
501
src/content/README.md
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
# Manuell hinzufügen
|
||||||
|
|
||||||
|
Hier müssen Artikel, die eingebettet werden sollen, manuell abgespeichert werden.
|
||||||
|
Da diese anders lizensiert sein können, sind sie nicht Bestandteil des Open-Source-Repositorys.
|
||||||
|
|
||||||
|
**Artikel-Quelle:** https://cloud.cc24.dev/f/47971 (Interner Nextcloud-Share)
|
||||||
|
|
||||||
|
Bei Bedarf bitte Kontakt aufnehmen mit mstoeck3@hs-mittweida.de.
|
||||||
|
|
||||||
|
# Artikel-Schema
|
||||||
|
|
||||||
|
## Dateistruktur
|
||||||
|
|
||||||
|
Alle Artikel müssen als Markdown-Dateien im Format `src/content/knowledgebase/` gespeichert werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/content/knowledgebase/
|
||||||
|
├── tool-autopsy-grundlagen.md
|
||||||
|
├── tool-volatility-memory-analysis.md
|
||||||
|
├── method-timeline-analysis.md
|
||||||
|
└── concept-hash-functions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namenskonventionen
|
||||||
|
|
||||||
|
- **Tool-Artikel**: `tool-{toolname}-{topic}.md`
|
||||||
|
- **Methoden-Artikel**: `method-{methodname}-{topic}.md`
|
||||||
|
- **Konzept-Artikel**: `concept-{conceptname}-{topic}.md`
|
||||||
|
|
||||||
|
## Frontmatter-Schema
|
||||||
|
|
||||||
|
Jeder Artikel muss einen YAML-Frontmatter-Header mit folgender Struktur haben:
|
||||||
|
|
||||||
|
### Pflichtfelder
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Titel des Artikels"
|
||||||
|
description: "Kurze Beschreibung für die Übersicht und SEO"
|
||||||
|
last_updated: 2024-01-15 # Datum im YYYY-MM-DD Format
|
||||||
|
author: "Name des Autors"
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optionale Felder
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
# Tool-Verknüpfung
|
||||||
|
tool_name: "Autopsy" # Exakter Name aus tools.yaml
|
||||||
|
related_tools:
|
||||||
|
- "Volatility 3"
|
||||||
|
- "YARA"
|
||||||
|
|
||||||
|
# Klassifizierung
|
||||||
|
difficulty: "intermediate" # novice, beginner, intermediate, advanced, expert
|
||||||
|
categories:
|
||||||
|
- "Tutorial"
|
||||||
|
- "Best Practices"
|
||||||
|
tags:
|
||||||
|
- "memory-analysis"
|
||||||
|
- "malware"
|
||||||
|
- "windows"
|
||||||
|
|
||||||
|
# Zugriffskontrolle
|
||||||
|
gated_content: false # true = Authentifizierung erforderlich
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vollständiges Beispiel
|
||||||
|
|
||||||
|
**Dateiname:** `tool-volatility-memory-analysis-grundlagen.md`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Volatility 3 Memory Analysis Grundlagen"
|
||||||
|
description: "Einführung in die RAM-Analyse mit Volatility 3 für Windows-Systeme"
|
||||||
|
last_updated: 2024-01-15
|
||||||
|
tool_name: "Volatility 3"
|
||||||
|
related_tools:
|
||||||
|
- "Autopsy"
|
||||||
|
- "YARA"
|
||||||
|
author: "Max Mustermann"
|
||||||
|
difficulty: "intermediate"
|
||||||
|
categories:
|
||||||
|
- "Tutorial"
|
||||||
|
- "Memory Analysis"
|
||||||
|
tags:
|
||||||
|
- "volatility"
|
||||||
|
- "memory-dump"
|
||||||
|
- "malware-analysis"
|
||||||
|
- "incident-response"
|
||||||
|
published: true
|
||||||
|
gated_content: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Volatility 3 Memory Analysis Grundlagen
|
||||||
|
|
||||||
|
Dieses Tutorial zeigt die Grundlagen der Speicher-Analyse...
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Volatility 3 kann über pip installiert werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install volatility3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
### Memory Dump laden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vol -f memory.dmp windows.info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prozesse auflisten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vol -f memory.dmp windows.pslist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video-Demonstration
|
||||||
|
|
||||||
|
<video src="/videos/volatility-basics.mp4" title="Volatility Grundlagen Tutorial" controls preload="metadata"></video>
|
||||||
|
|
||||||
|
## Weiterführende Links
|
||||||
|
|
||||||
|
- [Offizielle Dokumentation](https://volatility3.readthedocs.io/)
|
||||||
|
- [Cheat Sheet](/downloads/volatility-cheat-sheet.pdf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content-Features
|
||||||
|
|
||||||
|
### Markdown-Unterstützung
|
||||||
|
|
||||||
|
- Standard Markdown-Syntax
|
||||||
|
- Code-Blöcke mit Syntax-Highlighting
|
||||||
|
- Tabellen
|
||||||
|
- Listen und verschachtelte Inhalte
|
||||||
|
- Automatische Inhaltsverzeichnis-Generierung
|
||||||
|
|
||||||
|
### Video-Einbindung
|
||||||
|
|
||||||
|
Videos können direkt in Markdown eingebettet werden und werden automatisch mit responsiven Containern erweitert:
|
||||||
|
|
||||||
|
#### Basis-Video-Einbindung
|
||||||
|
|
||||||
|
```html
|
||||||
|
<video src="/videos/demo.mp4" title="Tool-Demonstration" controls></video>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Vollständige Video-Konfiguration
|
||||||
|
|
||||||
|
```html
|
||||||
|
<video
|
||||||
|
src="/videos/advanced-tutorial.mp4"
|
||||||
|
title="Erweiterte Analysefunktionen"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
width="720"
|
||||||
|
height="405"
|
||||||
|
muted
|
||||||
|
poster="/images/video-thumbnail.jpg"
|
||||||
|
>
|
||||||
|
<p>Ihr Browser unterstützt das Video-Element nicht.</p>
|
||||||
|
</video>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Unterstützte Video-Attribute
|
||||||
|
|
||||||
|
**Basis-Attribute:**
|
||||||
|
- `src`: **Erforderlich** - Pfad zur Videodatei (relativ zu `/public/`)
|
||||||
|
- `title`: **Empfohlen** - Beschreibung für Metadaten und Accessibility
|
||||||
|
- `controls`: Zeigt Player-Steuerung (Standard-Empfehlung)
|
||||||
|
|
||||||
|
**Erweiterte Attribute:**
|
||||||
|
- `autoplay`: Automatisches Abspielen (nicht empfohlen für UX)
|
||||||
|
- `muted`: Stummgeschaltet (erforderlich für Autoplay in den meisten Browsern)
|
||||||
|
- `loop`: Endlosschleife
|
||||||
|
- `preload`: `"none"` | `"metadata"` | `"auto"` (Standard: `"metadata"`)
|
||||||
|
- `poster`: Vorschaubild-URL
|
||||||
|
- `width`/`height`: Feste Dimensionen (Optional, responsive Container anpasst sich automatisch)
|
||||||
|
|
||||||
|
**Accessibility-Attribute:**
|
||||||
|
- `aria-label`: Alternative Beschreibung
|
||||||
|
- `aria-describedby`: ID eines Elements mit detaillierter Beschreibung
|
||||||
|
|
||||||
|
#### iframe-Einbindung (YouTube, Vimeo, etc.)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube.com/embed/VIDEO_ID"
|
||||||
|
title="YouTube-Tutorial: Forensic Analysis mit Tool XYZ"
|
||||||
|
width="560"
|
||||||
|
height="315"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
**iframe-Attribute:**
|
||||||
|
- `src`: **Erforderlich** - Embed-URL des Video-Dienstes
|
||||||
|
- `title`: **Erforderlich** - Beschreibung für Accessibility
|
||||||
|
- `width`/`height`: Empfohlene Dimensionen (werden responsive angepasst)
|
||||||
|
- `frameborder`: Auf `"0"` setzen für modernen Look
|
||||||
|
- `allowfullscreen`: Vollbild-Modus erlauben
|
||||||
|
- `loading`: `"lazy"` für Performance-Optimierung
|
||||||
|
|
||||||
|
### Automatische Video-Verarbeitung
|
||||||
|
|
||||||
|
Das System erweitert Video-Tags automatisch:
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```html
|
||||||
|
<video src="/videos/demo.mp4" title="Demo" controls></video>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output (automatisch generiert):**
|
||||||
|
```html
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
src="/videos/demo.mp4"
|
||||||
|
title="Demo"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
data-video-title="Demo"
|
||||||
|
>
|
||||||
|
<p>Your browser does not support the video element.</p>
|
||||||
|
</video>
|
||||||
|
<div class="video-metadata">
|
||||||
|
<div class="video-title">Demo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firefox-Kompatibilität
|
||||||
|
|
||||||
|
**Wichtiger Hinweis:** Videos müssen in Firefox-kompatiblen Formaten bereitgestellt werden:
|
||||||
|
|
||||||
|
#### Empfohlene Formate
|
||||||
|
|
||||||
|
**Primäre Formate (höchste Kompatibilität):**
|
||||||
|
- **MP4 (H.264/AVC)**: `.mp4` - Beste Kompatibilität across Browser
|
||||||
|
- **WebM (VP8/VP9)**: `.webm` - Moderne Browser, gute Kompression
|
||||||
|
|
||||||
|
**Sekundäre Formate:**
|
||||||
|
- **OGG Theora**: `.ogv` - Fallback für ältere Firefox-Versionen
|
||||||
|
|
||||||
|
#### Format-Konvertierung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit ffmpeg zu Firefox-kompatiblem MP4 konvertieren
|
||||||
|
ffmpeg -i input.mov -c:v libx264 -c:a aac -movflags +faststart output.mp4
|
||||||
|
|
||||||
|
# Mit ffmpeg zu WebM konvertieren
|
||||||
|
ffmpeg -i input.mov -c:v libvpx-vp9 -c:a libvorbis output.webm
|
||||||
|
|
||||||
|
# Multi-Format-Bereitstellung
|
||||||
|
<video title="Demo" controls>
|
||||||
|
<source src="/videos/demo.mp4" type="video/mp4">
|
||||||
|
<source src="/videos/demo.webm" type="video/webm">
|
||||||
|
<p>Ihr Browser unterstützt das Video-Element nicht.</p>
|
||||||
|
</video>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Firefox-spezifische Probleme
|
||||||
|
|
||||||
|
Das System erkennt automatisch Firefox und implementiert Error-Recovery:
|
||||||
|
|
||||||
|
- **Automatische Fehlererkennung** für nicht unterstützte Formate
|
||||||
|
- **Fallback-Mechanismen** bei Codec-Problemen
|
||||||
|
- **Erweiterte Logging** für Debugging
|
||||||
|
|
||||||
|
**Bekannte Firefox-Probleme:**
|
||||||
|
- H.265/HEVC nicht unterstützt
|
||||||
|
- Proprietäre Codecs teilweise eingeschränkt
|
||||||
|
- MIME-Type-Sensitivität höher als bei Chrome
|
||||||
|
|
||||||
|
### Video-Datei-Management
|
||||||
|
|
||||||
|
#### Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
public/
|
||||||
|
├── videos/
|
||||||
|
│ ├── tools/
|
||||||
|
│ │ ├── autopsy-basics.mp4
|
||||||
|
│ │ ├── volatility-tutorial.webm
|
||||||
|
│ │ └── yara-rules-demo.mp4
|
||||||
|
│ ├── methods/
|
||||||
|
│ │ ├── timeline-analysis.mp4
|
||||||
|
│ │ └── disk-imaging.mp4
|
||||||
|
│ └── concepts/
|
||||||
|
│ ├── hash-functions.mp4
|
||||||
|
│ └── chain-custody.mp4
|
||||||
|
└── images/
|
||||||
|
└── video-thumbnails/
|
||||||
|
├── autopsy-thumb.jpg
|
||||||
|
└── volatility-thumb.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dateigröße-Empfehlungen
|
||||||
|
|
||||||
|
- **Streaming-Qualität**: 5-15 MB/Minute (720p)
|
||||||
|
- **High-Quality Tutorials**: 20-40 MB/Minute (1080p)
|
||||||
|
- **Mobile-Optimiert**: 2-8 MB/Minute (480p)
|
||||||
|
|
||||||
|
#### Konventionen
|
||||||
|
|
||||||
|
**Dateinamen:**
|
||||||
|
- Lowercase mit Bindestrichen: `tool-autopsy-installation.mp4`
|
||||||
|
- Präfix nach Kategorie: `tool-`, `method-`, `concept-`
|
||||||
|
- Beschreibender Suffix: `-basics`, `-advanced`, `-troubleshooting`
|
||||||
|
|
||||||
|
**Video-Titel:**
|
||||||
|
- Beschreibend und suchfreundlich
|
||||||
|
- Tool/Methode im Titel erwähnen
|
||||||
|
- Skill-Level angeben: "Grundlagen", "Erweitert", "Expertenlevel"
|
||||||
|
|
||||||
|
### Code-Blöcke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bash-Beispiel
|
||||||
|
volatility -f memory.dmp --profile=Win7SP1x64 pslist
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python-Beispiel
|
||||||
|
import volatility.conf as conf
|
||||||
|
import volatility.registry as registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabellen
|
||||||
|
|
||||||
|
| Plugin | Beschreibung | Video-Tutorial |
|
||||||
|
|--------|--------------|----------------|
|
||||||
|
| pslist | Prozesse auflisten | [Tutorial ansehen](/videos/pslist-demo.mp4) |
|
||||||
|
| malfind | Malware finden | [Demo](/videos/malfind-basics.mp4) |
|
||||||
|
|
||||||
|
## Artikel-Typen
|
||||||
|
|
||||||
|
### 1. Tool-spezifische Artikel (`tool-*.md`)
|
||||||
|
|
||||||
|
Artikel die einem konkreten Software-Tool zugeordnet sind:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tool_name: "Autopsy" # Muss exakt mit tools.yaml übereinstimmen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Methoden-Artikel (`method-*.md`)
|
||||||
|
|
||||||
|
Artikel zu forensischen Methoden und Vorgehensweisen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tool_name: "Timeline Analysis" # Verweis auf method-type in tools.yaml
|
||||||
|
categories: ["Methodology", "Best Practices"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Konzept-Artikel (`concept-*.md`)
|
||||||
|
|
||||||
|
Artikel zu theoretischen Konzepten und Grundlagen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tool_name: "Hash Functions & Digital Signatures" # Verweis auf concept-type in tools.yaml
|
||||||
|
categories: ["Theory", "Fundamentals"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Typen erscheinen:
|
||||||
|
- In der Knowledgebase-Übersicht
|
||||||
|
- Bei gesetztem `tool_name`: In der Tool-Detailansicht
|
||||||
|
- Mit entsprechenden Icons und Badges
|
||||||
|
|
||||||
|
### 4. Geschützte Artikel
|
||||||
|
|
||||||
|
Unabhängig vom Typ können Artikel Authentifizierung erfordern:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gated_content: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Erscheinen mit 🔒-Symbol und erfordern Anmeldung.
|
||||||
|
|
||||||
|
## Verknüpfungen
|
||||||
|
|
||||||
|
### Related Tools
|
||||||
|
|
||||||
|
Tools aus dem `related_tools` Array werden automatisch verlinkt:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
related_tools:
|
||||||
|
- "YARA" # Wird zu Tool-Details verlinkt
|
||||||
|
- "Wireshark" # Muss in tools.yaml existieren
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interne Links
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [Knowledgebase](/knowledgebase)
|
||||||
|
- [Tool-Übersicht](/#tools-grid)
|
||||||
|
- [Anderer Artikel](/knowledgebase/artikel-slug)
|
||||||
|
```
|
||||||
|
|
||||||
|
## SEO und Metadaten
|
||||||
|
|
||||||
|
### Automatische Generierung
|
||||||
|
|
||||||
|
- URL: `/knowledgebase/{dateiname-ohne-extension}`
|
||||||
|
- Meta-Description: Aus `description`-Feld
|
||||||
|
- Breadcrumbs: Automatisch generiert
|
||||||
|
- Reading-Time: Automatisch berechnet
|
||||||
|
|
||||||
|
### Social Sharing
|
||||||
|
|
||||||
|
Jeder Artikel erhält automatisch Share-Buttons für:
|
||||||
|
- URL-Kopieren
|
||||||
|
- Tool-spezifische Verlinkung
|
||||||
|
|
||||||
|
## Validierung
|
||||||
|
|
||||||
|
### Pflichtfeld-Prüfung
|
||||||
|
|
||||||
|
Das System validiert automatisch:
|
||||||
|
- ✅ `title` ist gesetzt
|
||||||
|
- ✅ `description` ist gesetzt
|
||||||
|
- ✅ `last_updated` ist gültiges Datum
|
||||||
|
- ✅ `difficulty` ist gültiger Wert
|
||||||
|
- ✅ `tool_name` existiert in tools.yaml (falls gesetzt)
|
||||||
|
|
||||||
|
### Content-Validierung
|
||||||
|
|
||||||
|
- Automatische HTML-Escaping für Sicherheit
|
||||||
|
- Video-URLs werden validiert
|
||||||
|
- Broken Links werden geloggt (development)
|
||||||
|
- Dateinamen-Präfixe helfen bei der Organisation und Verknüpfung
|
||||||
|
|
||||||
|
### Video-Validierung
|
||||||
|
|
||||||
|
- Dateipfade auf Existenz geprüft (development)
|
||||||
|
- Format-Kompatibilität gewarnt
|
||||||
|
- Firefox-spezifische Warnings bei problematischen Formaten
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
1. Artikel von Nextcloud-Share herunterladen: https://cloud.cc24.dev/f/47971
|
||||||
|
2. Videos manuell in `public/videos/` bereitstellen (siehe `public/videos/README.md`)
|
||||||
|
3. Artikel in `src/content/knowledgebase/` ablegen (flache Struktur mit Präfixen)
|
||||||
|
4. Frontmatter nach Schema überprüfen/anpassen
|
||||||
|
5. Build-Prozess validiert automatisch
|
||||||
|
6. Artikel erscheint in Knowledgebase-Übersicht
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**Artikel erscheint nicht:**
|
||||||
|
- `published: true` gesetzt?
|
||||||
|
- Frontmatter-Syntax korrekt?
|
||||||
|
- Datei in `src/content/knowledgebase/` (flache Struktur)?
|
||||||
|
- Dateiname folgt Konvention (`tool-*`, `method-*`, `concept-*`)?
|
||||||
|
|
||||||
|
**Tool-Verknüpfung funktioniert nicht:**
|
||||||
|
- `tool_name` exakt wie in tools.yaml?
|
||||||
|
- Groß-/Kleinschreibung beachten
|
||||||
|
|
||||||
|
**Video lädt nicht:**
|
||||||
|
- Pfad korrekt? (beginnt mit `/videos/`)
|
||||||
|
- Datei im `public/videos/` Ordner?
|
||||||
|
- Unterstütztes Format? (mp4, webm, ogg)
|
||||||
|
- Firefox-kompatibel? (H.264/AVC für MP4)
|
||||||
|
|
||||||
|
**Firefox-Video-Probleme:**
|
||||||
|
- H.265/HEVC-Codecs vermeiden
|
||||||
|
- Multiple `<source>`-Tags für Fallbacks nutzen
|
||||||
|
- Browser-Console auf Codec-Fehler prüfen
|
||||||
|
- MIME-Types korrekt gesetzt?
|
||||||
|
|
||||||
|
## Beispiel-Ordnerstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/content/knowledgebase/
|
||||||
|
├── tool-autopsy-timeline-analysis.md
|
||||||
|
├── tool-volatility-basic-commands.md
|
||||||
|
├── tool-yara-rule-writing.md
|
||||||
|
├── method-timeline-analysis-fundamentals.md
|
||||||
|
├── method-disk-imaging-best-practices.md
|
||||||
|
├── concept-hash-functions-digital-signatures.md
|
||||||
|
├── concept-regex-pattern-matching.md
|
||||||
|
└── concept-chain-of-custody.md
|
||||||
|
|
||||||
|
public/videos/
|
||||||
|
├── tools/
|
||||||
|
│ ├── autopsy-timeline-tutorial.mp4
|
||||||
|
│ ├── volatility-basics-demo.mp4
|
||||||
|
│ └── yara-rules-advanced.webm
|
||||||
|
├── methods/
|
||||||
|
│ ├── timeline-analysis-walkthrough.mp4
|
||||||
|
│ └── disk-imaging-best-practices.mp4
|
||||||
|
└── concepts/
|
||||||
|
├── hash-functions-explained.mp4
|
||||||
|
└── chain-custody-procedures.mp4
|
||||||
|
```
|
||||||
@@ -16,6 +16,11 @@ const knowledgebaseCollection = defineCollection({
|
|||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
|
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
|
gated_content: z.boolean().default(false),
|
||||||
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
knowledgebase: knowledgebaseCollection
|
||||||
|
};
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Extraktion logischer Dateisysteme alter Android-Smartphones - eine KI-Recherche"
|
|
||||||
tool_name: "Android Logical Imaging"
|
|
||||||
description: "Wie man alte Android-Handys aufbekommen könnte - eine Recherche von Claude"
|
|
||||||
last_updated: 2025-07-21
|
|
||||||
author: "Claude 4 Sonnet (Research)"
|
|
||||||
difficulty: "advanced"
|
|
||||||
categories: ["data-collection"]
|
|
||||||
tags: ["imaging", "filesystem", "hardware-interface"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: true
|
|
||||||
configuration: true
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: true
|
|
||||||
advanced_topics: true
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
Open-Source Android Forensik bietet robuste Alternativen zu kommerziellen Lösungen wie Cellebrite UFED und Magnet AXIOM. Besonders für ältere Android-Geräte (5+ Jahre) existieren bewährte Methoden zur Datenextraktion und -analyse.
|
|
||||||
|
|
||||||
## Kernkomponenten des Open-Source Forensik-Stacks
|
|
||||||
|
|
||||||
**Autopsy Digital Forensics Platform** bildet das Fundament mit GUI-basierter Analyse und integrierten Android-Parsing-Fähigkeiten. Die Plattform unterstützt **ALEAPP (Android Logs Events And Protobuf Parser)**, das über 100 Artefakt-Kategorien aus Android-Extraktionen parst.
|
|
||||||
|
|
||||||
**Mobile Verification Toolkit (MVT)** von Amnesty International bietet spezialisierte Command-Line-Tools für Android-Analyse mit Fokus auf Kompromittierungserkennung.
|
|
||||||
|
|
||||||
**SIFT Workstation** stellt eine komplette Ubuntu-basierte forensische Umgebung mit 125+ vorinstallierten Tools bereit.
|
|
||||||
|
|
||||||
## Erfolgsraten nach Gerätealter
|
|
||||||
|
|
||||||
- **Pre-2017 Geräte**: 85-98% logische Extraktion, 30-70% physische Extraktion
|
|
||||||
- **2017-2019 Geräte**: 80-95% logische Extraktion, 15-35% physische Extraktion
|
|
||||||
- **2020+ Geräte**: 70-85% logische Extraktion, 5-15% physische Extraktion
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
## SIFT Workstation Setup
|
|
||||||
|
|
||||||
### Systemanforderungen
|
|
||||||
- Quad-Core CPU 2.5GHz+
|
|
||||||
- 16GB+ RAM
|
|
||||||
- 500GB+ SSD Speicher
|
|
||||||
- USB 3.0+ Anschlüsse
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
1. Download von [SANS SIFT Workstation](https://www.sans.org/tools/sift-workstation/)
|
|
||||||
2. VMware/VirtualBox Import der OVA-Datei
|
|
||||||
3. VM-Konfiguration: 8GB+ RAM, 4+ CPU-Kerne
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update nach Installation
|
|
||||||
sudo apt update && sudo apt upgrade -y
|
|
||||||
sudo sift update
|
|
||||||
```
|
|
||||||
|
|
||||||
## Autopsy Installation
|
|
||||||
|
|
||||||
### Windows Installation
|
|
||||||
1. Download von [autopsy.com](https://www.autopsy.com/)
|
|
||||||
2. Java 8+ Installation erforderlich
|
|
||||||
3. Installation mit Administratorrechten
|
|
||||||
|
|
||||||
### Linux Installation
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt install autopsy sleuthkit
|
|
||||||
# Oder manueller Download und Installation
|
|
||||||
wget https://github.com/sleuthkit/autopsy/releases/latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Essential Tools Installation
|
|
||||||
|
|
||||||
### Android Debug Bridge (ADB)
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt install android-tools-adb android-tools-fastboot
|
|
||||||
|
|
||||||
# Windows - Download Android Platform Tools
|
|
||||||
# https://developer.android.com/studio/releases/platform-tools
|
|
||||||
```
|
|
||||||
|
|
||||||
### ALEAPP Installation
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/abrignoni/ALEAPP.git
|
|
||||||
cd ALEAPP
|
|
||||||
pip3 install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mobile Verification Toolkit (MVT)
|
|
||||||
```bash
|
|
||||||
pip3 install mvt
|
|
||||||
# Oder via GitHub für neueste Version
|
|
||||||
git clone https://github.com/mvt-project/mvt.git
|
|
||||||
cd mvt && pip3 install .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Andriller Installation
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/den4uk/andriller.git
|
|
||||||
cd andriller
|
|
||||||
pip3 install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
# Konfiguration
|
|
||||||
|
|
||||||
## ADB Setup und Gerätevorbereitung
|
|
||||||
|
|
||||||
### USB-Debugging aktivieren
|
|
||||||
1. Entwickleroptionen freischalten (7x Build-Nummer antippen)
|
|
||||||
2. USB-Debugging aktivieren
|
|
||||||
3. Gerät via USB verbinden
|
|
||||||
4. RSA-Fingerprint akzeptieren
|
|
||||||
|
|
||||||
### ADB Verbindung testen
|
|
||||||
```bash
|
|
||||||
adb devices
|
|
||||||
# Sollte Gerät mit "device" Status zeigen
|
|
||||||
adb shell getprop ro.build.version.release # Android Version
|
|
||||||
adb shell getprop ro.product.model # Gerätemodell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Autopsy Projektkonfiguration
|
|
||||||
|
|
||||||
### Case-Setup
|
|
||||||
1. Neuen Fall erstellen
|
|
||||||
2. Ermittler-Informationen eingeben
|
|
||||||
3. Case-Verzeichnis festlegen (ausreichend Speicherplatz)
|
|
||||||
|
|
||||||
### Android Analyzer Module aktivieren
|
|
||||||
- Tools → Options → Modules
|
|
||||||
- Android Analyzer aktivieren
|
|
||||||
- ALEAPP Integration konfigurieren
|
|
||||||
|
|
||||||
### Hash-Algorithmen konfigurieren
|
|
||||||
- MD5, SHA-1, SHA-256 für Integritätsprüfung
|
|
||||||
- Automatische Hash-Berechnung bei Import aktivieren
|
|
||||||
|
|
||||||
## MVT Konfiguration
|
|
||||||
|
|
||||||
### Konfigurationsdatei erstellen
|
|
||||||
```yaml
|
|
||||||
# ~/.mvt/config.yaml
|
|
||||||
adb_path: "/usr/bin/adb"
|
|
||||||
output_folder: "/home/user/mvt_output"
|
|
||||||
```
|
|
||||||
|
|
||||||
# Verwendungsbeispiele
|
|
||||||
|
|
||||||
## Fall 1: Logische Datenextraktion mit ADB
|
|
||||||
|
|
||||||
### Geräteinformationen sammeln
|
|
||||||
```bash
|
|
||||||
# Systeminfo
|
|
||||||
adb shell getprop > device_properties.txt
|
|
||||||
adb shell cat /proc/version > kernel_info.txt
|
|
||||||
adb shell mount > mount_info.txt
|
|
||||||
|
|
||||||
# Installierte Apps
|
|
||||||
adb shell pm list packages -f > installed_packages.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank-Extraktion
|
|
||||||
```bash
|
|
||||||
# SMS/MMS Datenbank
|
|
||||||
adb pull /data/data/com.android.providers.telephony/databases/mmssms.db
|
|
||||||
|
|
||||||
# Kontakte
|
|
||||||
adb pull /data/data/com.android.providers.contacts/databases/contacts2.db
|
|
||||||
|
|
||||||
# Anrufliste
|
|
||||||
adb pull /data/data/com.android.providers.contacts/databases/calllog.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### WhatsApp Datenextraktion
|
|
||||||
```bash
|
|
||||||
# WhatsApp Datenbanken (Root erforderlich)
|
|
||||||
adb shell su -c "cp -r /data/data/com.whatsapp/ /sdcard/whatsapp_backup/"
|
|
||||||
adb pull /sdcard/whatsapp_backup/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fall 2: Android Backup-Analyse
|
|
||||||
|
|
||||||
### Vollständiges Backup erstellen
|
|
||||||
```bash
|
|
||||||
# Umfassendes Backup (ohne Root)
|
|
||||||
adb backup -all -system -apk -shared -f backup.ab
|
|
||||||
|
|
||||||
# Backup entschlüsseln (falls verschlüsselt)
|
|
||||||
java -jar abe.jar unpack backup.ab backup.tar
|
|
||||||
tar -xf backup.tar
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup mit ALEAPP analysieren
|
|
||||||
```bash
|
|
||||||
python3 aleappGUI.py
|
|
||||||
# Oder Command-Line
|
|
||||||
python3 aleapp.py -t tar -i backup.tar -o output_folder
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fall 3: MVT Kompromittierungsanalyse
|
|
||||||
|
|
||||||
### Live-Geräteanalyse
|
|
||||||
```bash
|
|
||||||
# ADB-basierte Analyse
|
|
||||||
mvt-android check-adb --output /path/to/output/
|
|
||||||
|
|
||||||
# Backup-Analyse
|
|
||||||
mvt-android check-backup --output /path/to/output/ backup.ab
|
|
||||||
```
|
|
||||||
|
|
||||||
### IOC-Suche mit Pegasus-Indikatoren
|
|
||||||
```bash
|
|
||||||
# Mit vorgefertigten IOCs
|
|
||||||
mvt-android check-adb --iocs /path/to/pegasus.stix2 --output results/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fall 4: Physische Extraktion (Root erforderlich)
|
|
||||||
|
|
||||||
### Device Rooting - MediaTek Geräte
|
|
||||||
```bash
|
|
||||||
# MTKClient für MediaTek-Chipsets
|
|
||||||
git clone https://github.com/bkerler/mtkclient.git
|
|
||||||
cd mtkclient
|
|
||||||
python3 mtk payload
|
|
||||||
|
|
||||||
# Nach erfolgreichem Root
|
|
||||||
adb shell su
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vollständiges Memory Dump
|
|
||||||
```bash
|
|
||||||
# Partitionslayout ermitteln
|
|
||||||
adb shell su -c "cat /proc/partitions"
|
|
||||||
adb shell su -c "ls -la /dev/block/"
|
|
||||||
|
|
||||||
# Vollständiges Device Image (Root erforderlich)
|
|
||||||
adb shell su -c "dd if=/dev/block/mmcblk0 of=/sdcard/full_device.img bs=4096"
|
|
||||||
adb pull /sdcard/full_device.img
|
|
||||||
```
|
|
||||||
|
|
||||||
# Best Practices
|
|
||||||
|
|
||||||
## Rechtliche Compliance
|
|
||||||
|
|
||||||
### Dokumentation und Chain of Custody
|
|
||||||
- **Vollständige Dokumentation**: Wer, Was, Wann, Wo, Warum
|
|
||||||
- **Hash-Verifikation**: MD5/SHA-256 für alle extrahierten Daten
|
|
||||||
- **Nur forensische Kopien analysieren**, niemals Originaldaten
|
|
||||||
- **Schriftliche Genehmigung** für Geräteanalyse einholen
|
|
||||||
|
|
||||||
### Familiengeräte und Nachlässe
|
|
||||||
- Genehmigung durch Nachlassverwalter erforderlich
|
|
||||||
- Gerichtsbeschlüsse für Cloud-Zugang eventuell nötig
|
|
||||||
- Drittpartei-Kommunikation kann weiterhin geschützt sein
|
|
||||||
|
|
||||||
## Technische Best Practices
|
|
||||||
|
|
||||||
### Hash-Integrität sicherstellen
|
|
||||||
```bash
|
|
||||||
# Hash vor und nach Transfer prüfen
|
|
||||||
md5sum original_file.db
|
|
||||||
sha256sum original_file.db
|
|
||||||
|
|
||||||
# Hash-Verifikation dokumentieren
|
|
||||||
echo "$(date): MD5: $(md5sum file.db)" >> chain_of_custody.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sichere Arbeitsumgebung
|
|
||||||
- Isolierte VM für Forensik-Arbeit
|
|
||||||
- Netzwerk-Isolation während Analyse
|
|
||||||
- Verschlüsselte Speicherung aller Evidenz
|
|
||||||
- Regelmäßige Backups der Case-Datenbanken
|
|
||||||
|
|
||||||
### Qualitätssicherung
|
|
||||||
- Peer-Review kritischer Analysen
|
|
||||||
- Standardisierte Arbeitsabläufe (SOPs)
|
|
||||||
- Regelmäßige Tool-Validierung
|
|
||||||
- Kontinuierliche Weiterbildung
|
|
||||||
|
|
||||||
## Erfolgsmaximierung nach Gerätehersteller
|
|
||||||
|
|
||||||
### MediaTek-Geräte (Höchste Erfolgsrate)
|
|
||||||
- BootROM-Exploits für MT6735, MT6737, MT6750, MT6753, MT6797
|
|
||||||
- MTKClient für Hardware-Level-Zugang
|
|
||||||
- Erfolgsrate: 80%+ für Geräte 2015-2019
|
|
||||||
|
|
||||||
### Samsung-Geräte
|
|
||||||
- Ältere Knox-Implementierungen umgehbar
|
|
||||||
- Emergency Dialer Exploits für Android 4.x
|
|
||||||
- Erfolgsrate: 40-70% je nach Knox-Version
|
|
||||||
|
|
||||||
### Pixel/Nexus-Geräte
|
|
||||||
- Bootloader-Unlocking oft möglich
|
|
||||||
- Fastboot-basierte Recovery-Installation
|
|
||||||
- Erfolgsrate: 60-80% bei freigeschaltetem Bootloader
|
|
||||||
|
|
||||||
# Troubleshooting
|
|
||||||
|
|
||||||
## Problem: ADB erkennt Gerät nicht
|
|
||||||
|
|
||||||
### Lösung: USB-Treiber und Berechtigungen
|
|
||||||
```bash
|
|
||||||
# Linux: USB-Berechtigungen prüfen
|
|
||||||
lsusb | grep -i android
|
|
||||||
sudo chmod 666 /dev/bus/usb/XXX/XXX
|
|
||||||
|
|
||||||
# udev-Regeln erstellen
|
|
||||||
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/51-android.rules
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows: Treiber-Installation
|
|
||||||
1. Geräte-Manager öffnen
|
|
||||||
2. Android-Gerät mit Warnsymbol finden
|
|
||||||
3. Treiber manuell installieren (Android USB Driver)
|
|
||||||
|
|
||||||
## Problem: Verschlüsselte Android Backups
|
|
||||||
|
|
||||||
### Lösung: Android Backup Extractor
|
|
||||||
```bash
|
|
||||||
# ADB Backup Extractor installieren
|
|
||||||
git clone https://github.com/nelenkov/android-backup-extractor.git
|
|
||||||
cd android-backup-extractor
|
|
||||||
gradle build
|
|
||||||
|
|
||||||
# Backup entschlüsseln
|
|
||||||
java -jar abe.jar unpack backup.ab backup.tar [password]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Problem: Unzureichende Berechtigungen für Datenextraktion
|
|
||||||
|
|
||||||
### Lösung: Alternative Extraktionsmethoden
|
|
||||||
```bash
|
|
||||||
# AFLogical OSE für begrenzte Extraktion ohne Root
|
|
||||||
# WhatsApp Key/DB Extractor für spezifische Apps
|
|
||||||
# Backup-basierte Extraktion als Fallback
|
|
||||||
|
|
||||||
# Custom Recovery für erweiterten Zugang
|
|
||||||
fastboot flash recovery twrp-device.img
|
|
||||||
```
|
|
||||||
|
|
||||||
## Problem: ALEAPP Parsing-Fehler
|
|
||||||
|
|
||||||
### Lösung: Datenformat-Probleme beheben
|
|
||||||
```bash
|
|
||||||
# Log-Dateien prüfen
|
|
||||||
python3 aleapp.py -t dir -i /path/to/data -o output --debug
|
|
||||||
|
|
||||||
# Spezifische Parser deaktivieren
|
|
||||||
# Manuelle SQLite-Analyse bei Parser-Fehlern
|
|
||||||
sqlite3 database.db ".tables"
|
|
||||||
sqlite3 database.db ".schema table_name"
|
|
||||||
```
|
|
||||||
|
|
||||||
# Erweiterte Techniken
|
|
||||||
|
|
||||||
## Memory Forensics mit LiME
|
|
||||||
|
|
||||||
### LiME für ARM-Devices kompilieren
|
|
||||||
```bash
|
|
||||||
# Cross-Compilation Setup
|
|
||||||
export ARCH=arm
|
|
||||||
export CROSS_COMPILE=arm-linux-gnueabi-
|
|
||||||
export KERNEL_DIR=/path/to/kernel/source
|
|
||||||
|
|
||||||
# LiME Module kompilieren
|
|
||||||
git clone https://github.com/504ensicsLabs/LiME.git
|
|
||||||
cd LiME/src
|
|
||||||
make
|
|
||||||
|
|
||||||
# Memory Dump erstellen (Root erforderlich)
|
|
||||||
adb push lime.ko /data/local/tmp/
|
|
||||||
adb shell su -c "insmod /data/local/tmp/lime.ko 'path=/sdcard/memory.lime format=lime'"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Volatility-Analyse von Android Memory
|
|
||||||
```bash
|
|
||||||
# Memory Dump analysieren
|
|
||||||
python vol.py -f memory.lime --profile=Linux <profile> linux.pslist
|
|
||||||
python vol.py -f memory.lime --profile=Linux <profile> linux.bash
|
|
||||||
python vol.py -f memory.lime --profile=Linux <profile> linux.netstat
|
|
||||||
```
|
|
||||||
|
|
||||||
## FRIDA-basierte Runtime-Analyse
|
|
||||||
|
|
||||||
### FRIDA für Kryptographie-Hooks
|
|
||||||
```javascript
|
|
||||||
// crypto_hooks.js - SSL/TLS Traffic abfangen
|
|
||||||
Java.perform(function() {
|
|
||||||
var SSLContext = Java.use("javax.net.ssl.SSLContext");
|
|
||||||
SSLContext.init.overload('[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom').implementation = function(keyManagers, trustManagers, secureRandom) {
|
|
||||||
console.log("[+] SSLContext.init() called");
|
|
||||||
this.init(keyManagers, trustManagers, secureRandom);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### FRIDA Installation und Verwendung
|
|
||||||
```bash
|
|
||||||
# FRIDA Server auf Android-Gerät installieren
|
|
||||||
adb push frida-server /data/local/tmp/
|
|
||||||
adb shell su -c "chmod 755 /data/local/tmp/frida-server"
|
|
||||||
adb shell su -c "/data/local/tmp/frida-server &"
|
|
||||||
|
|
||||||
# Script ausführen
|
|
||||||
frida -U -l crypto_hooks.js com.target.package
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Recovery und Fastboot-Exploits
|
|
||||||
|
|
||||||
### TWRP Installation für forensischen Zugang
|
|
||||||
```bash
|
|
||||||
# Bootloader entsperren (Herstellerabhängig)
|
|
||||||
fastboot oem unlock
|
|
||||||
# Oder
|
|
||||||
fastboot flashing unlock
|
|
||||||
|
|
||||||
# TWRP flashen
|
|
||||||
fastboot flash recovery twrp-device.img
|
|
||||||
fastboot boot twrp-device.img # Temporäre Installation
|
|
||||||
|
|
||||||
# In TWRP: ADB-Zugang mit Root-Berechtigungen
|
|
||||||
adb shell mount /system
|
|
||||||
adb shell mount /data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Partitions-Imaging mit dd
|
|
||||||
```bash
|
|
||||||
# Vollständige Partition-Liste
|
|
||||||
adb shell cat /proc/partitions
|
|
||||||
|
|
||||||
# Kritische Partitionen extrahieren
|
|
||||||
adb shell dd if=/dev/block/bootdevice/by-name/system of=/external_sd/system.img
|
|
||||||
adb shell dd if=/dev/block/bootdevice/by-name/userdata of=/external_sd/userdata.img
|
|
||||||
adb shell dd if=/dev/block/bootdevice/by-name/boot of=/external_sd/boot.img
|
|
||||||
```
|
|
||||||
|
|
||||||
## SQLite Forensics und gelöschte Daten
|
|
||||||
|
|
||||||
### Erweiterte SQLite-Analyse
|
|
||||||
```bash
|
|
||||||
# Freelist-Analyse für gelöschte Einträge
|
|
||||||
sqlite3 database.db "PRAGMA freelist_count;"
|
|
||||||
sqlite3 database.db "PRAGMA page_size;"
|
|
||||||
|
|
||||||
# WAL-Datei Analyse
|
|
||||||
sqlite3 database.db "PRAGMA wal_checkpoint;"
|
|
||||||
strings database.db-wal | grep -i "search_term"
|
|
||||||
|
|
||||||
# Undark für Deleted Record Recovery
|
|
||||||
undark database.db --freelist --export-csv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Timeline-Rekonstruktion
|
|
||||||
```bash
|
|
||||||
# Autopsy Timeline-Generierung
|
|
||||||
# Tools → Generate Timeline
|
|
||||||
# Analyse von MAC-Times (Modified, Accessed, Created)
|
|
||||||
|
|
||||||
# Plaso Timeline-Tools
|
|
||||||
log2timeline.py timeline.plaso /path/to/android/data/
|
|
||||||
psort.py -o dynamic timeline.plaso
|
|
||||||
```
|
|
||||||
|
|
||||||
## Weiterführende Ressourcen
|
|
||||||
|
|
||||||
### Dokumentation und Standards
|
|
||||||
- [NIST SP 800-101 Rev. 1 - Mobile Device Forensics Guidelines](https://csrc.nist.gov/pubs/sp/800/101/r1/final)
|
|
||||||
- [SANS FOR585 - Smartphone Forensics](https://www.sans.org/cyber-security-courses/advanced-smartphone-mobile-device-forensics/)
|
|
||||||
- [ALEAPP GitHub Repository](https://github.com/abrignoni/ALEAPP)
|
|
||||||
- [MVT Documentation](https://docs.mvt.re/en/latest/)
|
|
||||||
|
|
||||||
### Community und Weiterbildung
|
|
||||||
- [Autopsy User Documentation](https://sleuthkit.org/autopsy/docs/)
|
|
||||||
- [Android Forensics References](https://github.com/impillar/AndroidReferences/blob/master/AndroidTools.md)
|
|
||||||
- [Digital Forensics Framework Collection](https://github.com/mesquidar/ForensicsTools)
|
|
||||||
|
|
||||||
### Spezialisierte Tools
|
|
||||||
- [MTKClient für MediaTek Exploits](https://github.com/bkerler/mtkclient)
|
|
||||||
- [Android Forensics Framework](https://github.com/nowsecure/android-forensics)
|
|
||||||
- [Santoku Linux Mobile Forensics Distribution](https://santoku-linux.com/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Wichtiger Hinweis**: Diese Anleitung dient ausschließlich für autorisierte forensische Untersuchungen. Stellen Sie sicher, dass Sie über entsprechende rechtliche Befugnisse verfügen, bevor Sie diese Techniken anwenden. Bei Zweifeln konsultieren Sie Rechtsberatung.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Kali Linux - Die Hacker-Distribution für Forensik & Penetration Testing"
|
|
||||||
tool_name: "Kali Linux"
|
|
||||||
description: "Leitfaden zur Installation, Nutzung und Best Practices für Kali Linux – die All-in-One-Plattform für Security-Profis."
|
|
||||||
last_updated: 2025-07-20
|
|
||||||
author: "Claude 4 Sonnet"
|
|
||||||
difficulty: "intermediate"
|
|
||||||
categories: ["incident-response", "forensics", "penetration-testing"]
|
|
||||||
tags: ["live-boot", "tool-collection", "penetration-testing", "forensics-suite", "virtualization", "arm-support"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: true
|
|
||||||
configuration: true
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: true
|
|
||||||
advanced_topics: true
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
Kali Linux ist eine auf Debian basierende Linux-Distribution, die speziell für Penetration Testing, digitale Forensik, Reverse Engineering und Incident Response entwickelt wurde. Mit über 600 vorinstallierten Tools ist sie ein unverzichtbares Werkzeug für Security-Experten, Ermittler und forensische Analysten. Die Live-Boot-Funktion erlaubt es, Systeme ohne Spuren zu hinterlassen zu analysieren – ideal für forensische Untersuchungen.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Option 1: Live-System (USB/DVD)
|
|
||||||
|
|
||||||
1. ISO-Image von [kali.org](https://www.kali.org/get-kali/) herunterladen.
|
|
||||||
2. Mit **Rufus** oder **balenaEtcher** auf einen USB-Stick schreiben.
|
|
||||||
3. Vom USB-Stick booten (ggf. Boot-Reihenfolge im BIOS anpassen).
|
|
||||||
4. Kali kann direkt ohne Installation im Live-Modus verwendet werden.
|
|
||||||
|
|
||||||
### Option 2: Installation auf Festplatte
|
|
||||||
|
|
||||||
1. ISO-Image booten und **Graphical Install** wählen.
|
|
||||||
2. Schritt-für-Schritt durch den Installationsassistenten navigieren:
|
|
||||||
- Sprache, Zeitzone und Tastaturlayout auswählen
|
|
||||||
- Partitionierung konfigurieren (automatisch oder manuell)
|
|
||||||
- Benutzerkonten erstellen
|
|
||||||
3. Nach Installation Neustart durchführen.
|
|
||||||
|
|
||||||
### Option 3: Virtuelle Maschine (VM)
|
|
||||||
|
|
||||||
- Offizielle VM-Images für VirtualBox und VMware von der [Kali-Website](https://www.kali.org/get-kali/#kali-virtual-machines)
|
|
||||||
- Importieren, ggf. Netzwerkbrücke und Shared Folders aktivieren
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Netzwerkeinstellungen
|
|
||||||
|
|
||||||
- Konfiguration über `nmtui` oder `/etc/network/interfaces`
|
|
||||||
- VPN und Proxy-Integration über GUI oder Terminal
|
|
||||||
|
|
||||||
### Updates & Paketquellen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update && sudo apt full-upgrade
|
|
||||||
````
|
|
||||||
|
|
||||||
> Hinweis: `kali-rolling` ist die Standard-Distribution für kontinuierliche Updates.
|
|
||||||
|
|
||||||
### Sprache & Lokalisierung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dpkg-reconfigure locales
|
|
||||||
sudo dpkg-reconfigure keyboard-configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verwendungsbeispiele
|
|
||||||
|
|
||||||
### 1. Netzwerkscan mit Nmap
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nmap -sS -T4 -A 192.168.1.0/24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Passwort-Cracking mit John the Ripper
|
|
||||||
|
|
||||||
```bash
|
|
||||||
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Forensik mit Autopsy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
autopsy &
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Android-Analyse mit MobSF (in Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull opensecurity/mobile-security-framework-mobsf
|
|
||||||
docker run -it -p 8000:8000 mobsf
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
* Nutze immer **aktuelle Snapshots** oder VM-Clones vor gefährlichen Tests
|
|
||||||
* Verwende separate Netzwerke (z. B. Host-only oder NAT) für Tests
|
|
||||||
* Deaktiviere automatisches WLAN bei forensischen Analysen
|
|
||||||
* Prüfe und aktualisiere regelmäßig Toolsets (`apt`, `git`, `pip`)
|
|
||||||
* Halte deine ISO-Images versioniert für forensische Reproduzierbarkeit
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Problem: Keine Internetverbindung nach Installation
|
|
||||||
|
|
||||||
**Lösung:** Netzwerkadapter prüfen, ggf. mit `ifconfig` oder `ip a` überprüfen, DHCP aktivieren.
|
|
||||||
|
|
||||||
### Problem: Tools fehlen nach Update
|
|
||||||
|
|
||||||
**Lösung:** Tool-Gruppen wie `kali-linux-default` manuell nachinstallieren:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install kali-linux-default
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: „Permission Denied“ bei Tools
|
|
||||||
|
|
||||||
**Lösung:** Root-Rechte nutzen oder mit `sudo` ausführen.
|
|
||||||
|
|
||||||
## Weiterführende Themen
|
|
||||||
|
|
||||||
* **Kustomisierung von Kali ISOs** mit `live-build`
|
|
||||||
* **NetHunter**: Kali für mobile Geräte (Android)
|
|
||||||
* **Kali Purple**: Defensive Security Suite
|
|
||||||
* Integration mit **Cloud-Infrastrukturen** via WSL oder Azure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Links & Ressourcen:**
|
|
||||||
|
|
||||||
* Offizielle Website: [https://kali.org](https://kali.org/)
|
|
||||||
* Dokumentation: [https://docs.kali.org/](https://docs.kali.org/)
|
|
||||||
* GitLab Repo: [https://gitlab.com/kalilinux](https://gitlab.com/kalilinux)
|
|
||||||
* Discord-Community: [https://discord.com/invite/kali-linux](https://discord.com/invite/kali-linux)
|
|
||||||
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
---
|
|
||||||
title: "MISP - Plattform für Threat Intelligence Sharing"
|
|
||||||
tool_name: "MISP"
|
|
||||||
description: "Das Rückgrat des modernen Threat-Intelligence-Sharings mit über 40.000 aktiven Instanzen weltweit."
|
|
||||||
last_updated: 2025-07-20
|
|
||||||
author: "Claude 4 Sonnet"
|
|
||||||
difficulty: "intermediate"
|
|
||||||
categories: ["incident-response", "static-investigations", "malware-analysis", "network-forensics", "cloud-forensics"]
|
|
||||||
tags: ["web-based", "threat-intelligence", "api", "correlation", "ioc-sharing", "automation"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: true
|
|
||||||
configuration: true
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: true
|
|
||||||
advanced_topics: false
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
**MISP (Malware Information Sharing Platform & Threat Sharing)** ist eine freie Open-Source-Plattform zur strukturierten Erfassung, Speicherung, Analyse und gemeinsamen Nutzung von Cyber-Bedrohungsdaten. Mit über 40.000 Instanzen weltweit ist MISP der De-facto-Standard für den Austausch von Indicators of Compromise (IoCs) und Threat Intelligence zwischen CERTs, SOCs, Strafverfolgungsbehörden und anderen sicherheitsrelevanten Organisationen.
|
|
||||||
|
|
||||||
Die föderierte Architektur ermöglicht einen kontrollierten, dezentralen Austausch von Informationen über vertrauenswürdige Partner hinweg. Durch Taxonomien, Tags und integrierte APIs ist eine automatische Anreicherung, Korrelation und Verarbeitung von Informationen in SIEMs, Firewalls oder Endpoint-Lösungen möglich.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- **Server-Betriebssystem:** Linux (empfohlen: Debian/Ubuntu)
|
|
||||||
- **Abhängigkeiten:** MariaDB/MySQL, PHP, Apache/Nginx, Redis
|
|
||||||
- **Ressourcen:** Mindestens 4 GB RAM, SSD empfohlen
|
|
||||||
|
|
||||||
### Installationsschritte
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Beispiel für Debian/Ubuntu:
|
|
||||||
sudo apt update && sudo apt install -y curl gnupg git python3 python3-pip redis-server mariadb-server apache2 php libapache2-mod-php
|
|
||||||
|
|
||||||
# MISP klonen
|
|
||||||
git clone https://github.com/MISP/MISP.git /var/www/MISP
|
|
||||||
|
|
||||||
# Setup-Skript nutzen
|
|
||||||
cd /var/www/MISP && bash INSTALL/INSTALL.debian.sh
|
|
||||||
````
|
|
||||||
|
|
||||||
Weitere Details: [Offizielle Installationsanleitung](https://misp.github.io/MISP/INSTALL.debian/)
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Webserver
|
|
||||||
|
|
||||||
* HTTPS aktivieren (Let's Encrypt oder Reverse Proxy)
|
|
||||||
* PHP-Konfiguration anpassen (`upload_max_filesize`, `memory_limit`, `post_max_size`)
|
|
||||||
|
|
||||||
### Benutzerrollen
|
|
||||||
|
|
||||||
* Administrator, Org-Admin, Analyst etc.
|
|
||||||
* Zugriffsbeschränkungen nach Organisation/Feed definierbar
|
|
||||||
|
|
||||||
### Feeds und Galaxies
|
|
||||||
|
|
||||||
* Aktivierung von Feeds (z. B. CIRCL, Abuse.ch, OpenCTI)
|
|
||||||
* Nutzung von Galaxies zur Klassifizierung (APT-Gruppen, Malware-Familien)
|
|
||||||
|
|
||||||
## Verwendungsbeispiele
|
|
||||||
|
|
||||||
### Beispiel 1: Import von IoCs aus externem Feed
|
|
||||||
|
|
||||||
1. Feed aktivieren unter **Administration → List Feeds**
|
|
||||||
2. Feed synchronisieren
|
|
||||||
3. Ereignisse durchsuchen, analysieren, ggf. mit eigenen Daten korrelieren
|
|
||||||
|
|
||||||
### Beispiel 2: Automatisierte Anbindung an SIEM
|
|
||||||
|
|
||||||
* REST-API-Token erstellen
|
|
||||||
* API-Calls zur Abfrage neuer Events (z. B. mit Python, Logstash oder MISP Workbench)
|
|
||||||
* Integration in Security-Systeme über JSON/STIX export
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
* Regelmäßige Backups der Datenbank
|
|
||||||
* Taxonomien konsistent verwenden
|
|
||||||
* Nutzung der Sighting-Funktion zur Validierung von IoCs
|
|
||||||
* Vertrauensstufen (TLP, PAP) korrekt setzen
|
|
||||||
* Nicht nur konsumieren – auch teilen!
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Problem: MISP-Feeds laden nicht
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
* Internetverbindung prüfen
|
|
||||||
* Cronjobs aktiv?
|
|
||||||
* Logs prüfen: `/var/www/MISP/app/tmp/logs/error.log`
|
|
||||||
|
|
||||||
### Problem: API gibt 403 zurück
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
* Ist der API-Key korrekt und aktiv?
|
|
||||||
* Rechte des Benutzers überprüfen
|
|
||||||
* IP-Filter im MISP-Backend beachten
|
|
||||||
|
|
||||||
### Problem: Hohe Datenbanklast
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
* Indizes optimieren
|
|
||||||
* Redis aktivieren
|
|
||||||
* Alte Events regelmäßig archivieren oder löschen
|
|
||||||
|
|
||||||
## Weiterführende Themen
|
|
||||||
|
|
||||||
* STIX2-Import/Export
|
|
||||||
* Erweiterungen mit MISP Modules (z. B. für Virustotal, YARA)
|
|
||||||
* Föderierte Netzwerke und Community-Portale
|
|
||||||
* Integration mit OpenCTI oder TheHive
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Links:**
|
|
||||||
|
|
||||||
* 🌐 [Offizielle Projektseite](https://misp-project.org/)
|
|
||||||
* 📦 [CC24-MISP-Instanz](https://misp.cc24.dev)
|
|
||||||
* 📊 [Status-Monitoring](https://status.mikoshi.de/api/badge/34/status)
|
|
||||||
|
|
||||||
Lizenz: **AGPL-3.0**
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Nextcloud - Sichere Kollaborationsplattform"
|
|
||||||
tool_name: "Nextcloud"
|
|
||||||
description: "Detaillierte Anleitung und Best Practices für Nextcloud in forensischen Einsatzszenarien"
|
|
||||||
last_updated: 2025-07-20
|
|
||||||
author: "Claude 4 Sonnet"
|
|
||||||
difficulty: "novice"
|
|
||||||
categories: ["collaboration-general"]
|
|
||||||
tags: ["web-based", "collaboration", "file-sharing", "api", "encryption", "document-management"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: true
|
|
||||||
configuration: true
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: true
|
|
||||||
advanced_topics: false
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
Nextcloud ist eine Open-Source-Cloud-Suite, die speziell für die sichere Zusammenarbeit entwickelt wurde. Sie eignet sich ideal für forensische Teams, da sie eine DSGVO-konforme Umgebung mit verschlüsselter Dateiablage, Office-Integration und Videokonferenzen bereitstellt. Zusätzlich bietet Nextcloud einen integrierten SSO-Provider, der das Identitätsmanagement für andere forensische Tools stark vereinfacht.
|
|
||||||
|
|
||||||
Skalierbar von kleinen Raspberry-Pi-Installationen bis hin zu hochverfügbaren Multi-Node-Setups.
|
|
||||||
|
|
||||||
- **Website:** [nextcloud.com](https://nextcloud.com/)
|
|
||||||
- **Demo/Projektinstanz:** [cloud.cc24.dev](https://cloud.cc24.dev)
|
|
||||||
- **Statusseite:** [Mikoshi Status](https://status.mikoshi.de/api/badge/11/status)
|
|
||||||
- **Lizenz:** AGPL-3.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- Linux-Server oder Raspberry Pi
|
|
||||||
- PHP 8.1 oder höher
|
|
||||||
- MariaDB/PostgreSQL
|
|
||||||
- Webserver (Apache/Nginx)
|
|
||||||
- SSL-Zertifikat (empfohlen: Let's Encrypt)
|
|
||||||
|
|
||||||
### Installationsschritte (Ubuntu Beispiel)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update && sudo apt upgrade
|
|
||||||
sudo apt install apache2 mariadb-server libapache2-mod-php php php-mysql \
|
|
||||||
php-gd php-xml php-mbstring php-curl php-zip php-intl php-bcmath unzip
|
|
||||||
|
|
||||||
wget https://download.nextcloud.com/server/releases/latest.zip
|
|
||||||
unzip latest.zip -d /var/www/
|
|
||||||
chown -R www-data:www-data /var/www/nextcloud
|
|
||||||
````
|
|
||||||
|
|
||||||
Danach den Web-Installer im Browser aufrufen (`https://<your-domain>/nextcloud`) und Setup abschließen.
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
* **Trusted Domains** in `config.php` definieren
|
|
||||||
* SSO mit OpenID Connect aktivieren
|
|
||||||
* Dateiverschlüsselung aktivieren (`Settings → Security`)
|
|
||||||
* Benutzer und Gruppen über LDAP oder SAML integrieren
|
|
||||||
|
|
||||||
## Verwendungsbeispiele
|
|
||||||
|
|
||||||
### Gemeinsame Fallbearbeitung
|
|
||||||
|
|
||||||
1. Ermittlungsordner als geteiltes Gruppenverzeichnis anlegen
|
|
||||||
2. Versionierung und Kommentare zu forensischen Berichten aktivieren
|
|
||||||
3. Vorschau für Office-Dateien, PDFs und Bilder direkt im Browser nutzen
|
|
||||||
|
|
||||||
### Videokonferenzen mit "Nextcloud Talk"
|
|
||||||
|
|
||||||
* Sichere Kommunikation zwischen Ermittlern und Sachverständigen
|
|
||||||
* Ende-zu-Ende-verschlüsselt
|
|
||||||
* Bildschirmfreigabe möglich
|
|
||||||
|
|
||||||
### Automatischer Dateiimport per API
|
|
||||||
|
|
||||||
* REST-Schnittstelle nutzen, um z. B. automatisch Logdateien oder Exportdaten hochzuladen
|
|
||||||
* Ideal für Anbindung an SIEM, DLP oder Analyse-Pipelines
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
* Zwei-Faktor-Authentifizierung aktivieren
|
|
||||||
* Tägliche Backups der Datenbank und Datenstruktur
|
|
||||||
* Nutzung von OnlyOffice oder Collabora für revisionssichere Dokumentenbearbeitung
|
|
||||||
* Zugriff regelmäßig überprüfen, insbesondere bei externen Partnern
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Problem: Langsame Performance
|
|
||||||
|
|
||||||
**Lösung:** APCu aktivieren und Caching optimieren (`config.php → 'memcache.local'`).
|
|
||||||
|
|
||||||
### Problem: Dateien erscheinen nicht im Sync
|
|
||||||
|
|
||||||
**Lösung:** Cronjob für `files:scan` konfigurieren oder manuell ausführen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u www-data php /var/www/nextcloud/occ files:scan --all
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: Fehlermeldung "Trusted domain not set"
|
|
||||||
|
|
||||||
**Lösung:** In `config/config.php` Eintrag `trusted_domains` korrekt konfigurieren:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'trusted_domains' =>
|
|
||||||
array (
|
|
||||||
0 => 'yourdomain.tld',
|
|
||||||
1 => 'cloud.cc24.dev',
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
## Weiterführende Themen
|
|
||||||
|
|
||||||
* **Integration mit Forensik-Plattformen** (über WebDAV, API oder SSO)
|
|
||||||
* **Custom Apps entwickeln** für spezielle Ermittlungs-Workflows
|
|
||||||
* **Auditing aktivieren**: Nutzung und Änderungen nachvollziehen mit Protokollierungsfunktionen
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Regular Expressions (Regex) – Musterbasierte Textanalyse"
|
|
||||||
tool_name: "Regular Expressions (Regex)"
|
|
||||||
description: "Pattern matching language für Suche, Extraktion und Manipulation von Text in forensischen Analysen."
|
|
||||||
last_updated: 2025-07-20
|
|
||||||
author: "Claude 4 Sonnet"
|
|
||||||
difficulty: "intermediate"
|
|
||||||
categories: ["incident-response", "malware-analysis", "network-forensics", "fraud-investigation"]
|
|
||||||
tags: ["pattern-matching", "text-processing", "log-analysis", "string-manipulation", "search-algorithms"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: false
|
|
||||||
configuration: false
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: false
|
|
||||||
advanced_topics: true
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
**Regular Expressions (Regex)** sind ein leistungsfähiges Werkzeug zur Erkennung, Extraktion und Transformation von Zeichenfolgen anhand vordefinierter Muster. In der digitalen Forensik sind Regex-Ausdrücke unverzichtbar: Sie helfen beim Auffinden von IP-Adressen, Hash-Werten, Dateipfaden, Malware-Signaturen oder Kreditkartennummern in großen Mengen unstrukturierter Daten wie Logdateien, Netzwerktraces oder Memory Dumps.
|
|
||||||
|
|
||||||
Regex ist nicht auf eine bestimmte Plattform oder Software beschränkt – es wird in nahezu allen gängigen Programmiersprachen, Texteditoren und forensischen Tools unterstützt.
|
|
||||||
|
|
||||||
## Verwendungsbeispiele
|
|
||||||
|
|
||||||
### 1. IP-Adressen extrahieren
|
|
||||||
|
|
||||||
```regex
|
|
||||||
\b(?:\d{1,3}\.){3}\d{1,3}\b
|
|
||||||
````
|
|
||||||
|
|
||||||
Verwendung:
|
|
||||||
|
|
||||||
* Finden von IP-Adressen in Firewall-Logs oder Packet Captures.
|
|
||||||
* Beispiel-Zeile:
|
|
||||||
|
|
||||||
```
|
|
||||||
Connection from 192.168.1.101 to port 443 established
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. E-Mail-Adressen identifizieren
|
|
||||||
|
|
||||||
```regex
|
|
||||||
[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}
|
|
||||||
```
|
|
||||||
|
|
||||||
Verwendung:
|
|
||||||
|
|
||||||
* Erkennung von kompromittierten Accounts in Phishing-E-Mails.
|
|
||||||
* Analyse von Useraktivitäten oder Kommunikationsverläufen.
|
|
||||||
|
|
||||||
### 3. Hash-Werte erkennen (z. B. SHA-256)
|
|
||||||
|
|
||||||
```regex
|
|
||||||
\b[A-Fa-f0-9]{64}\b
|
|
||||||
```
|
|
||||||
|
|
||||||
Verwendung:
|
|
||||||
|
|
||||||
* Extraktion von Malware-Hashes aus Memory Dumps oder YARA-Logs.
|
|
||||||
|
|
||||||
### 4. Zeitstempel in Logdateien extrahieren
|
|
||||||
|
|
||||||
```regex
|
|
||||||
\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}
|
|
||||||
```
|
|
||||||
|
|
||||||
Verwendung:
|
|
||||||
|
|
||||||
* Zeitsensitive Korrelationsanalysen (z. B. bei Intrusion Detection oder Timeline-Rekonstruktionen).
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
* **Regex testen**: Nutze Plattformen wie [regexr.com](https://regexr.com/) oder [regex101.com](https://regex101.com/) zur Validierung.
|
|
||||||
* **Performance beachten**: Komplexe Ausdrücke können ineffizient sein und Systeme verlangsamen – verwende Lazy Quantifiers (`*?`, `+?`) bei Bedarf.
|
|
||||||
* **Escape-Zeichen korrekt anwenden**: Spezielle Zeichen wie `.` oder `\` müssen bei Bedarf mit `\\` oder `\.` maskiert werden.
|
|
||||||
* **Portabilität prüfen**: Unterschiedliche Regex-Engines (z. B. Python `re`, PCRE, JavaScript) interpretieren manche Syntax leicht unterschiedlich.
|
|
||||||
* **Lesbarkeit fördern**: Verwende benannte Gruppen (`(?P<name>...)`) und Kommentare (`(?x)`), um reguläre Ausdrücke besser wartbar zu machen.
|
|
||||||
|
|
||||||
## Weiterführende Themen
|
|
||||||
|
|
||||||
### Lookaheads und Lookbehinds
|
|
||||||
|
|
||||||
Mit **Lookaheads** (`(?=...)`) und **Lookbehinds** (`(?<=...)`) können Bedingungen formuliert werden, ohne dass der Text Teil des Matchs wird.
|
|
||||||
|
|
||||||
Beispiel: Alle `.exe`-Dateinamen **ohne** das Wort `safe` davor matchen:
|
|
||||||
|
|
||||||
```regex
|
|
||||||
(?<!safe\s)[\w-]+\.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
### Regex in Forensik-Tools
|
|
||||||
|
|
||||||
* **YARA**: Unterstützt Regex zur Erstellung von Malware-Signaturen.
|
|
||||||
* **Wireshark**: Filtert Payloads anhand von Regex-ähnlicher Syntax.
|
|
||||||
* **Splunk & ELK**: Verwenden Regex für Logparsing und Visualisierung.
|
|
||||||
* **Volatility Plugins**: Extrahieren Artefakte mit Regex-basierten Scans.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 🔤 **Regex ist ein universelles Werkzeug für Analysten, Ermittler und Entwickler, um versteckte Informationen schnell und flexibel aufzuspüren.**
|
|
||||||
>
|
|
||||||
> Nutze es überall dort, wo Textdaten eine Rolle spielen.
|
|
||||||
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Velociraptor – Skalierbare Endpoint-Forensik mit VQL"
|
|
||||||
tool_name: "Velociraptor"
|
|
||||||
description: "Detaillierte Anleitung und Best Practices für Velociraptor – Remote-Forensik der nächsten Generation"
|
|
||||||
last_updated: 2025-07-20
|
|
||||||
author: "Claude 4 Sonnet"
|
|
||||||
difficulty: "advanced"
|
|
||||||
categories: ["incident-response", "malware-analysis", "network-forensics"]
|
|
||||||
tags: ["web-based", "endpoint-monitoring", "artifact-extraction", "scripting", "live-forensics", "hunting"]
|
|
||||||
sections:
|
|
||||||
overview: true
|
|
||||||
installation: true
|
|
||||||
configuration: true
|
|
||||||
usage_examples: true
|
|
||||||
best_practices: true
|
|
||||||
troubleshooting: true
|
|
||||||
advanced_topics: true
|
|
||||||
review_status: "published"
|
|
||||||
---
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
|
||||||
|
|
||||||
|
|
||||||
# Übersicht
|
|
||||||
|
|
||||||
Velociraptor ist ein Open-Source-Tool zur Endpoint-Forensik mit Fokus auf Skalierbarkeit, Präzision und Geschwindigkeit. Es ermöglicht die zielgerichtete Erfassung und Analyse digitaler Artefakte über eine eigene Query Language – VQL (Velociraptor Query Language). Die Architektur erlaubt remote Zugriff auf tausende Endpoints gleichzeitig, ohne dass vollständige Disk-Images erforderlich sind.
|
|
||||||
|
|
||||||
## Hauptmerkmale
|
|
||||||
|
|
||||||
- 🌐 Web-basierte Benutzeroberfläche
|
|
||||||
- 💡 VQL – mächtige, SQL-ähnliche Abfragesprache
|
|
||||||
- 🚀 Hochskalierbare Hunt-Funktionalität
|
|
||||||
- 🔍 Artefaktbasierte Sammlung (ohne Full-Image)
|
|
||||||
- 🖥️ Plattformunterstützung für Windows, macOS, Linux
|
|
||||||
- 📦 Apache 2.0 Lizenz – Open Source
|
|
||||||
|
|
||||||
Weitere Infos: [velociraptor.app](https://www.velociraptor.app/)
|
|
||||||
Projektspiegel: [raptor.cc24.dev](https://raptor.cc24.dev)
|
|
||||||
Status: 
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- Python ≥ 3.9
|
|
||||||
- Adminrechte auf dem System
|
|
||||||
- Firewall-Freigaben für Webport (Standard: 8000)
|
|
||||||
|
|
||||||
### Installation unter Linux/macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://github.com/Velocidex/velociraptor/releases/latest/download/velociraptor
|
|
||||||
chmod +x velociraptor
|
|
||||||
sudo mv velociraptor /usr/local/bin/
|
|
||||||
````
|
|
||||||
|
|
||||||
### Installation unter Windows
|
|
||||||
|
|
||||||
1. Download der `.exe` von der [Release-Seite](https://github.com/Velocidex/velociraptor/releases)
|
|
||||||
2. Ausführung in PowerShell mit Adminrechten:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\velociraptor.exe config generate > server.config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Server Setup
|
|
||||||
|
|
||||||
1. Generiere die Konfigurationsdatei:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
velociraptor config generate > server.config.yaml
|
|
||||||
```
|
|
||||||
2. Starte den Server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
velociraptor --config server.config.yaml frontend
|
|
||||||
```
|
|
||||||
3. Zugriff über Browser via `https://<hostname>:8000`
|
|
||||||
|
|
||||||
### Client Deployment
|
|
||||||
|
|
||||||
* MSI/EXE für Windows, oder `deb/rpm` für Linux
|
|
||||||
* Unterstützt automatische Registrierung am Server
|
|
||||||
* Deployment über GPO, Puppet, Ansible etc. möglich
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verwendungsbeispiele
|
|
||||||
|
|
||||||
### 1. Live-Memory-Artefakte sammeln
|
|
||||||
|
|
||||||
```vql
|
|
||||||
SELECT * FROM Artifact.MemoryInfo()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Hunt starten auf verdächtige Prozesse
|
|
||||||
|
|
||||||
```vql
|
|
||||||
SELECT * FROM pslist()
|
|
||||||
WHERE Name =~ "mimikatz|cobaltstrike"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Dateiinhalt extrahieren
|
|
||||||
|
|
||||||
```vql
|
|
||||||
SELECT * FROM glob(globs="C:\\Users\\*\\AppData\\*.dat")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
* Erstelle eigene Artefakte für unternehmensspezifische Bedrohungsmodelle
|
|
||||||
* Verwende "Notebook"-Funktion für strukturierte Analysen
|
|
||||||
* Nutze "Labels", um Endpoints zu organisieren (z. B. `location:Berlin`)
|
|
||||||
* Kombiniere Velociraptor mit SIEM/EDR-Systemen über REST API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Problem: Keine Verbindung vom Client zum Server
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
* Ports freigegeben? (Default: 8000/tcp)
|
|
||||||
* TLS-Zertifikate korrekt generiert?
|
|
||||||
* `server.config.yaml` auf korrekte `public_ip` prüfen
|
|
||||||
|
|
||||||
### Problem: Hunt hängt in Warteschleife
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
* Genügend Worker-Prozesse aktiv?
|
|
||||||
* Endpoint online?
|
|
||||||
* `log_level` auf `debug` setzen und Log analysieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Weiterführende Themen
|
|
||||||
|
|
||||||
* Eigene Artefakte schreiben mit VQL
|
|
||||||
* Integration mit ELK Stack
|
|
||||||
* Automatisiertes Incident Response Playbook
|
|
||||||
* Velociraptor als IR-as-a-Service einsetzen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🧠 **Tipp:** Die Lernkurve bei VQL ist steil – aber mit hohem ROI. Testumgebung aufsetzen und mit Community-Artefakten starten.
|
|
||||||
|
|
||||||
📚 Weitere Ressourcen:
|
|
||||||
|
|
||||||
* [Offizielle Doku](https://docs.velociraptor.app/)
|
|
||||||
* [YouTube Channel](https://www.youtube.com/c/VelociraptorDFIR)
|
|
||||||
* [Community auf Discord](https://www.velociraptor.app/community/)
|
|
||||||
5849
src/data/tools.yaml
5849
src/data/tools.yaml
File diff suppressed because it is too large
Load Diff
14
src/env.d.ts
vendored
14
src/env.d.ts
vendored
@@ -11,6 +11,9 @@ declare global {
|
|||||||
showToolDetails: (toolName: string, modalType?: string) => void;
|
showToolDetails: (toolName: string, modalType?: string) => void;
|
||||||
hideToolDetails: (modalType?: string) => void;
|
hideToolDetails: (modalType?: string) => void;
|
||||||
hideAllToolDetails: () => void;
|
hideAllToolDetails: () => void;
|
||||||
|
matrixShowToolDetails?: (toolName: string, modalType?: string) => void;
|
||||||
|
matrixHideToolDetails?: (modalType?: string) => void;
|
||||||
|
|
||||||
toggleKbEntry: (entryId: string) => void;
|
toggleKbEntry: (entryId: string) => void;
|
||||||
toggleDomainAgnosticSection: (sectionId: string) => void;
|
toggleDomainAgnosticSection: (sectionId: string) => void;
|
||||||
restoreAIResults?: () => void;
|
restoreAIResults?: () => void;
|
||||||
@@ -22,17 +25,15 @@ declare global {
|
|||||||
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
||||||
isToolHosted: (tool: any) => boolean;
|
isToolHosted: (tool: any) => boolean;
|
||||||
|
|
||||||
checkClientAuth: (context?: string) => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
checkClientAuth: (context?: 'contributions' | 'ai' | 'general' | 'gatedcontent') => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
||||||
requireClientAuth: (callback?: () => void, returnUrl?: string, context?: string) => Promise<boolean>;
|
requireClientAuth: (callback?: () => void, returnUrl?: string, context?: 'contributions' | 'ai' | 'general' | 'gatedcontent') => Promise<boolean>;
|
||||||
showIfAuthenticated: (selector: string, context?: string) => Promise<void>;
|
showIfAuthenticated: (selector: string, context?: 'contributions' | 'ai' | 'general' | 'gatedcontent') => Promise<void>;
|
||||||
setupAuthButtons: (selector?: string) => void;
|
setupAuthButtons: (selector?: string) => void;
|
||||||
|
|
||||||
// Consolidated scroll utilities
|
|
||||||
scrollToElement: (element: Element | null, options?: ScrollIntoViewOptions) => void;
|
scrollToElement: (element: Element | null, options?: ScrollIntoViewOptions) => void;
|
||||||
scrollToElementById: (elementId: string, options?: ScrollIntoViewOptions) => void;
|
scrollToElementById: (elementId: string, options?: ScrollIntoViewOptions) => void;
|
||||||
scrollToElementBySelector: (selector: string, options?: ScrollIntoViewOptions) => void;
|
scrollToElementBySelector: (selector: string, options?: ScrollIntoViewOptions) => void;
|
||||||
|
|
||||||
// Additional global functions that might be called
|
|
||||||
applyScenarioSearch?: (scenarioId: string) => void;
|
applyScenarioSearch?: (scenarioId: string) => void;
|
||||||
selectPhase?: (phase: string) => void;
|
selectPhase?: (phase: string) => void;
|
||||||
selectApproach?: (approach: string) => void;
|
selectApproach?: (approach: string) => void;
|
||||||
@@ -41,6 +42,9 @@ declare global {
|
|||||||
toggleAllScenarios?: () => void;
|
toggleAllScenarios?: () => void;
|
||||||
showShareDialog?: (shareButton: Element) => void;
|
showShareDialog?: (shareButton: Element) => void;
|
||||||
modalHideInProgress?: boolean;
|
modalHideInProgress?: boolean;
|
||||||
|
|
||||||
|
shareArticle: (button: HTMLElement, url: string, title: string) => Promise<void>;
|
||||||
|
shareCurrentArticle: (button: HTMLElement) => Promise<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import Navigation from '../components/Navigation.astro';
|
import Navigation from '../components/Navigation.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
import '../styles/auditTrail.css';
|
||||||
|
import '../styles/knowledgebase.css';
|
||||||
|
import '../styles/palette.css';
|
||||||
|
import '../styles/autocomplete.css';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -21,46 +25,50 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Move utility functions OUTSIDE DOMContentLoaded to avoid race conditions
|
async function loadUtilityFunctions() {
|
||||||
function createToolSlug(toolName) {
|
try {
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
const { createToolSlug, findToolByIdentifier, isToolHosted } = await import('../utils/clientUtils.js');
|
||||||
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
|
|
||||||
return '';
|
(window as any).createToolSlug = createToolSlug;
|
||||||
|
(window as any).findToolByIdentifier = findToolByIdentifier;
|
||||||
|
(window as any).isToolHosted = isToolHosted;
|
||||||
|
|
||||||
|
console.log('[UTILS] Utility functions loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load utility functions:', error);
|
||||||
|
|
||||||
|
(window as any).createToolSlug = (toolName: string) => {
|
||||||
|
if (!toolName || typeof toolName !== 'string') return '';
|
||||||
|
return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).findToolByIdentifier = (tools: any[], identifier: string) => {
|
||||||
|
if (!identifier || !Array.isArray(tools)) return undefined;
|
||||||
|
return tools.find((tool: any) =>
|
||||||
|
tool.name === identifier ||
|
||||||
|
(window as any).createToolSlug(tool.name) === identifier.toLowerCase()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).isToolHosted = (tool: any) => {
|
||||||
|
return tool.projectUrl !== undefined &&
|
||||||
|
tool.projectUrl !== null &&
|
||||||
|
tool.projectUrl !== "" &&
|
||||||
|
tool.projectUrl.trim() !== "";
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[UTILS] Fallback utility functions registered');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return toolName.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
function findToolByIdentifier(tools, identifier) {
|
function scrollToElement(element: Element | null, options = {}) {
|
||||||
if (!identifier || !Array.isArray(tools)) return undefined;
|
|
||||||
|
|
||||||
return tools.find(tool =>
|
|
||||||
tool.name === identifier ||
|
|
||||||
createToolSlug(tool.name) === identifier.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToolHosted(tool) {
|
|
||||||
return tool.projectUrl !== undefined &&
|
|
||||||
tool.projectUrl !== null &&
|
|
||||||
tool.projectUrl !== "" &&
|
|
||||||
tool.projectUrl.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consolidated scrolling utility - also moved outside DOMContentLoaded
|
|
||||||
function scrollToElement(element, options = {}) {
|
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
// Calculate target position manually to avoid double-scroll
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const headerHeight = document.querySelector('nav')?.offsetHeight || 80;
|
const headerHeight = document.querySelector('nav')?.offsetHeight || 80;
|
||||||
const elementRect = element.getBoundingClientRect();
|
const elementRect = element.getBoundingClientRect();
|
||||||
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
||||||
const targetPosition = absoluteElementTop - headerHeight - 20; // Adjust this 20 as needed
|
const targetPosition = absoluteElementTop - headerHeight - 20;
|
||||||
|
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: targetPosition,
|
top: targetPosition,
|
||||||
@@ -69,19 +77,21 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience functions for common scroll targets
|
function scrollToElementById(elementId: string, options = {}) {
|
||||||
function scrollToElementById(elementId, options = {}) {
|
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
scrollToElement(element, options);
|
if (element) {
|
||||||
|
scrollToElement(element, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToElementBySelector(selector, options = {}) {
|
function scrollToElementBySelector(selector: string, options = {}) {
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
scrollToElement(element, options);
|
if (element) {
|
||||||
|
scrollToElement(element, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple search prioritization - exact tag matches first
|
function prioritizeSearchResults(tools: any[], searchTerm: string) {
|
||||||
function prioritizeSearchResults(tools, searchTerm) {
|
|
||||||
if (!searchTerm || !searchTerm.trim()) {
|
if (!searchTerm || !searchTerm.trim()) {
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
@@ -89,31 +99,27 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
const lowerSearchTerm = searchTerm.toLowerCase().trim();
|
const lowerSearchTerm = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
return tools.sort((a, b) => {
|
return tools.sort((a, b) => {
|
||||||
const aTagsLower = (a.tags || []).map(tag => tag.toLowerCase());
|
const aTagsLower = (a.tags || []).map((tag: string) => tag.toLowerCase());
|
||||||
const bTagsLower = (b.tags || []).map(tag => tag.toLowerCase());
|
const bTagsLower = (b.tags || []).map((tag: string) => tag.toLowerCase());
|
||||||
|
|
||||||
const aExactTag = aTagsLower.includes(lowerSearchTerm);
|
const aExactTag = aTagsLower.includes(lowerSearchTerm);
|
||||||
const bExactTag = bTagsLower.includes(lowerSearchTerm);
|
const bExactTag = bTagsLower.includes(lowerSearchTerm);
|
||||||
|
|
||||||
// If one has exact tag match and other doesn't, prioritize the exact match
|
|
||||||
if (aExactTag && !bExactTag) return -1;
|
if (aExactTag && !bExactTag) return -1;
|
||||||
if (!aExactTag && bExactTag) return 1;
|
if (!aExactTag && bExactTag) return 1;
|
||||||
|
|
||||||
// Otherwise maintain original order (or sort by name as secondary)
|
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach to window immediately - BEFORE DOMContentLoaded
|
|
||||||
(window as any).createToolSlug = createToolSlug;
|
|
||||||
(window as any).findToolByIdentifier = findToolByIdentifier;
|
|
||||||
(window as any).isToolHosted = isToolHosted;
|
|
||||||
(window as any).scrollToElement = scrollToElement;
|
(window as any).scrollToElement = scrollToElement;
|
||||||
(window as any).scrollToElementById = scrollToElementById;
|
(window as any).scrollToElementById = scrollToElementById;
|
||||||
(window as any).scrollToElementBySelector = scrollToElementBySelector;
|
(window as any).scrollToElementBySelector = scrollToElementBySelector;
|
||||||
(window as any).prioritizeSearchResults = prioritizeSearchResults;
|
(window as any).prioritizeSearchResults = prioritizeSearchResults;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await loadUtilityFunctions();
|
||||||
|
|
||||||
const THEME_KEY = 'dfir-theme';
|
const THEME_KEY = 'dfir-theme';
|
||||||
|
|
||||||
function getSystemTheme() {
|
function getSystemTheme() {
|
||||||
@@ -124,12 +130,12 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
return localStorage.getItem(THEME_KEY) || 'auto';
|
return localStorage.getItem(THEME_KEY) || 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme) {
|
function applyTheme(theme: string) {
|
||||||
const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
|
const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateThemeToggle(theme) {
|
function updateThemeToggle(theme: string) {
|
||||||
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
||||||
button.setAttribute('data-current-theme', theme);
|
button.setAttribute('data-current-theme', theme);
|
||||||
});
|
});
|
||||||
@@ -164,6 +170,43 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
toggleTheme,
|
toggleTheme,
|
||||||
getStoredTheme
|
getStoredTheme
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50;
|
||||||
|
|
||||||
|
const tryDelegate = () => {
|
||||||
|
const matrixShowToolDetails = (window as any).matrixShowToolDetails;
|
||||||
|
|
||||||
|
if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
|
||||||
|
return matrixShowToolDetails(toolName, modalType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directShowToolDetails = (window as any).directShowToolDetails;
|
||||||
|
if (directShowToolDetails && typeof directShowToolDetails === 'function') {
|
||||||
|
return directShowToolDetails(toolName, modalType);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(tryDelegate, 100);
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tryDelegate();
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).hideToolDetails = function(modalType: string = 'both') {
|
||||||
|
const matrixHideToolDetails = (window as any).matrixHideToolDetails;
|
||||||
|
if (matrixHideToolDetails && typeof matrixHideToolDetails === 'function') {
|
||||||
|
return matrixHideToolDetails(modalType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).hideAllToolDetails = function() {
|
||||||
|
(window as any).hideToolDetails('both');
|
||||||
|
};
|
||||||
|
|
||||||
async function checkClientAuth(context = 'general') {
|
async function checkClientAuth(context = 'general') {
|
||||||
try {
|
try {
|
||||||
@@ -183,6 +226,12 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
authRequired: data.aiAuthRequired,
|
authRequired: data.aiAuthRequired,
|
||||||
expires: data.expires
|
expires: data.expires
|
||||||
};
|
};
|
||||||
|
case 'gatedcontent':
|
||||||
|
return {
|
||||||
|
authenticated: data.gatedContentAuthenticated,
|
||||||
|
authRequired: data.gatedContentAuthRequired,
|
||||||
|
expires: data.expires
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
authenticated: data.authenticated,
|
authenticated: data.authenticated,
|
||||||
@@ -199,7 +248,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireClientAuth(callback, returnUrl, context = 'general') {
|
async function requireClientAuth(callback: () => void, returnUrl: string, context = 'general') {
|
||||||
const authStatus = await checkClientAuth(context);
|
const authStatus = await checkClientAuth(context);
|
||||||
|
|
||||||
if (authStatus.authRequired && !authStatus.authenticated) {
|
if (authStatus.authRequired && !authStatus.authenticated) {
|
||||||
@@ -214,12 +263,12 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showIfAuthenticated(selector, context = 'general') {
|
async function showIfAuthenticated(selector: string, context = 'general') {
|
||||||
const authStatus = await checkClientAuth(context);
|
const authStatus = await checkClientAuth(context);
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.style.display = (!authStatus.authRequired || authStatus.authenticated)
|
(element as HTMLElement).style.display = (!authStatus.authRequired || authStatus.authenticated)
|
||||||
? 'inline-flex'
|
? 'inline-flex'
|
||||||
: 'none';
|
: 'none';
|
||||||
}
|
}
|
||||||
@@ -248,6 +297,51 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
(window as any).showIfAuthenticated = showIfAuthenticated;
|
(window as any).showIfAuthenticated = showIfAuthenticated;
|
||||||
(window as any).setupAuthButtons = setupAuthButtons;
|
(window as any).setupAuthButtons = setupAuthButtons;
|
||||||
|
|
||||||
|
async function copyUrlToClipboard(url: string, button: HTMLElement) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = `
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
|
||||||
|
<polyline points="20,6 9,17 4,12"/>
|
||||||
|
</svg>
|
||||||
|
Kopiert!
|
||||||
|
`;
|
||||||
|
button.style.color = 'var(--color-accent)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.style.color = '';
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = url;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = 'Kopiert!';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareArticle(button: HTMLElement, url: string, title: string) {
|
||||||
|
const fullUrl = window.location.origin + url;
|
||||||
|
await copyUrlToClipboard(fullUrl, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareCurrentArticle(button: HTMLElement) {
|
||||||
|
await copyUrlToClipboard(window.location.href, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).shareArticle = shareArticle;
|
||||||
|
(window as any).shareCurrentArticle = shareCurrentArticle;
|
||||||
|
|
||||||
initTheme();
|
initTheme();
|
||||||
setupAuthButtons('[data-contribute-button]');
|
setupAuthButtons('[data-contribute-button]');
|
||||||
|
|
||||||
@@ -255,9 +349,30 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
await showIfAuthenticated('#ai-view-toggle', 'ai');
|
await showIfAuthenticated('#ai-view-toggle', 'ai');
|
||||||
};
|
};
|
||||||
initAIButton();
|
initAIButton();
|
||||||
|
|
||||||
console.log('[CONSOLIDATED] All utilities loaded and initialized');
|
|
||||||
});
|
});
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') ||
|
||||||
|
navigator.userAgent.toLowerCase().includes('librewolf');
|
||||||
|
|
||||||
|
if (isFirefox) {
|
||||||
|
console.log('[VIDEO] Firefox detected - setting up error recovery');
|
||||||
|
|
||||||
|
document.querySelectorAll('video').forEach(video => {
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
errorCount++;
|
||||||
|
console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
const title = video.getAttribute('data-video-title') || 'Video';
|
||||||
|
console.log(`[VIDEO] Successfully loaded: ${title}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
|
|
||||||
<div style="display: grid; gap: 1.25rem;">
|
<div style="display: grid; gap: 1.25rem;">
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4>
|
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">📝 Vorschläge</h4>
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Du hast eine Idee, wie wir den Hub erweitern können? Reiche deinen Vorschlag unkompliziert
|
Du hast eine Idee, wie wir den Hub erweitern können? Reiche deinen Vorschlag unkompliziert
|
||||||
über unsere <a href="/contribute#vorschlaege">/contribute</a>-Seite ein.
|
über unsere <a href="/contribute#vorschlaege">/contribute</a>-Seite ein.
|
||||||
@@ -210,15 +210,54 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
</svg>
|
</svg>
|
||||||
Git‑Repository besuchen
|
Git-Repository besuchen
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightning Support Section with simple-boost integration -->
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">⚡ Unterstützung</h4>
|
<h4 style="margin: 0 0 0.75rem 0; color: var(--color-accent); display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<p style="margin: 0;">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
Kleine Spenden zur Infrastruktur-Finanzierung nehme ich auch gerne an, wenn es sein muss.
|
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10 13,2"/>
|
||||||
Fragt einfach nach der Lightning-Adresse oder BTC-Adresse!
|
</svg>
|
||||||
|
⚡ Unterstützung
|
||||||
|
</h4>
|
||||||
|
<p style="margin: 0 0 1rem 0; font-size: 0.875rem; line-height: 1.5;">
|
||||||
|
Kleine Spenden zur Server-Finanzierung sind willkommen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<!-- Simple EUR Payment -->
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: center; justify-content: center; max-width: 300px; margin: 0 auto;">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="eur-amount"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0,50"
|
||||||
|
value="0.5"
|
||||||
|
style="width: 80px; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.375rem; font-size: 0.875rem; text-align: center;">
|
||||||
|
<span style="font-size: 0.875rem; color: var(--color-text-secondary);">€</span>
|
||||||
|
<simple-boost
|
||||||
|
id="eur-boost"
|
||||||
|
class="bootstrap"
|
||||||
|
nwc="nostr+walletconnect://4fe05896e1faf09d1902ea24ef589f65a9606d1710420a9574ce331e3c7f486b?relay=wss://nostr.mikoshi.de&secret=bdfc861fe71e8d9e375b7a2484052e92def7caf4b317d8f6537b784d3cd6eb3b"
|
||||||
|
amount="0.5"
|
||||||
|
currency="eur"
|
||||||
|
memo="ForensicPathways Unterstützung - Vielen Dank!"
|
||||||
|
style="background-color: var(--color-accent); color: white; border: none; border-radius: 0.375rem; padding: 0.5rem 1rem; font-size: 0.875rem; cursor: pointer;">
|
||||||
|
⚡ Senden
|
||||||
|
</simple-boost>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem; padding: 0.75rem; background-color: var(--color-bg); border-radius: 0.375rem; border-left: 3px solid var(--color-accent);">
|
||||||
|
<p style="margin: 0; font-size: 0.75rem; color: var(--color-text-secondary); line-height: 1.4; text-align: center;">
|
||||||
|
<strong>⚡ Lightning-Unterstützung:</strong> Betrag eingeben und senden.
|
||||||
|
Benötigt eine Lightning-Wallet wie <a href="https://getalby.com" target="_blank" rel="noopener" style="color: var(--color-accent);">Alby</a> oder
|
||||||
|
<a href="https://phoenix.acinq.co" target="_blank" rel="noopener" style="color: var(--color-accent);">Phoenix</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,4 +270,70 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// TODO: cleanup
|
||||||
|
import('simple-boost').then(() => {
|
||||||
|
console.log('Simple-boost loaded successfully from local dependencies');
|
||||||
|
|
||||||
|
setupDynamicAmounts();
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Failed to load simple-boost:', error);
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'module';
|
||||||
|
script.src = '/node_modules/simple-boost/dist/simple-boost.js';
|
||||||
|
script.onload = () => {
|
||||||
|
console.log('Simple-boost fallback loaded');
|
||||||
|
setupDynamicAmounts();
|
||||||
|
};
|
||||||
|
script.onerror = () => console.error('Simple-boost fallback failed');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDynamicAmounts() {
|
||||||
|
const eurBoost = document.getElementById('eur-boost');
|
||||||
|
const eurInput = document.getElementById('eur-amount') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (eurBoost && eurInput) {
|
||||||
|
eurBoost.addEventListener('click', (e) => {
|
||||||
|
const amount = parseFloat(eurInput.value) || 0.5;
|
||||||
|
eurBoost.setAttribute('amount', amount.toString());
|
||||||
|
console.log('EUR amount set to:', amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
eurInput.addEventListener('input', () => {
|
||||||
|
const amount = parseFloat(eurInput.value) || 0.5;
|
||||||
|
eurBoost.setAttribute('amount', amount.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
simple-boost {
|
||||||
|
--simple-boost-primary: var(--color-warning);
|
||||||
|
--simple-boost-primary-hover: var(--color-accent);
|
||||||
|
--simple-boost-text: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
simple-boost:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
simple-boost .simple-boost-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state styling */
|
||||||
|
simple-boost[loading] {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
src/pages/api/ai/embeddings-status.ts
Normal file
41
src/pages/api/ai/embeddings-status.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// src/pages/api/ai/embeddings-status.ts
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { embeddingsService } from '../../../utils/embeddings.js';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
await embeddingsService.waitForInitialization();
|
||||||
|
|
||||||
|
const stats = embeddingsService.getStats();
|
||||||
|
const status = stats.initialized ? 'ready' :
|
||||||
|
!stats.initialized ? 'initializing' : 'disabled';
|
||||||
|
|
||||||
|
console.log(`[EMBEDDINGS-STATUS-API] Service status: ${status}, stats:`, stats);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
embeddings: stats,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: status
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EMBEDDINGS-STATUS-API] Error checking embeddings status:', error);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
embeddings: { enabled: false, initialized: false, count: 0 },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'disabled',
|
||||||
|
error: error.message
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// src/pages/api/ai/embeddings-status.ts
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { embeddingsService } from '../../../utils/embeddings.js';
|
|
||||||
import { apiResponse, apiServerError } from '../../../utils/api.js';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
|
||||||
try {
|
|
||||||
const stats = embeddingsService.getStats();
|
|
||||||
|
|
||||||
return apiResponse.success({
|
|
||||||
embeddings: stats,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: stats.enabled && stats.initialized ? 'ready' :
|
|
||||||
stats.enabled && !stats.initialized ? 'initializing' : 'disabled'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Embeddings status error:', error);
|
|
||||||
return apiServerError.internal('Failed to get embeddings status');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,28 +1,57 @@
|
|||||||
// src/pages/api/ai/enhance-input.ts - ENHANCED with forensics methodology
|
// src/pages/api/ai/enhance-input.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
||||||
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
||||||
|
import { aiService } from '../../../utils/aiService.js';
|
||||||
|
import { JSONParser } from '../../../utils/jsonUtils.js';
|
||||||
|
import { getPrompt } from '../../../config/prompts.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
function getEnv(key: string): string {
|
const RATE_LIMIT_WINDOW_MS =
|
||||||
const value = process.env[key];
|
Number.isFinite(parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '', 10))
|
||||||
if (!value) {
|
? parseInt(process.env.RATE_LIMIT_WINDOW_MS!, 10)
|
||||||
throw new Error(`Missing environment variable: ${key}`);
|
: 60_000;
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the analyzer AI for smart prompting (smaller, faster model)
|
const RATE_LIMIT_MAX =
|
||||||
const AI_ENDPOINT = getEnv('AI_ANALYZER_ENDPOINT');
|
Number.isFinite(parseInt(process.env.AI_RATE_LIMIT_MAX_REQUESTS ?? '', 10))
|
||||||
const AI_API_KEY = getEnv('AI_ANALYZER_API_KEY');
|
? parseInt(process.env.AI_RATE_LIMIT_MAX_REQUESTS!, 10)
|
||||||
const AI_MODEL = getEnv('AI_ANALYZER_MODEL');
|
: 5;
|
||||||
|
|
||||||
|
const INPUT_MIN_CHARS = 40;
|
||||||
|
const INPUT_MAX_CHARS = 1000;
|
||||||
|
const Q_MIN_LEN = 15;
|
||||||
|
const Q_MAX_LEN = 160;
|
||||||
|
const Q_MAX_COUNT = 3;
|
||||||
|
const AI_TEMPERATURE = 0.3;
|
||||||
|
const CLEANER_TEMPERATURE = 0.0;
|
||||||
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
|
||||||
const RATE_LIMIT_MAX = 5; // 5 enhancement requests per minute per user
|
|
||||||
|
|
||||||
|
function checkRateLimit(userId: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(userId);
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.count >= RATE_LIMIT_MAX) return false;
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredRateLimits(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [userId, entry] of rateLimitStore.entries()) {
|
||||||
|
if (now > entry.resetTime) rateLimitStore.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers
|
||||||
|
*/
|
||||||
function sanitizeInput(input: string): string {
|
function sanitizeInput(input: string): string {
|
||||||
return input
|
return input
|
||||||
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
||||||
@@ -30,79 +59,24 @@ function sanitizeInput(input: string): string {
|
|||||||
.replace(/\b(system|assistant|user)\s*[:]/gi, '[ROLE_REMOVED]')
|
.replace(/\b(system|assistant|user)\s*[:]/gi, '[ROLE_REMOVED]')
|
||||||
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 1000); // Shorter limit for enhancement
|
.slice(0, INPUT_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRateLimit(userId: string): boolean {
|
function stripJsonFences(s: string): string {
|
||||||
const now = Date.now();
|
return s.replace(/^```json\s*/i, '')
|
||||||
const userLimit = rateLimitStore.get(userId);
|
.replace(/^```\s*/i, '')
|
||||||
|
.replace(/\s*```\s*$/, '')
|
||||||
if (!userLimit || now > userLimit.resetTime) {
|
.trim();
|
||||||
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userLimit.count >= RATE_LIMIT_MAX) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
userLimit.count++;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupExpiredRateLimits() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [userId, limit] of rateLimitStore.entries()) {
|
|
||||||
if (now > limit.resetTime) {
|
|
||||||
rateLimitStore.delete(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired limits every 5 minutes
|
|
||||||
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
function createEnhancementPrompt(input: string): string {
|
|
||||||
return `Sie sind ein DFIR-Experte mit Spezialisierung auf forensische Methodik. Ein Nutzer beschreibt ein forensisches Szenario oder Problem. Analysieren Sie die Eingabe auf Vollständigkeit für eine wissenschaftlich fundierte forensische Untersuchung.
|
|
||||||
|
|
||||||
ANALYSIEREN SIE DIESE FORENSISCHEN KATEGORIEN:
|
|
||||||
1. **Incident Context**: Was ist passiert? Welche Angriffsvektoren oder technischen Probleme liegen vor?
|
|
||||||
2. **Affected Systems**: Welche spezifischen Technologien/Plattformen sind betroffen? (Windows/Linux/ICS/SCADA/Mobile/Cloud/Network Infrastructure)
|
|
||||||
3. **Available Evidence**: Welche forensischen Datenquellen stehen zur Verfügung? (RAM-Dumps, Disk-Images, Log-Files, Network-Captures, Registry-Hives)
|
|
||||||
4. **Investigation Objectives**: Was soll erreicht werden? (IOC-Extraktion, Timeline-Rekonstruktion, Attribution, Impact-Assessment)
|
|
||||||
5. **Timeline Constraints**: Wie zeitkritisch ist die Untersuchung?
|
|
||||||
6. **Legal & Compliance**: Rechtliche Anforderungen, Chain of Custody, Compliance-Rahmen (DSGVO, sector-specific regulations)
|
|
||||||
7. **Technical Constraints**: Verfügbare Ressourcen, Skills, Infrastrukturbeschränkungen
|
|
||||||
|
|
||||||
WENN die Beschreibung alle kritischen forensischen Aspekte abdeckt: Geben Sie eine leere Liste [] zurück.
|
|
||||||
|
|
||||||
WENN wichtige forensische Details fehlen: Formulieren Sie 2-3 präzise Fragen, die die kritischsten Lücken für eine wissenschaftlich fundierte forensische Analyse schließen.
|
|
||||||
|
|
||||||
QUALITÄTSKRITERIEN FÜR FRAGEN:
|
|
||||||
- Forensisch spezifisch, nicht allgemein (❌ "Mehr Details?" ✅ "Welche forensischen Artefakte (RAM-Dumps, Disk-Images, Logs) stehen zur Verfügung?")
|
|
||||||
- Methodisch relevant (❌ "Wann passierte das?" ✅ "Liegen Log-Dateien aus dem Incident-Zeitraum vor, und welche Retention-Policy gilt?")
|
|
||||||
- Priorisiert nach Auswirkung auf die forensische Untersuchungsqualität
|
|
||||||
|
|
||||||
ANTWORTFORMAT (NUR JSON, KEIN ZUSÄTZLICHER TEXT):
|
|
||||||
[
|
|
||||||
"Forensisch spezifische Frage 1?",
|
|
||||||
"Forensisch spezifische Frage 2?",
|
|
||||||
"Forensisch spezifische Frage 3?"
|
|
||||||
]
|
|
||||||
|
|
||||||
NUTZER-EINGABE:
|
|
||||||
${input}
|
|
||||||
`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler
|
||||||
|
*/
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const authResult = await withAPIAuth(request, 'ai');
|
const auth = await withAPIAuth(request, 'ai');
|
||||||
if (!authResult.authenticated) {
|
if (!auth.authenticated) return createAuthErrorResponse();
|
||||||
return createAuthErrorResponse();
|
const userId = auth.userId;
|
||||||
}
|
|
||||||
|
|
||||||
const userId = authResult.userId;
|
|
||||||
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!checkRateLimit(userId)) {
|
||||||
return apiError.rateLimit('Enhancement rate limit exceeded');
|
return apiError.rateLimit('Enhancement rate limit exceeded');
|
||||||
@@ -111,104 +85,53 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { input } = body;
|
const { input } = body;
|
||||||
|
|
||||||
if (!input || typeof input !== 'string' || input.length < 40) {
|
if (!input || typeof input !== 'string' || input.length < INPUT_MIN_CHARS) {
|
||||||
return apiError.badRequest('Input too short for enhancement (minimum 40 characters)');
|
return apiError.badRequest(`Input too short for enhancement (minimum ${INPUT_MIN_CHARS} characters)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedInput = sanitizeInput(input);
|
const sanitizedInput = sanitizeInput(input);
|
||||||
if (sanitizedInput.length < 40) {
|
if (sanitizedInput.length < INPUT_MIN_CHARS) {
|
||||||
return apiError.badRequest('Input too short after sanitization');
|
return apiError.badRequest('Input too short after sanitization');
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = createEnhancementPrompt(sanitizedInput);
|
const taskId = `enhance_${userId}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const taskId = `enhance_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`;
|
const questionsPrompt = getPrompt('enhancementQuestions', sanitizedInput);
|
||||||
|
|
||||||
const aiResponse = await enqueueApiCall(() =>
|
|
||||||
fetch(`${AI_ENDPOINT}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${AI_API_KEY}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: AI_MODEL,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: systemPrompt
|
|
||||||
}
|
|
||||||
],
|
|
||||||
max_tokens: 300,
|
|
||||||
temperature: 0.7,
|
|
||||||
// Enhanced: Better parameters for consistent forensics output
|
|
||||||
top_p: 0.9,
|
|
||||||
frequency_penalty: 0.2,
|
|
||||||
presence_penalty: 0.1
|
|
||||||
})
|
|
||||||
}), taskId);
|
|
||||||
|
|
||||||
if (!aiResponse.ok) {
|
console.log(`[ENHANCE-API] Processing enhancement request for user: ${userId}`);
|
||||||
console.error('AI enhancement error:', await aiResponse.text());
|
|
||||||
return apiServerError.unavailable('Enhancement service unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiData = await aiResponse.json();
|
const aiResponse = await enqueueApiCall(
|
||||||
const aiContent = aiData.choices?.[0]?.message?.content;
|
() => aiService.callAI(questionsPrompt, { temperature: AI_TEMPERATURE }),
|
||||||
|
taskId
|
||||||
|
);
|
||||||
|
|
||||||
if (!aiContent) {
|
if (!aiResponse?.content) {
|
||||||
return apiServerError.unavailable('No enhancement response');
|
return apiServerError.unavailable('No enhancement response');
|
||||||
}
|
}
|
||||||
|
|
||||||
let questions;
|
let parsed: unknown = JSONParser.safeParseJSON(stripJsonFences(aiResponse.content), null);
|
||||||
try {
|
|
||||||
const cleanedContent = aiContent
|
|
||||||
.replace(/^```json\s*/i, '')
|
|
||||||
.replace(/\s*```\s*$/, '')
|
|
||||||
.trim();
|
|
||||||
questions = JSON.parse(cleanedContent);
|
|
||||||
|
|
||||||
if (!Array.isArray(questions)) {
|
|
||||||
throw new Error('Response is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced validation and cleaning for forensics context
|
|
||||||
questions = questions
|
|
||||||
.filter(q => typeof q === 'string' && q.length > 20 && q.length < 200) // More appropriate length for forensics questions
|
|
||||||
.filter(q => q.includes('?')) // Must be a question
|
|
||||||
.filter(q => {
|
|
||||||
// Enhanced: Filter for forensics-relevant questions
|
|
||||||
const forensicsTerms = ['forensisch', 'log', 'dump', 'image', 'artefakt', 'evidence', 'incident', 'system', 'netzwerk', 'zeitraum', 'verfügbar'];
|
|
||||||
const lowerQ = q.toLowerCase();
|
|
||||||
return forensicsTerms.some(term => lowerQ.includes(term));
|
|
||||||
})
|
|
||||||
.map(q => q.trim())
|
|
||||||
.slice(0, 3); // Max 3 questions
|
|
||||||
|
|
||||||
// If no valid forensics questions, return empty array (means input is complete)
|
|
||||||
if (questions.length === 0) {
|
|
||||||
questions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
let questions: string[] = Array.isArray(parsed) ? parsed : [];
|
||||||
console.error('Failed to parse enhancement response:', aiContent);
|
questions = questions
|
||||||
// If parsing fails, assume input is complete enough
|
.filter(q => typeof q === 'string')
|
||||||
questions = [];
|
.map(q => q.trim())
|
||||||
}
|
.filter(q => q.endsWith('?'))
|
||||||
|
.filter(q => q.length >= Q_MIN_LEN && q.length <= Q_MAX_LEN)
|
||||||
|
.slice(0, Q_MAX_COUNT);
|
||||||
|
|
||||||
console.log(`[AI Enhancement] User: ${userId}, Forensics Questions: ${questions.length}, Input length: ${sanitizedInput.length}`);
|
console.log(`[ENHANCE-API] User: ${userId}, Questions generated: ${questions.length}, Input length: ${sanitizedInput.length}`);
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
questions,
|
questions,
|
||||||
taskId,
|
taskId,
|
||||||
inputComplete: questions.length === 0 // Flag to indicate if input seems complete
|
inputComplete: questions.length === 0
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Enhancement error:', error);
|
console.error('[ENHANCE-API] Enhancement error:', err);
|
||||||
return apiServerError.internal('Enhancement processing failed');
|
return apiServerError.internal('Enhancement processing failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// src/pages/api/ai/query.ts - FIXED: Rate limiting for micro-task pipeline
|
// src/pages/api/ai/query.ts
|
||||||
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
||||||
@@ -21,15 +20,14 @@ const MAIN_RATE_LIMIT_MAX = parseInt(process.env.AI_RATE_LIMIT_MAX_REQUESTS || '
|
|||||||
const MICRO_TASK_TOTAL_LIMIT = parseInt(process.env.AI_MICRO_TASK_TOTAL_LIMIT || '50', 10);
|
const MICRO_TASK_TOTAL_LIMIT = parseInt(process.env.AI_MICRO_TASK_TOTAL_LIMIT || '50', 10);
|
||||||
|
|
||||||
function sanitizeInput(input: string): string {
|
function sanitizeInput(input: string): string {
|
||||||
let sanitized = input
|
return input
|
||||||
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
||||||
.replace(/\<\/?[^>]+(>|$)/g, '')
|
.replace(/\<\/?[^>]+(>|$)/g, '')
|
||||||
.replace(/\b(system|assistant|user)\s*[:]/gi, '[ROLE_REMOVED]')
|
.replace(/\b(system|assistant|user)\s*[:]/gi, '[ROLE_REMOVED]')
|
||||||
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
||||||
.trim();
|
.trim()
|
||||||
|
.slice(0, 2000)
|
||||||
sanitized = sanitized.slice(0, 2000).replace(/\s+/g, ' ');
|
.replace(/\s+/g, ' ');
|
||||||
return sanitized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRateLimit(userId: string): { allowed: boolean; reason?: string; microTasksRemaining?: number } {
|
function checkRateLimit(userId: string): { allowed: boolean; reason?: string; microTasksRemaining?: number } {
|
||||||
@@ -78,7 +76,7 @@ function incrementMicroTaskCount(userId: string, aiCallsMade: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupExpiredRateLimits() {
|
function cleanupExpiredRateLimits(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxStoreSize = 1000;
|
const maxStoreSize = 1000;
|
||||||
|
|
||||||
@@ -118,51 +116,52 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] Received request - TaskId: ${clientTaskId}, Mode: ${mode}, Query length: ${query?.length || 0}`);
|
console.log(`[AI-API] Received request - TaskId: ${clientTaskId}, Mode: ${mode}, Query length: ${query?.length || 0}`);
|
||||||
console.log(`[MICRO-TASK API] Micro-task calls remaining: ${rateLimitResult.microTasksRemaining}`);
|
console.log(`[AI-API] Micro-task calls remaining: ${rateLimitResult.microTasksRemaining}`);
|
||||||
|
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
console.log(`[MICRO-TASK API] Invalid query for task ${clientTaskId}`);
|
console.log(`[AI-API] Invalid query for task ${clientTaskId}`);
|
||||||
return apiError.badRequest('Query required');
|
return apiError.badRequest('Query required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['workflow', 'tool'].includes(mode)) {
|
if (!['workflow', 'tool'].includes(mode)) {
|
||||||
console.log(`[MICRO-TASK API] Invalid mode for task ${clientTaskId}: ${mode}`);
|
console.log(`[AI-API] Invalid mode for task ${clientTaskId}: ${mode}`);
|
||||||
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedQuery = sanitizeInput(query);
|
const sanitizedQuery = sanitizeInput(query);
|
||||||
if (sanitizedQuery.includes('[FILTERED]')) {
|
if (sanitizedQuery.includes('[FILTERED]')) {
|
||||||
console.log(`[MICRO-TASK API] Filtered input detected for task ${clientTaskId}`);
|
console.log(`[AI-API] Filtered input detected for task ${clientTaskId}`);
|
||||||
return apiError.badRequest('Invalid input detected');
|
return apiError.badRequest('Invalid input detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] About to enqueue micro-task pipeline ${taskId}`);
|
console.log(`[AI-API] Enqueueing pipeline task ${taskId}`);
|
||||||
|
|
||||||
const result = await enqueueApiCall(() =>
|
const result = await enqueueApiCall(() =>
|
||||||
aiPipeline.processQuery(sanitizedQuery, mode)
|
aiPipeline.processQuery(sanitizedQuery, mode)
|
||||||
, taskId);
|
, taskId);
|
||||||
|
|
||||||
if (!result || !result.recommendation) {
|
if (!result || !result.recommendation) {
|
||||||
return apiServerError.unavailable('No response from micro-task AI pipeline');
|
return apiServerError.unavailable('No response from AI pipeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = result.processingStats;
|
const stats = result.processingStats;
|
||||||
const estimatedAICallsMade = stats.microTasksCompleted + stats.microTasksFailed;
|
const estimatedAICallsMade = stats.microTasksCompleted + stats.microTasksFailed;
|
||||||
incrementMicroTaskCount(userId, estimatedAICallsMade);
|
incrementMicroTaskCount(userId, estimatedAICallsMade);
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] Pipeline completed for ${taskId}:`);
|
console.log(`[AI-API] Pipeline completed for ${taskId}:`, {
|
||||||
console.log(` - Mode: ${mode}`);
|
mode,
|
||||||
console.log(` - User: ${userId}`);
|
user: userId,
|
||||||
console.log(` - Query length: ${sanitizedQuery.length}`);
|
queryLength: sanitizedQuery.length,
|
||||||
console.log(` - Processing time: ${stats.processingTimeMs}ms`);
|
processingTime: stats.processingTimeMs,
|
||||||
console.log(` - Micro-tasks completed: ${stats.microTasksCompleted}`);
|
microTasksCompleted: stats.microTasksCompleted,
|
||||||
console.log(` - Micro-tasks failed: ${stats.microTasksFailed}`);
|
microTasksFailed: stats.microTasksFailed,
|
||||||
console.log(` - Estimated AI calls: ${estimatedAICallsMade}`);
|
estimatedAICalls: estimatedAICallsMade,
|
||||||
console.log(` - Embeddings used: ${stats.embeddingsUsed}`);
|
embeddingsUsed: stats.embeddingsUsed,
|
||||||
console.log(` - Final items: ${stats.finalSelectedItems}`);
|
finalItems: stats.finalSelectedItems
|
||||||
|
});
|
||||||
|
|
||||||
const currentLimit = rateLimitStore.get(userId);
|
const currentLimit = rateLimitStore.get(userId);
|
||||||
const remainingMicroTasks = currentLimit ?
|
const remainingMicroTasks = currentLimit ?
|
||||||
@@ -176,7 +175,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
query: sanitizedQuery,
|
query: sanitizedQuery,
|
||||||
processingStats: {
|
processingStats: {
|
||||||
...result.processingStats,
|
...result.processingStats,
|
||||||
pipelineType: 'micro-task',
|
pipelineType: 'refactored',
|
||||||
microTasksSuccessRate: stats.microTasksCompleted / (stats.microTasksCompleted + stats.microTasksFailed),
|
microTasksSuccessRate: stats.microTasksCompleted / (stats.microTasksCompleted + stats.microTasksFailed),
|
||||||
averageTaskTime: stats.processingTimeMs / (stats.microTasksCompleted + stats.microTasksFailed),
|
averageTaskTime: stats.processingTimeMs / (stats.microTasksCompleted + stats.microTasksFailed),
|
||||||
estimatedAICallsMade
|
estimatedAICallsMade
|
||||||
@@ -192,18 +191,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MICRO-TASK API] Pipeline error:', error);
|
console.error('[AI-API] Pipeline error:', error);
|
||||||
|
|
||||||
if (error.message.includes('embeddings')) {
|
if (error.message.includes('embeddings')) {
|
||||||
return apiServerError.unavailable('Embeddings service error - using AI fallback');
|
return apiServerError.unavailable('Embeddings service error');
|
||||||
} else if (error.message.includes('micro-task')) {
|
} else if (error.message.includes('AI')) {
|
||||||
return apiServerError.unavailable('Micro-task pipeline error - some analysis steps failed');
|
return apiServerError.unavailable('AI service error');
|
||||||
} else if (error.message.includes('selector')) {
|
|
||||||
return apiServerError.unavailable('AI selector service error');
|
|
||||||
} else if (error.message.includes('rate limit')) {
|
} else if (error.message.includes('rate limit')) {
|
||||||
return apiError.rateLimit('AI service rate limits exceeded during micro-task processing');
|
return apiError.rateLimit('AI service rate limits exceeded');
|
||||||
} else {
|
} else {
|
||||||
return apiServerError.internal('Micro-task AI pipeline error');
|
return apiServerError.internal('AI pipeline error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
// src/pages/api/auth/login.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
|
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
|
||||||
|
import { serialize } from 'cookie';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -8,14 +10,27 @@ export const GET: APIRoute = async ({ url, redirect }) => {
|
|||||||
const state = generateState();
|
const state = generateState();
|
||||||
const authUrl = generateAuthUrl(state);
|
const authUrl = generateAuthUrl(state);
|
||||||
|
|
||||||
console.log('Generated auth URL:', authUrl);
|
console.log('[AUTH] Generated auth URL:', authUrl);
|
||||||
|
|
||||||
const returnTo = url.searchParams.get('returnTo') || '/';
|
const returnTo = url.searchParams.get('returnTo') || '/';
|
||||||
|
|
||||||
logAuthEvent('Login initiated', { returnTo, authUrl });
|
logAuthEvent('Login initiated', { returnTo, authUrl });
|
||||||
|
|
||||||
const stateData = JSON.stringify({ state, returnTo });
|
const stateData = JSON.stringify({ state, returnTo });
|
||||||
const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`;
|
|
||||||
|
const publicBaseUrl = process.env.PUBLIC_BASE_URL || '';
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
|
||||||
|
|
||||||
|
const stateCookie = serialize('auth_state', stateData, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600, // 10 minutes
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AUTH] Setting auth state cookie:', stateCookie.substring(0, 50) + '...');
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
|
// src/pages/api/auth/process.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import {
|
import {
|
||||||
verifyAuthState,
|
verifyAuthState,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
createSessionWithCookie,
|
createSessionWithCookie,
|
||||||
logAuthEvent
|
logAuthEvent
|
||||||
} from '../../../utils/auth.js';
|
} from '../../../utils/auth.js';
|
||||||
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
|
import { apiError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -30,9 +30,15 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const stateVerification = verifyAuthState(request, state);
|
const stateVerification = verifyAuthState(request, state);
|
||||||
if (!stateVerification.isValid || !stateVerification.stateData) {
|
if (!stateVerification.isValid || !stateVerification.stateData) {
|
||||||
|
logAuthEvent('State verification failed', {
|
||||||
|
error: stateVerification.error,
|
||||||
|
hasStateData: !!stateVerification.stateData
|
||||||
|
});
|
||||||
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[AUTH] State verification successful, exchanging code for tokens');
|
||||||
|
|
||||||
const tokens = await exchangeCodeForTokens(code);
|
const tokens = await exchangeCodeForTokens(code);
|
||||||
const userInfo = await getUserInfo(tokens.access_token);
|
const userInfo = await getUserInfo(tokens.access_token);
|
||||||
|
|
||||||
@@ -43,6 +49,12 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
email: sessionResult.userEmail
|
email: sessionResult.userEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const returnUrl = new URL(stateVerification.stateData.returnTo, request.url);
|
||||||
|
returnUrl.searchParams.set('auth', 'success');
|
||||||
|
const redirectUrl = returnUrl.toString();
|
||||||
|
|
||||||
|
console.log('[AUTH] Redirecting to:', redirectUrl);
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
responseHeaders.set('Content-Type', 'application/json');
|
responseHeaders.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
@@ -51,7 +63,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: stateVerification.stateData.returnTo
|
redirectTo: redirectUrl
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: responseHeaders
|
headers: responseHeaders
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
const contributionAuth = await withAPIAuth(request, 'contributions');
|
const contributionAuth = await withAPIAuth(request, 'contributions');
|
||||||
const aiAuth = await withAPIAuth(request, 'ai');
|
const aiAuth = await withAPIAuth(request, 'ai');
|
||||||
|
const gatedContentAuth = await withAPIAuth(request, 'gatedcontent');
|
||||||
|
|
||||||
return apiResponse.success({
|
return apiResponse.success({
|
||||||
authenticated: contributionAuth.authenticated || aiAuth.authenticated,
|
authenticated: contributionAuth.authenticated || aiAuth.authenticated || gatedContentAuth.authenticated,
|
||||||
contributionAuthRequired: contributionAuth.authRequired,
|
contributionAuthRequired: contributionAuth.authRequired,
|
||||||
aiAuthRequired: aiAuth.authRequired,
|
aiAuthRequired: aiAuth.authRequired,
|
||||||
|
gatedContentAuthRequired: gatedContentAuth.authRequired,
|
||||||
contributionAuthenticated: contributionAuth.authenticated,
|
contributionAuthenticated: contributionAuth.authenticated,
|
||||||
aiAuthenticated: aiAuth.authenticated,
|
aiAuthenticated: aiAuth.authenticated,
|
||||||
|
gatedContentAuthenticated: gatedContentAuth.authenticated,
|
||||||
expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
|
expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
|
||||||
});
|
});
|
||||||
}, 'Status check failed');
|
}, 'Status check failed');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
|
// src/pages/api/contribute/knowledgebase.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
|
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
|
||||||
@@ -43,7 +43,7 @@ interface KnowledgebaseContributionData {
|
|||||||
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
|
const RATE_LIMIT_MAX = 3;
|
||||||
|
|
||||||
function checkRateLimit(userEmail: string): boolean {
|
function checkRateLimit(userEmail: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses)
|
// src/pages/api/contribute/tool.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
||||||
@@ -27,6 +27,7 @@ const ContributionToolSchema = z.object({
|
|||||||
knowledgebase: z.boolean().optional().nullable(),
|
knowledgebase: z.boolean().optional().nullable(),
|
||||||
'domain-agnostic-software': z.array(z.string()).optional().nullable(),
|
'domain-agnostic-software': z.array(z.string()).optional().nullable(),
|
||||||
related_concepts: z.array(z.string()).optional().nullable(),
|
related_concepts: z.array(z.string()).optional().nullable(),
|
||||||
|
related_software: z.array(z.string()).optional().nullable(),
|
||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
statusUrl: z.string().url('Must be a valid URL').optional().nullable()
|
statusUrl: z.string().url('Must be a valid URL').optional().nullable()
|
||||||
});
|
});
|
||||||
@@ -80,6 +81,34 @@ function sanitizeInput(obj: any): any {
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preprocessFormData(body: any): any {
|
||||||
|
if (body.tool) {
|
||||||
|
if (typeof body.tool.tags === 'string') {
|
||||||
|
body.tool.tags = body.tool.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.tool.relatedConcepts) {
|
||||||
|
if (typeof body.tool.relatedConcepts === 'string') {
|
||||||
|
body.tool.related_concepts = body.tool.relatedConcepts.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
body.tool.related_concepts = body.tool.relatedConcepts;
|
||||||
|
}
|
||||||
|
delete body.tool.relatedConcepts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.tool.relatedSoftware) {
|
||||||
|
if (typeof body.tool.relatedSoftware === 'string') {
|
||||||
|
body.tool.related_software = body.tool.relatedSoftware.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
body.tool.related_software = body.tool.relatedSoftware;
|
||||||
|
}
|
||||||
|
delete body.tool.relatedSoftware;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
|
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@@ -109,6 +138,14 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool.related_concepts && tool.related_concepts.length > 0) {
|
||||||
|
console.log('[VALIDATION] Related concepts provided:', tool.related_concepts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.related_software && tool.related_software.length > 0) {
|
||||||
|
console.log('[VALIDATION] Related software provided:', tool.related_software);
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
return { valid: errors.length === 0, errors };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,6 +180,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiSpecial.invalidJSON();
|
return apiSpecial.invalidJSON();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body = preprocessFormData(body);
|
||||||
|
|
||||||
const sanitizedBody = sanitizeInput(body);
|
const sanitizedBody = sanitizeInput(body);
|
||||||
|
|
||||||
let validatedData;
|
let validatedData;
|
||||||
@@ -153,6 +192,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const errorMessages = error.errors.map(err =>
|
const errorMessages = error.errors.map(err =>
|
||||||
`${err.path.join('.')}: ${err.message}`
|
`${err.path.join('.')}: ${err.message}`
|
||||||
);
|
);
|
||||||
|
console.log('[VALIDATION] Zod validation errors:', errorMessages);
|
||||||
return apiError.validation('Validation failed', errorMessages);
|
return apiError.validation('Validation failed', errorMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +214,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[CONTRIBUTION] Processing contribution:', {
|
||||||
|
type: contributionData.type,
|
||||||
|
toolName: contributionData.tool.name,
|
||||||
|
toolType: contributionData.tool.type,
|
||||||
|
submitter: userEmail,
|
||||||
|
hasRelatedConcepts: !!(contributionData.tool.related_concepts?.length),
|
||||||
|
hasRelatedSoftware: !!(contributionData.tool.related_software?.length),
|
||||||
|
tagsCount: contributionData.tool.tags?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gitManager = new GitContributionManager();
|
const gitManager = new GitContributionManager();
|
||||||
const result = await gitManager.submitContribution(contributionData);
|
const result = await gitManager.submitContribution(contributionData);
|
||||||
|
|||||||
75
src/pages/api/search/semantic.ts
Normal file
75
src/pages/api/search/semantic.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// src/pages/api/search/semantic.ts
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getToolsData } from '../../../utils/dataService.js';
|
||||||
|
import { configDotenv } from 'dotenv';
|
||||||
|
|
||||||
|
configDotenv();
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESULTS = (() => {
|
||||||
|
const raw = process.env.AI_EMBEDDING_CANDIDATES;
|
||||||
|
const n = Number.parseInt(raw ?? '', 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 50;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const DEFAULT_THRESHOLD = (() => {
|
||||||
|
const raw = process.env.AI_SIMILARITY_THRESHOLD;
|
||||||
|
const n = Number.parseFloat(raw ?? '');
|
||||||
|
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.45;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
maxResults = DEFAULT_MAX_RESULTS,
|
||||||
|
threshold = DEFAULT_THRESHOLD
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, error: 'Query is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { embeddingsService } = await import('../../../utils/embeddings.js');
|
||||||
|
|
||||||
|
await embeddingsService.waitForInitialization();
|
||||||
|
|
||||||
|
const similarItems = await embeddingsService.findSimilar(
|
||||||
|
query.trim(),
|
||||||
|
maxResults,
|
||||||
|
threshold
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolsData = await getToolsData();
|
||||||
|
const rankedTools = similarItems
|
||||||
|
.map((s, i) => {
|
||||||
|
const tool = toolsData.tools.find(t => t.name === s.name);
|
||||||
|
return tool ? { ...tool, _semanticSimilarity: s.similarity, _semanticRank: i + 1 } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
query: query.trim(),
|
||||||
|
results: rankedTools,
|
||||||
|
totalFound: rankedTools.length,
|
||||||
|
semanticSearch: true,
|
||||||
|
threshold,
|
||||||
|
maxSimilarity: rankedTools[0]?._semanticSimilarity ?? 0
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Semantic search error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, error: 'Semantic search failed' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/pages/api/upload/media.ts (UPDATED - Using consolidated API responses)
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
|
||||||
@@ -22,7 +21,9 @@ const UPLOAD_CONFIG = {
|
|||||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||||
allowedTypes: new Set([
|
allowedTypes: new Set([
|
||||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||||
|
|
||||||
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
||||||
|
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
'application/msword',
|
'application/msword',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
@@ -30,7 +31,29 @@ const UPLOAD_CONFIG = {
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.ms-powerpoint',
|
'application/vnd.ms-powerpoint',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
'text/plain', 'text/csv', 'application/json'
|
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'text/markdown',
|
||||||
|
'text/x-markdown',
|
||||||
|
'application/json',
|
||||||
|
'application/xml',
|
||||||
|
'text/xml',
|
||||||
|
'text/html',
|
||||||
|
|
||||||
|
'application/zip',
|
||||||
|
'application/x-tar',
|
||||||
|
'application/gzip',
|
||||||
|
'application/x-gzip',
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
'application/x-rar-compressed',
|
||||||
|
'application/x-7z-compressed',
|
||||||
|
|
||||||
|
'application/rtf',
|
||||||
|
'text/richtext',
|
||||||
|
'application/x-yaml',
|
||||||
|
'text/yaml',
|
||||||
|
'application/yaml'
|
||||||
]),
|
]),
|
||||||
localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads',
|
localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads',
|
||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
|
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
|
||||||
@@ -50,6 +73,7 @@ function checkUploadRateLimit(userEmail: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userLimit.count >= RATE_LIMIT_MAX) {
|
if (userLimit.count >= RATE_LIMIT_MAX) {
|
||||||
|
console.warn(`[UPLOAD] Rate limit exceeded for user: ${userEmail} (${userLimit.count}/${RATE_LIMIT_MAX})`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,27 +82,37 @@ function checkUploadRateLimit(userEmail: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateFile(file: File): { valid: boolean; error?: string } {
|
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
console.log(`[UPLOAD] Validating file: ${file.name}, size: ${file.size}, type: ${file.type}`);
|
||||||
|
|
||||||
if (file.size > UPLOAD_CONFIG.maxFileSize) {
|
if (file.size > UPLOAD_CONFIG.maxFileSize) {
|
||||||
return {
|
const errorMsg = `File too large. Maximum size is ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB`;
|
||||||
valid: false,
|
console.warn(`[UPLOAD] ${errorMsg} - File size: ${file.size}`);
|
||||||
error: `File too large. Maximum size is ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB`
|
return { valid: false, error: errorMsg };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
|
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
|
||||||
return {
|
const errorMsg = `File type ${file.type} not allowed`;
|
||||||
valid: false,
|
console.warn(`[UPLOAD] ${errorMsg} - Allowed types:`, Array.from(UPLOAD_CONFIG.allowedTypes));
|
||||||
error: `File type ${file.type} not allowed`
|
return { valid: false, error: errorMsg };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[UPLOAD] File validation passed for: ${file.name}`);
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
|
async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
|
||||||
|
console.log(`[UPLOAD] Attempting Nextcloud upload for: ${file.name} by ${userEmail}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploader = new NextcloudUploader();
|
const uploader = new NextcloudUploader();
|
||||||
const result = await uploader.uploadFile(file, userEmail);
|
const result = await uploader.uploadFile(file, userEmail);
|
||||||
|
|
||||||
|
console.log(`[UPLOAD] Nextcloud upload successful:`, {
|
||||||
|
filename: result.filename,
|
||||||
|
url: result.url,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
@@ -87,7 +121,7 @@ async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadR
|
|||||||
storage: 'nextcloud'
|
storage: 'nextcloud'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Nextcloud upload failed:', error);
|
console.error('[UPLOAD] Nextcloud upload failed:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Nextcloud upload failed',
|
error: error instanceof Error ? error.message : 'Nextcloud upload failed',
|
||||||
@@ -97,7 +131,10 @@ async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadR
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
|
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
|
||||||
|
console.log(`[UPLOAD] Attempting local upload for: ${file.name} (${userType})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[UPLOAD] Creating directory: ${UPLOAD_CONFIG.localUploadPath}`);
|
||||||
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
@@ -106,11 +143,20 @@ async function uploadToLocal(file: File, userType: string): Promise<UploadResult
|
|||||||
const filename = `${timestamp}-${randomString}${extension}`;
|
const filename = `${timestamp}-${randomString}${extension}`;
|
||||||
|
|
||||||
const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
|
const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
|
||||||
|
console.log(`[UPLOAD] Writing file to: ${filepath}`);
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await fs.writeFile(filepath, buffer);
|
await fs.writeFile(filepath, buffer);
|
||||||
|
|
||||||
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
|
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
|
||||||
|
|
||||||
|
console.log(`[UPLOAD] Local upload successful:`, {
|
||||||
|
filename,
|
||||||
|
filepath,
|
||||||
|
publicUrl,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
@@ -119,7 +165,7 @@ async function uploadToLocal(file: File, userType: string): Promise<UploadResult
|
|||||||
storage: 'local'
|
storage: 'local'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Local upload failed:', error);
|
console.error('[UPLOAD] Local upload failed:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Local upload failed',
|
error: error instanceof Error ? error.message : 'Local upload failed',
|
||||||
@@ -130,12 +176,22 @@ async function uploadToLocal(file: File, userType: string): Promise<UploadResult
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
|
console.log('[UPLOAD] Processing upload request');
|
||||||
|
|
||||||
const authResult = await withAPIAuth(request, 'contributions');
|
const authResult = await withAPIAuth(request, 'contributions');
|
||||||
|
console.log('[UPLOAD] Auth result:', {
|
||||||
|
authenticated: authResult.authenticated,
|
||||||
|
authRequired: authResult.authRequired,
|
||||||
|
userId: authResult.userId
|
||||||
|
});
|
||||||
|
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
if (authResult.authRequired && !authResult.authenticated) {
|
||||||
return apiError.unauthorized();
|
console.warn('[UPLOAD] Upload rejected - authentication required but user not authenticated');
|
||||||
|
return apiError.unauthorized('Authentication required for file uploads');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
||||||
|
console.log(`[UPLOAD] Processing upload for user: ${userEmail}`);
|
||||||
|
|
||||||
if (!checkUploadRateLimit(userEmail)) {
|
if (!checkUploadRateLimit(userEmail)) {
|
||||||
return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.');
|
return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.');
|
||||||
@@ -143,38 +199,59 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
let formData;
|
let formData;
|
||||||
try {
|
try {
|
||||||
|
console.log('[UPLOAD] Parsing form data');
|
||||||
formData = await request.formData();
|
formData = await request.formData();
|
||||||
|
console.log('[UPLOAD] Form data keys:', Array.from(formData.keys()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return apiError.badRequest('Invalid form data');
|
console.error('[UPLOAD] Failed to parse form data:', error);
|
||||||
|
return apiError.badRequest('Invalid form data - could not parse request');
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
const type = formData.get('type') as string;
|
const type = formData.get('type') as string;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
console.warn('[UPLOAD] No file provided in request');
|
||||||
return apiSpecial.missingRequired(['file']);
|
return apiSpecial.missingRequired(['file']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[UPLOAD] Processing file: ${file.name}, type parameter: ${type}`);
|
||||||
|
|
||||||
const validation = validateFile(file);
|
const validation = validateFile(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return apiError.badRequest(validation.error!);
|
return apiError.badRequest(validation.error!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextcloudConfigured = isNextcloudConfigured();
|
||||||
|
console.log('[UPLOAD] Environment check:', {
|
||||||
|
nextcloudConfigured,
|
||||||
|
localUploadPath: UPLOAD_CONFIG.localUploadPath,
|
||||||
|
publicBaseUrl: UPLOAD_CONFIG.publicBaseUrl,
|
||||||
|
nodeEnv: process.env.NODE_ENV
|
||||||
|
});
|
||||||
|
|
||||||
let result: UploadResult;
|
let result: UploadResult;
|
||||||
|
|
||||||
if (isNextcloudConfigured()) {
|
if (nextcloudConfigured) {
|
||||||
|
console.log('[UPLOAD] Using Nextcloud as primary storage');
|
||||||
result = await uploadToNextcloud(file, userEmail);
|
result = await uploadToNextcloud(file, userEmail);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.warn('Nextcloud upload failed, trying local fallback:', result.error);
|
console.warn('[UPLOAD] Nextcloud upload failed, trying local fallback:', result.error);
|
||||||
result = await uploadToLocal(file, type);
|
result = await uploadToLocal(file, type);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[UPLOAD] Using local storage (Nextcloud not configured)');
|
||||||
result = await uploadToLocal(file, type);
|
result = await uploadToLocal(file, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
|
console.log(`[UPLOAD] Upload completed successfully:`, {
|
||||||
|
filename: result.filename,
|
||||||
|
storage: result.storage,
|
||||||
|
url: result.url,
|
||||||
|
user: userEmail
|
||||||
|
});
|
||||||
|
|
||||||
return apiSpecial.uploadSuccess({
|
return apiSpecial.uploadSuccess({
|
||||||
url: result.url!,
|
url: result.url!,
|
||||||
@@ -183,7 +260,12 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
storage: result.storage!
|
storage: result.storage!
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
|
console.error(`[UPLOAD] Upload failed completely:`, {
|
||||||
|
filename: file.name,
|
||||||
|
error: result.error,
|
||||||
|
storage: result.storage,
|
||||||
|
user: userEmail
|
||||||
|
});
|
||||||
|
|
||||||
return apiSpecial.uploadFailed(result.error!);
|
return apiSpecial.uploadFailed(result.error!);
|
||||||
}
|
}
|
||||||
@@ -193,6 +275,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
|
console.log('[UPLOAD] Getting upload status');
|
||||||
|
|
||||||
const authResult = await withAPIAuth(request);
|
const authResult = await withAPIAuth(request);
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
if (authResult.authRequired && !authResult.authenticated) {
|
||||||
return apiError.unauthorized();
|
return apiError.unauthorized();
|
||||||
@@ -204,12 +288,14 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
try {
|
try {
|
||||||
await fs.access(UPLOAD_CONFIG.localUploadPath);
|
await fs.access(UPLOAD_CONFIG.localUploadPath);
|
||||||
localStorageAvailable = true;
|
localStorageAvailable = true;
|
||||||
|
console.log('[UPLOAD] Local storage accessible');
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
||||||
localStorageAvailable = true;
|
localStorageAvailable = true;
|
||||||
|
console.log('[UPLOAD] Local storage created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Local upload directory not accessible:', error);
|
console.warn('[UPLOAD] Local upload directory not accessible:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +323,14 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
paths: {
|
paths: {
|
||||||
uploadEndpoint: '/api/upload/media',
|
uploadEndpoint: '/api/upload/media',
|
||||||
localPath: localStorageAvailable ? '/uploads' : null
|
localPath: localStorageAvailable ? '/uploads' : null
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
publicBaseUrl: UPLOAD_CONFIG.publicBaseUrl
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[UPLOAD] Status check completed:', status);
|
||||||
return apiResponse.success(status);
|
return apiResponse.success(status);
|
||||||
|
|
||||||
}, 'Upload status retrieval failed');
|
}, 'Upload status retrieval failed');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/pages/contribute/index.astro - Consolidated Auth
|
// src/pages/contribute/index.astro
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { withAuth } from '../../utils/auth.js';
|
import { withAuth } from '../../utils/auth.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation
|
// src/pages/contribute/knowledgebase.astro
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { withAuth } from '../../utils/auth.js';
|
import { withAuth } from '../../utils/auth.js';
|
||||||
import { getToolsData } from '../../utils/dataService.js';
|
import { getToolsData } from '../../utils/dataService.js';
|
||||||
@@ -114,8 +114,13 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Dokumente, Bilder, Videos (Optional)</label>
|
<label class="form-label">Dokumente, Bilder, Videos (Optional)</label>
|
||||||
<div class="upload-area" id="upload-area">
|
<div class="upload-area" id="upload-area">
|
||||||
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4,.webm" class="hidden">
|
<input
|
||||||
<div class="upload-placeholder">
|
type="file"
|
||||||
|
id="file-input"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md,.markdown,.csv,.json,.xml,.html,.rtf,.yaml,.yml,.zip,.tar,.gz,.rar,.7z,.png,.jpg,.jpeg,.gif,.webp,.svg,.mp4,.webm,.mov,.avi"
|
||||||
|
class="hidden"
|
||||||
|
> <div class="upload-placeholder">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
@@ -161,6 +166,14 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="gated-content" name="gatedContent" />
|
||||||
|
<span>🔒 Als geschützten Inhalt markieren (Authentifizierung erforderlich)</span>
|
||||||
|
</label>
|
||||||
|
<small class="form-help">Nur für interne oder vertrauliche Inhalte</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="reason" class="form-label">Grund für den Beitrag (Optional)</label>
|
<label for="reason" class="form-label">Grund für den Beitrag (Optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -304,6 +317,12 @@ class KnowledgebaseForm {
|
|||||||
|
|
||||||
private handleFiles(files: File[]) {
|
private handleFiles(files: File[]) {
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
|
const validation = this.validateFileBeforeUpload(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.log('[UPLOAD]Cannot upload ', file.name, ' Error: ', validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
const newFile: UploadedFile = {
|
const newFile: UploadedFile = {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
@@ -317,30 +336,98 @@ class KnowledgebaseForm {
|
|||||||
this.renderFileList();
|
this.renderFileList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateFileBeforeUpload(file: File): { valid: boolean; error?: string } {
|
||||||
|
const maxSizeBytes = 50 * 1024 * 1024; // 50MB
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
const sizeMB = (file.size / 1024 / 1024).toFixed(1);
|
||||||
|
const maxMB = (maxSizeBytes / 1024 / 1024).toFixed(0);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File too large (${sizeMB}MB). Maximum size: ${maxMB}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedExtensions = [
|
||||||
|
'.pdf', '.doc', '.docx', '.txt', '.md', '.markdown', '.csv', '.json',
|
||||||
|
'.xml', '.html', '.rtf', '.yaml', '.yml', '.zip', '.tar', '.gz',
|
||||||
|
'.rar', '.7z', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
|
||||||
|
'.mp4', '.webm', '.mov', '.avi'
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
|
||||||
|
|
||||||
|
if (!hasValidExtension) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File type not allowed. Allowed: ${allowedExtensions.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async uploadFile(fileId: string) {
|
private async uploadFile(fileId: string) {
|
||||||
const fileItem = this.uploadedFiles.find(f => f.id === fileId);
|
const fileItem = this.uploadedFiles.find(f => f.id === fileId);
|
||||||
if (!fileItem) return;
|
if (!fileItem) {
|
||||||
|
console.error('[UPLOAD] File item not found for ID:', fileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UPLOAD] Starting upload for:', fileItem.name, 'Size:', fileItem.file.size, 'Type:', fileItem.file.type);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', fileItem.file);
|
formData.append('file', fileItem.file);
|
||||||
formData.append('type', 'knowledgebase');
|
formData.append('type', 'knowledgebase');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[UPLOAD] Sending request to /api/upload/media');
|
||||||
|
|
||||||
const response = await fetch('/api/upload/media', {
|
const response = await fetch('/api/upload/media', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[UPLOAD] Response status:', response.status);
|
||||||
|
|
||||||
|
let responseText: string;
|
||||||
|
let responseData: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseText = await response.text();
|
||||||
|
console.log('[UPLOAD] Raw response:', responseText.substring(0, 200));
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseData = JSON.parse(responseText);
|
||||||
|
} catch (parseError) {
|
||||||
|
responseData = { error: responseText };
|
||||||
|
}
|
||||||
|
} catch (readError) {
|
||||||
|
console.error('[UPLOAD] Failed to read response:', readError);
|
||||||
|
throw new Error('Failed to read server response');
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
console.log('[UPLOAD] Success result:', responseData);
|
||||||
|
|
||||||
fileItem.uploaded = true;
|
fileItem.uploaded = true;
|
||||||
fileItem.url = result.url;
|
fileItem.url = responseData.url;
|
||||||
this.renderFileList();
|
this.renderFileList();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Upload failed');
|
|
||||||
|
if (responseData && responseData.details) {
|
||||||
|
console.error('[UPLOAD] Error details:', responseData.details);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showMessage('error', `Failed to upload ${fileItem.name}`);
|
console.error('[UPLOAD] Upload error for', fileItem.name, ':', error);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unknown upload error';
|
||||||
|
|
||||||
this.removeFile(fileId);
|
this.removeFile(fileId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +499,6 @@ class KnowledgebaseForm {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[KB FORM] Submission error:', error);
|
console.error('[KB FORM] Submission error:', error);
|
||||||
this.showMessage('error', 'Submission failed. Please try again.');
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
(this.elements.submitBtn as HTMLButtonElement).disabled = false;
|
(this.elements.submitBtn as HTMLButtonElement).disabled = false;
|
||||||
@@ -441,18 +527,6 @@ class KnowledgebaseForm {
|
|||||||
this.renderFileList();
|
this.renderFileList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private showMessage(type: 'success' | 'error' | 'warning', message: string) {
|
|
||||||
const container = document.getElementById('message-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const messageEl = document.createElement('div');
|
|
||||||
messageEl.className = `message message-${type}`;
|
|
||||||
messageEl.textContent = message;
|
|
||||||
|
|
||||||
container.appendChild(messageEl);
|
|
||||||
setTimeout(() => messageEl.remove(), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeFileById(fileId: string) {
|
public removeFileById(fileId: string) {
|
||||||
this.removeFile(fileId);
|
this.removeFile(fileId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ const existingTools = data.tools;
|
|||||||
const editToolName = Astro.url.searchParams.get('edit');
|
const editToolName = Astro.url.searchParams.get('edit');
|
||||||
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
|
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
|
||||||
const isEdit = !!editTool;
|
const isEdit = !!editTool;
|
||||||
|
|
||||||
|
const allTags = [...new Set(existingTools.flatMap(tool => tool.tags || []))].sort();
|
||||||
|
const allSoftwareAndMethods = existingTools
|
||||||
|
.filter(tool => tool.type === 'software' || tool.type === 'method')
|
||||||
|
.map(tool => tool.name)
|
||||||
|
.sort();
|
||||||
|
const allConcepts = existingTools
|
||||||
|
.filter(tool => tool.type === 'concept')
|
||||||
|
.map(tool => tool.name)
|
||||||
|
.sort();
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
|
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
|
||||||
@@ -194,16 +204,27 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
<div id="relations-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Verwandte Tools & Konzepte</h3>
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
|
||||||
{existingTools.filter(tool => tool.type === 'concept').map(concept => (
|
<div style="display: grid; gap: 1.5rem;">
|
||||||
<label class="checkbox-wrapper">
|
<div id="related-concepts-section">
|
||||||
<input type="checkbox" name="relatedConcepts" value={concept.name}
|
<label for="related-concepts-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Verwandte Konzepte</label>
|
||||||
checked={editTool?.related_concepts?.includes(concept.name)} />
|
<input type="text" id="related-concepts-input" placeholder="Beginne zu tippen, um Konzepte zu finden..." />
|
||||||
<span>{concept.name}</span>
|
<input type="hidden" id="related-concepts-hidden" name="relatedConcepts" value={editTool?.related_concepts?.join(', ') || ''} />
|
||||||
</label>
|
<small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
|
||||||
))}
|
Konzepte, die mit diesem Tool/Methode in Verbindung stehen
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="related-software-section">
|
||||||
|
<label for="related-software-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Verwandte Software & Methoden</label>
|
||||||
|
<input type="text" id="related-software-input" placeholder="Beginne zu tippen, um Software/Methoden zu finden..." />
|
||||||
|
<input type="hidden" id="related-software-hidden" name="relatedSoftware" value={editTool?.related_software?.join(', ') || ''} />
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
|
||||||
|
Software oder Methoden, die oft zusammen mit diesem Tool verwendet werden
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,9 +232,12 @@ const isEdit = !!editTool;
|
|||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
|
||||||
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
|
<label for="tags-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
|
||||||
<input type="text" id="tags" name="tags" value={editTool?.tags?.join(', ') || ''}
|
<input type="text" id="tags-input" placeholder="Beginne zu tippen, um Tags hinzuzufügen..." />
|
||||||
placeholder="Komma-getrennt: Passende Begriffe, nach denen ihr suchen würdet." />
|
<input type="hidden" id="tags-hidden" name="tags" value={editTool?.tags?.join(', ') || ''} />
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
|
||||||
|
Passende Begriffe, nach denen ihr suchen würdet
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
@@ -274,7 +298,269 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
|
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware, allTags, allSoftwareAndMethods, allConcepts }}>
|
||||||
|
class AutocompleteManager {
|
||||||
|
constructor(inputElement, dataSource, options = {}) {
|
||||||
|
this.input = inputElement;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.options = {
|
||||||
|
minLength: 1,
|
||||||
|
maxResults: 10,
|
||||||
|
placeholder: 'Type to search...',
|
||||||
|
allowMultiple: false,
|
||||||
|
separator: ', ',
|
||||||
|
filterFunction: this.defaultFilter.bind(this),
|
||||||
|
renderFunction: this.defaultRender.bind(this),
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.isOpen = false;
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
this.filteredData = [];
|
||||||
|
this.selectedItems = new Set();
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createDropdown();
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.initMultipleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createDropdown() {
|
||||||
|
this.dropdown = document.createElement('div');
|
||||||
|
this.dropdown.className = 'autocomplete-dropdown';
|
||||||
|
|
||||||
|
this.input.parentNode.style.position = 'relative';
|
||||||
|
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.input.addEventListener('input', (e) => {
|
||||||
|
this.handleInput(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('keydown', (e) => {
|
||||||
|
this.handleKeydown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('focus', () => {
|
||||||
|
if (this.input.value.length >= this.options.minLength) {
|
||||||
|
this.showDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('blur', (e) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.dropdown.contains(document.activeElement)) {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initMultipleMode() {
|
||||||
|
this.selectedContainer = document.createElement('div');
|
||||||
|
this.selectedContainer.className = 'autocomplete-selected';
|
||||||
|
|
||||||
|
this.input.parentNode.insertBefore(this.selectedContainer, this.input);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(value) {
|
||||||
|
if (value.length >= this.options.minLength) {
|
||||||
|
this.filteredData = this.options.filterFunction(value);
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
this.renderDropdown();
|
||||||
|
this.showDropdown();
|
||||||
|
} else {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredData.length - 1);
|
||||||
|
this.updateHighlight();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||||
|
this.updateHighlight();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.selectedIndex >= 0) {
|
||||||
|
this.selectItem(this.filteredData[this.selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
this.hideDropdown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultFilter(query) {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
return this.dataSource
|
||||||
|
.filter(item => {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
return text.toLowerCase().includes(searchTerm) &&
|
||||||
|
(!this.options.allowMultiple || !this.selectedItems.has(text));
|
||||||
|
})
|
||||||
|
.slice(0, this.options.maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRender(item) {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
return `<div class="autocomplete-item">${this.escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDropdown() {
|
||||||
|
if (this.filteredData.length === 0) {
|
||||||
|
this.dropdown.innerHTML = '<div class="autocomplete-no-results">No results found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dropdown.innerHTML = this.filteredData
|
||||||
|
.map((item, index) => {
|
||||||
|
const content = this.options.renderFunction(item);
|
||||||
|
return `<div class="autocomplete-option" data-index="${index}">${content}</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
this.selectItem(this.filteredData[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
option.addEventListener('mouseenter', () => {
|
||||||
|
this.selectedIndex = index;
|
||||||
|
this.updateHighlight();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHighlight() {
|
||||||
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
|
option.style.backgroundColor = index === this.selectedIndex
|
||||||
|
? 'var(--color-bg-secondary)'
|
||||||
|
: 'transparent';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(item) {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.selectedItems.add(text);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
this.input.value = '';
|
||||||
|
} else {
|
||||||
|
this.input.value = text;
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
||||||
|
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(text) {
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.selectedItems.delete(text);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedDisplay() {
|
||||||
|
if (!this.options.allowMultiple || !this.selectedContainer) return;
|
||||||
|
|
||||||
|
this.selectedContainer.innerHTML = Array.from(this.selectedItems)
|
||||||
|
.map(item => `
|
||||||
|
<span class="autocomplete-tag">
|
||||||
|
${this.escapeHtml(item)}
|
||||||
|
<button type="button" class="autocomplete-remove" data-item="${this.escapeHtml(item)}">×</button>
|
||||||
|
</span>
|
||||||
|
`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.removeItem(btn.getAttribute('data-item'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInputValue() {
|
||||||
|
if (this.options.allowMultiple && this.options.hiddenInput) {
|
||||||
|
this.options.hiddenInput.value = Array.from(this.selectedItems).join(this.options.separator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDropdown() {
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDropdown() {
|
||||||
|
this.dropdown.style.display = 'none';
|
||||||
|
this.isOpen = false;
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDataSource(newDataSource) {
|
||||||
|
this.dataSource = newDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedItems() {
|
||||||
|
return Array.from(this.selectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItems(items) {
|
||||||
|
this.selectedItems = new Set(items);
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.dropdown && this.dropdown.parentNode) {
|
||||||
|
this.dropdown.parentNode.removeChild(this.dropdown);
|
||||||
|
}
|
||||||
|
if (this.selectedContainer && this.selectedContainer.parentNode) {
|
||||||
|
this.selectedContainer.parentNode.removeChild(this.selectedContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[FORM] Script loaded, initializing...');
|
console.log('[FORM] Script loaded, initializing...');
|
||||||
|
|
||||||
class ContributionForm {
|
class ContributionForm {
|
||||||
@@ -282,7 +568,8 @@ class ContributionForm {
|
|||||||
this.isEdit = isEdit;
|
this.isEdit = isEdit;
|
||||||
this.editTool = editTool;
|
this.editTool = editTool;
|
||||||
this.elements = {};
|
this.elements = {};
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
|
this.autocompleteManagers = new Map();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,14 +590,20 @@ class ContributionForm {
|
|||||||
yamlPreview: document.getElementById('yaml-preview'),
|
yamlPreview: document.getElementById('yaml-preview'),
|
||||||
successModal: document.getElementById('success-modal'),
|
successModal: document.getElementById('success-modal'),
|
||||||
softwareFields: document.getElementById('software-fields'),
|
softwareFields: document.getElementById('software-fields'),
|
||||||
conceptsFields: document.getElementById('concepts-fields'),
|
relationsFields: document.getElementById('relations-fields'),
|
||||||
descriptionCount: document.getElementById('description-count'),
|
descriptionCount: document.getElementById('description-count'),
|
||||||
reasonCount: document.getElementById('reason-count'),
|
reasonCount: document.getElementById('reason-count'),
|
||||||
validationErrors: document.getElementById('validation-errors'),
|
validationErrors: document.getElementById('validation-errors'),
|
||||||
errorList: document.getElementById('error-list'),
|
errorList: document.getElementById('error-list'),
|
||||||
platformsRequired: document.getElementById('platforms-required'),
|
platformsRequired: document.getElementById('platforms-required'),
|
||||||
licenseRequired: document.getElementById('license-required'),
|
licenseRequired: document.getElementById('license-required'),
|
||||||
licenseInput: document.getElementById('license')
|
licenseInput: document.getElementById('license'),
|
||||||
|
tagsInput: document.getElementById('tags-input'),
|
||||||
|
tagsHidden: document.getElementById('tags-hidden'),
|
||||||
|
relatedConceptsInput: document.getElementById('related-concepts-input'),
|
||||||
|
relatedConceptsHidden: document.getElementById('related-concepts-hidden'),
|
||||||
|
relatedSoftwareInput: document.getElementById('related-software-input'),
|
||||||
|
relatedSoftwareHidden: document.getElementById('related-software-hidden')
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.elements.form || !this.elements.submitBtn) {
|
if (!this.elements.form || !this.elements.submitBtn) {
|
||||||
@@ -327,6 +620,7 @@ class ContributionForm {
|
|||||||
|
|
||||||
console.log('[FORM] Setting up handlers...');
|
console.log('[FORM] Setting up handlers...');
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
this.setupAutocomplete();
|
||||||
this.updateFieldVisibility();
|
this.updateFieldVisibility();
|
||||||
this.setupCharacterCounters();
|
this.setupCharacterCounters();
|
||||||
this.updateYAMLPreview();
|
this.updateYAMLPreview();
|
||||||
@@ -334,6 +628,58 @@ class ContributionForm {
|
|||||||
console.log('[FORM] Initialization complete!');
|
console.log('[FORM] Initialization complete!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupAutocomplete() {
|
||||||
|
if (this.elements.tagsInput && this.elements.tagsHidden) {
|
||||||
|
const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
|
||||||
|
allowMultiple: true,
|
||||||
|
hiddenInput: this.elements.tagsHidden,
|
||||||
|
placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.editTool?.tags) {
|
||||||
|
tagsManager.setSelectedItems(this.editTool.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autocompleteManagers.set('tags', tagsManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.elements.relatedConceptsInput && this.elements.relatedConceptsHidden) {
|
||||||
|
const conceptsManager = new AutocompleteManager(this.elements.relatedConceptsInput, allConcepts, {
|
||||||
|
allowMultiple: true,
|
||||||
|
hiddenInput: this.elements.relatedConceptsHidden,
|
||||||
|
placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.editTool?.related_concepts) {
|
||||||
|
conceptsManager.setSelectedItems(this.editTool.related_concepts);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autocompleteManagers.set('relatedConcepts', conceptsManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.elements.relatedSoftwareInput && this.elements.relatedSoftwareHidden) {
|
||||||
|
const softwareManager = new AutocompleteManager(this.elements.relatedSoftwareInput, allSoftwareAndMethods, {
|
||||||
|
allowMultiple: true,
|
||||||
|
hiddenInput: this.elements.relatedSoftwareHidden,
|
||||||
|
placeholder: 'Beginne zu tippen, um Software/Methoden zu finden...'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.editTool?.related_software) {
|
||||||
|
softwareManager.setSelectedItems(this.editTool.related_software);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autocompleteManagers.set('relatedSoftware', softwareManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.values(this.autocompleteManagers).forEach(manager => {
|
||||||
|
if (manager.input) {
|
||||||
|
manager.input.addEventListener('autocomplete:select', () => {
|
||||||
|
this.updateYAMLPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.elements.typeSelect.addEventListener('change', () => {
|
this.elements.typeSelect.addEventListener('change', () => {
|
||||||
this.updateFieldVisibility();
|
this.updateFieldVisibility();
|
||||||
@@ -363,203 +709,213 @@ class ContributionForm {
|
|||||||
console.log('[FORM] Event listeners attached');
|
console.log('[FORM] Event listeners attached');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFieldVisibility() {
|
updateFieldVisibility() {
|
||||||
const type = this.elements.typeSelect.value;
|
const type = this.elements.typeSelect.value;
|
||||||
|
|
||||||
this.elements.softwareFields.style.display = 'none';
|
|
||||||
this.elements.conceptsFields.style.display = 'none';
|
|
||||||
|
|
||||||
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
|
|
||||||
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
|
|
||||||
|
|
||||||
if (type === 'software') {
|
|
||||||
this.elements.softwareFields.style.display = 'block';
|
|
||||||
this.elements.conceptsFields.style.display = 'block';
|
|
||||||
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'inline';
|
|
||||||
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'inline';
|
|
||||||
} else if (type === 'method') {
|
|
||||||
this.elements.conceptsFields.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[FORM] Field visibility updated for type:', type);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupCharacterCounters() {
|
|
||||||
const counters = [
|
|
||||||
{ element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
|
|
||||||
{ element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
|
|
||||||
];
|
|
||||||
|
|
||||||
counters.forEach(({ element, counter, max }) => {
|
|
||||||
if (element && counter) {
|
|
||||||
const updateCounter = () => {
|
|
||||||
const count = element.value.length;
|
|
||||||
counter.textContent = count;
|
|
||||||
counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
|
|
||||||
};
|
|
||||||
|
|
||||||
element.addEventListener('input', updateCounter);
|
|
||||||
updateCounter();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateYAMLPreview() {
|
|
||||||
if (!this.elements.yamlPreview) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData(this.elements.form);
|
|
||||||
|
|
||||||
const tool = {
|
this.elements.softwareFields.style.display = type === 'software' ? 'block' : 'none';
|
||||||
name: formData.get('name') || 'Tool Name',
|
|
||||||
type: formData.get('type') || 'software',
|
this.elements.relationsFields.style.display = 'block';
|
||||||
description: formData.get('description') || 'Tool description',
|
|
||||||
domains: formData.getAll('domains'),
|
if (this.elements.platformsRequired) {
|
||||||
phases: formData.getAll('phases'),
|
this.elements.platformsRequired.style.display = type === 'software' ? 'inline' : 'none';
|
||||||
skillLevel: formData.get('skillLevel') || 'intermediate',
|
}
|
||||||
url: formData.get('url') || 'https://example.com'
|
if (this.elements.licenseRequired) {
|
||||||
};
|
this.elements.licenseRequired.style.display = type === 'software' ? 'inline' : 'none';
|
||||||
|
|
||||||
if (formData.get('icon')) {
|
|
||||||
tool.icon = formData.get('icon');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tool.type === 'software') {
|
const conceptsSection = document.getElementById('related-concepts-section');
|
||||||
tool.platforms = formData.getAll('platforms');
|
const softwareSection = document.getElementById('related-software-section');
|
||||||
tool.license = formData.get('license') || 'Unknown';
|
if (conceptsSection) conceptsSection.style.display = 'block';
|
||||||
if (formData.get('accessType')) {
|
if (softwareSection) softwareSection.style.display = 'block';
|
||||||
tool.accessType = formData.get('accessType');
|
|
||||||
|
console.log('[FORM] Updated visibility for type:', type || '(no type selected)');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCharacterCounters() {
|
||||||
|
const counters = [
|
||||||
|
{ element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
|
||||||
|
{ element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
|
||||||
|
];
|
||||||
|
|
||||||
|
counters.forEach(({ element, counter, max }) => {
|
||||||
|
if (element && counter) {
|
||||||
|
const updateCounter = () => {
|
||||||
|
const count = element.value.length;
|
||||||
|
counter.textContent = count;
|
||||||
|
counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('input', updateCounter);
|
||||||
|
updateCounter();
|
||||||
}
|
}
|
||||||
const domainAgnostic = formData.getAll('domainAgnostic');
|
});
|
||||||
if (domainAgnostic.length > 0) {
|
|
||||||
tool['domain-agnostic-software'] = domainAgnostic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.has('knowledgebase')) {
|
|
||||||
tool.knowledgebase = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = formData.get('tags');
|
|
||||||
if (tags) {
|
|
||||||
tool.tags = tags.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
const relatedConcepts = formData.getAll('relatedConcepts');
|
|
||||||
if (relatedConcepts.length > 0) {
|
|
||||||
tool.related_concepts = relatedConcepts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const yaml = this.generateYAML(tool);
|
|
||||||
this.elements.yamlPreview.textContent = yaml;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FORM] YAML preview error:', error);
|
|
||||||
this.elements.yamlPreview.textContent = '# Error generating preview';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateYAML(tool) {
|
|
||||||
const lines = [];
|
|
||||||
|
|
||||||
lines.push(`name: "${tool.name}"`);
|
|
||||||
if (tool.icon) lines.push(`icon: "${tool.icon}"`);
|
|
||||||
lines.push(`type: ${tool.type}`);
|
|
||||||
lines.push(`description: "${tool.description}"`);
|
|
||||||
lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
|
|
||||||
lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
|
|
||||||
lines.push(`skillLevel: ${tool.skillLevel}`);
|
|
||||||
lines.push(`url: "${tool.url}"`);
|
|
||||||
|
|
||||||
if (tool.platforms && tool.platforms.length > 0) {
|
|
||||||
lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
|
|
||||||
}
|
|
||||||
if (tool.license) lines.push(`license: "${tool.license}"`);
|
|
||||||
if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
|
|
||||||
if (tool['domain-agnostic-software']) {
|
|
||||||
lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
|
|
||||||
}
|
|
||||||
if (tool.knowledgebase) lines.push(`knowledgebase: true`);
|
|
||||||
if (tool.tags && tool.tags.length > 0) {
|
|
||||||
lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
|
|
||||||
}
|
|
||||||
if (tool.related_concepts && tool.related_concepts.length > 0) {
|
|
||||||
lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n');
|
updateYAMLPreview() {
|
||||||
}
|
if (!this.elements.yamlPreview) return;
|
||||||
|
|
||||||
validateForm() {
|
|
||||||
const errors = [];
|
|
||||||
const formData = new FormData(this.elements.form);
|
|
||||||
|
|
||||||
const name = formData.get('name')?.trim();
|
|
||||||
if (!name) {
|
|
||||||
errors.push('Tool name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = formData.get('description')?.trim();
|
|
||||||
if (!description) {
|
|
||||||
errors.push('Description is required');
|
|
||||||
} else if (description.length < 10) {
|
|
||||||
errors.push('Description must be at least 10 characters long');
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillLevel = formData.get('skillLevel');
|
|
||||||
if (!skillLevel) {
|
|
||||||
errors.push('Skill level is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = formData.get('type');
|
|
||||||
if (!type) {
|
|
||||||
errors.push('Type is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = formData.get('url')?.trim();
|
|
||||||
if (!url) {
|
|
||||||
errors.push('Primary URL is required');
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
const formData = new FormData(this.elements.form);
|
||||||
} catch {
|
|
||||||
errors.push('Primary URL must be a valid URL');
|
const tool = {
|
||||||
|
name: formData.get('name') || 'Tool Name',
|
||||||
|
type: formData.get('type') || 'software',
|
||||||
|
description: formData.get('description') || 'Tool description',
|
||||||
|
domains: formData.getAll('domains'),
|
||||||
|
phases: formData.getAll('phases'),
|
||||||
|
skillLevel: formData.get('skillLevel') || 'intermediate',
|
||||||
|
url: formData.get('url') || 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.get('icon')) {
|
||||||
|
tool.icon = formData.get('icon');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.type === 'software') {
|
||||||
|
tool.platforms = formData.getAll('platforms');
|
||||||
|
tool.license = formData.get('license') || 'Unknown';
|
||||||
|
if (formData.get('accessType')) {
|
||||||
|
tool.accessType = formData.get('accessType');
|
||||||
|
}
|
||||||
|
const domainAgnostic = formData.getAll('domainAgnostic');
|
||||||
|
if (domainAgnostic.length > 0) {
|
||||||
|
tool['domain-agnostic-software'] = domainAgnostic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.has('knowledgebase')) {
|
||||||
|
tool.knowledgebase = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsValue = this.elements.tagsHidden?.value || '';
|
||||||
|
if (tagsValue) {
|
||||||
|
tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
||||||
|
if (relatedConceptsValue) {
|
||||||
|
tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
||||||
|
if (relatedSoftwareValue) {
|
||||||
|
tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yaml = this.generateYAML(tool);
|
||||||
|
this.elements.yamlPreview.textContent = yaml;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FORM] YAML preview error:', error);
|
||||||
|
this.elements.yamlPreview.textContent = '# Error generating preview';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'software') {
|
generateYAML(tool) {
|
||||||
const platforms = formData.getAll('platforms');
|
const lines = [];
|
||||||
if (platforms.length === 0) {
|
|
||||||
errors.push('At least one platform is required for software');
|
lines.push(`name: "${tool.name}"`);
|
||||||
|
if (tool.icon) lines.push(`icon: "${tool.icon}"`);
|
||||||
|
lines.push(`type: ${tool.type}`);
|
||||||
|
lines.push(`description: "${tool.description}"`);
|
||||||
|
lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
|
||||||
|
lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
|
||||||
|
lines.push(`skillLevel: ${tool.skillLevel}`);
|
||||||
|
lines.push(`url: "${tool.url}"`);
|
||||||
|
|
||||||
|
if (tool.platforms && tool.platforms.length > 0) {
|
||||||
|
lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (tool.license) lines.push(`license: "${tool.license}"`);
|
||||||
|
if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
|
||||||
|
if (tool['domain-agnostic-software']) {
|
||||||
|
lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (tool.knowledgebase) lines.push(`knowledgebase: true`);
|
||||||
|
if (tool.tags && tool.tags.length > 0) {
|
||||||
|
lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (tool.related_concepts && tool.related_concepts.length > 0) {
|
||||||
|
lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (tool.related_software && tool.related_software.length > 0) {
|
||||||
|
lines.push(`related_software: [${tool.related_software.map(s => `"${s}"`).join(', ')}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const license = formData.get('license')?.trim();
|
return lines.join('\n');
|
||||||
if (!license) {
|
}
|
||||||
errors.push('License is required for software');
|
|
||||||
|
validateForm() {
|
||||||
|
const errors = [];
|
||||||
|
const formData = new FormData(this.elements.form);
|
||||||
|
|
||||||
|
const name = formData.get('name')?.trim();
|
||||||
|
if (!name) {
|
||||||
|
errors.push('Tool name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = formData.get('description')?.trim();
|
||||||
|
if (!description) {
|
||||||
|
errors.push('Description is required');
|
||||||
|
} else if (description.length < 10) {
|
||||||
|
errors.push('Description must be at least 10 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillLevel = formData.get('skillLevel');
|
||||||
|
if (!skillLevel) {
|
||||||
|
errors.push('Skill level is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = formData.get('type');
|
||||||
|
if (!type) {
|
||||||
|
errors.push('Type is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = formData.get('url')?.trim();
|
||||||
|
if (!url) {
|
||||||
|
errors.push('Primary URL is required');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
errors.push('Primary URL must be a valid URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'software') {
|
||||||
|
const platforms = formData.getAll('platforms');
|
||||||
|
if (platforms.length === 0) {
|
||||||
|
errors.push('At least one platform is required for software');
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = formData.get('license')?.trim();
|
||||||
|
if (!license) {
|
||||||
|
errors.push('License is required for software');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
showValidationErrors(errors) {
|
||||||
}
|
if (errors.length === 0) {
|
||||||
|
this.elements.validationErrors.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showValidationErrors(errors) {
|
this.elements.errorList.innerHTML = '';
|
||||||
if (errors.length === 0) {
|
|
||||||
this.elements.validationErrors.style.display = 'none';
|
errors.forEach(error => {
|
||||||
return;
|
const li = document.createElement('li');
|
||||||
|
li.textContent = error;
|
||||||
|
this.elements.errorList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.elements.validationErrors.style.display = 'block';
|
||||||
|
|
||||||
|
this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.elements.errorList.innerHTML = '';
|
|
||||||
|
|
||||||
errors.forEach(error => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.textContent = error;
|
|
||||||
this.elements.errorList.appendChild(li);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.elements.validationErrors.style.display = 'block';
|
|
||||||
|
|
||||||
this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
async handleSubmit() {
|
async handleSubmit() {
|
||||||
console.log('[FORM] Submit handler called!');
|
console.log('[FORM] Submit handler called!');
|
||||||
|
|
||||||
@@ -597,14 +953,29 @@ showValidationErrors(errors) {
|
|||||||
phases: formData.getAll('phases'),
|
phases: formData.getAll('phases'),
|
||||||
skillLevel: formData.get('skillLevel'),
|
skillLevel: formData.get('skillLevel'),
|
||||||
url: formData.get('url'),
|
url: formData.get('url'),
|
||||||
tags: formData.get('tags') ?
|
tags: []
|
||||||
formData.get('tags').split(',').map(t => t.trim()).filter(Boolean) : []
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
reason: formData.get('reason') || ''
|
reason: formData.get('reason') || '',
|
||||||
|
contact: formData.get('contact') || ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tagsValue = this.elements.tagsHidden?.value || '';
|
||||||
|
if (tagsValue) {
|
||||||
|
submission.tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
||||||
|
if (relatedConceptsValue) {
|
||||||
|
submission.tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
||||||
|
if (relatedSoftwareValue) {
|
||||||
|
submission.tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.get('icon')) submission.tool.icon = formData.get('icon');
|
if (formData.get('icon')) submission.tool.icon = formData.get('icon');
|
||||||
if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
|
if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
|
||||||
|
|
||||||
@@ -620,13 +991,6 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submission.tool.type !== 'concept') {
|
|
||||||
const related = formData.getAll('relatedConcepts');
|
|
||||||
if (related.length > 0) {
|
|
||||||
submission.tool.related_concepts = related;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[FORM] Sending submission:', submission);
|
console.log('[FORM] Sending submission:', submission);
|
||||||
|
|
||||||
const response = await fetch('/api/contribute/tool', {
|
const response = await fetch('/api/contribute/tool', {
|
||||||
@@ -681,6 +1045,13 @@ showValidationErrors(errors) {
|
|||||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.autocompleteManagers.forEach(manager => {
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
this.autocompleteManagers.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeForm() {
|
function initializeForm() {
|
||||||
@@ -706,4 +1077,5 @@ if (document.readyState === 'loading') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[FORM] Script loaded successfully');
|
console.log('[FORM] Script loaded successfully');
|
||||||
</script>
|
</script>
|
||||||
|
</BaseLayout>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
//src/pages/index.astro
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import ToolCard from '../components/ToolCard.astro';
|
import ToolCard from '../components/ToolCard.astro';
|
||||||
import ToolFilters from '../components/ToolFilters.astro';
|
import ToolFilters from '../components/ToolFilters.astro';
|
||||||
@@ -6,10 +7,18 @@ import ToolMatrix from '../components/ToolMatrix.astro';
|
|||||||
import AIQueryInterface from '../components/AIQueryInterface.astro';
|
import AIQueryInterface from '../components/AIQueryInterface.astro';
|
||||||
import TargetedScenarios from '../components/TargetedScenarios.astro';
|
import TargetedScenarios from '../components/TargetedScenarios.astro';
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
import { withAPIAuth, getAuthRequirementForContext } from '../utils/auth.js';
|
||||||
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const tools = data.tools;
|
const tools = data.tools;
|
||||||
const phases = data.phases;
|
const phases = data.phases;
|
||||||
|
|
||||||
|
const aiAuthRequired = getAuthRequirementForContext('ai');
|
||||||
|
let aiAuthContext: { authenticated: boolean; userId: string; session?: any; authRequired: boolean; } | null = null;
|
||||||
|
|
||||||
|
if (aiAuthRequired) {
|
||||||
|
aiAuthContext = await withAPIAuth(Astro.request, 'ai');
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="~/">
|
<BaseLayout title="~/">
|
||||||
@@ -21,6 +30,55 @@ const phases = data.phases;
|
|||||||
Systematische digitale Forensik nach bewährter NIST SP 800-86 Methodik.<br>
|
Systematische digitale Forensik nach bewährter NIST SP 800-86 Methodik.<br>
|
||||||
Wählen Sie Ihren Ansatz für die Werkzeugauswahl:
|
Wählen Sie Ihren Ansatz für die Werkzeugauswahl:
|
||||||
</p>
|
</p>
|
||||||
|
<div class="ai-hero-spotlight">
|
||||||
|
<div class="ai-spotlight-content">
|
||||||
|
<div class="ai-spotlight-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
|
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ai-spotlight-text">
|
||||||
|
<h3>Forensic AI-Beratung</h3>
|
||||||
|
<p>Analyse des Untersuchungsszenarios mit Empfehlungen zum Vorgehen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiAuthRequired && !aiAuthContext?.authenticated ? (
|
||||||
|
<div class="ai-auth-required">
|
||||||
|
<button id="ai-login-btn" class="btn btn-accent btn-lg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Anmelden für KI-Beratung
|
||||||
|
</button>
|
||||||
|
<p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); text-align: center;">
|
||||||
|
Authentifizierung erforderlich für KI-Features
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button id="ai-query-btn" class="btn btn-accent btn-lg ai-primary-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
|
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
||||||
|
</svg>
|
||||||
|
KI-Beratung starten
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="ml-2">
|
||||||
|
<line x1="7" y1="17" x2="17" y2="7"/>
|
||||||
|
<polyline points="7,7 17,7 17,17"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="ai-features-mini">
|
||||||
|
<span class="badge badge-secondary">Workflow-Empfehlungen</span>
|
||||||
|
<span class="badge badge-secondary">Transparenz</span>
|
||||||
|
<span class="badge badge-secondary">Sofortige Analyse</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="approach-selector">
|
<div class="approach-selector">
|
||||||
<div class="approach-card methodology" onclick="selectApproach('methodology')">
|
<div class="approach-card methodology" onclick="selectApproach('methodology')">
|
||||||
@@ -63,7 +121,7 @@ const phases = data.phases;
|
|||||||
Teilnehmer der Seminargruppe CC24-w1 (oder andere Berechtigte) können die gehostete Infrastruktur nutzen.
|
Teilnehmer der Seminargruppe CC24-w1 (oder andere Berechtigte) können die gehostete Infrastruktur nutzen.
|
||||||
<a href="/about#support">Kontakt bei Problemen</a>
|
<a href="/about#support">Kontakt bei Problemen</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<a href="/about" class="btn btn-secondary">
|
<a href="/about" class="btn btn-secondary">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -74,14 +132,6 @@ const phases = data.phases;
|
|||||||
Infos, SSO & Zugang
|
Infos, SSO & Zugang
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button id="ai-query-btn" class="btn btn-accent">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
|
||||||
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
|
||||||
</svg>
|
|
||||||
KI-Beratung
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a href="/contribute" class="btn" style="background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
|
<a href="/contribute" class="btn" style="background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
@@ -153,7 +203,39 @@ const phases = data.phases;
|
|||||||
<ToolFilters data={data} />
|
<ToolFilters data={data} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AIQueryInterface />
|
{aiAuthRequired && !aiAuthContext?.authenticated ? (
|
||||||
|
<section id="ai-interface" class="ai-interface hidden">
|
||||||
|
<div class="ai-query-section">
|
||||||
|
<div class="content-center-lg">
|
||||||
|
<div class="card" style="text-align: center; padding: 3rem; border-left: 4px solid var(--color-accent);">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.5" style="margin: 0 auto;">
|
||||||
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
|
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 style="margin-bottom: 1rem; color: var(--color-primary);">Anmeldung erforderlich</h2>
|
||||||
|
<p style="margin-bottom: 2rem; color: var(--color-text-secondary); line-height: 1.6;">
|
||||||
|
Für die Nutzung der KI-Beratung ist eine Authentifizierung erforderlich.
|
||||||
|
Melden Sie sich an, um personalisierten Workflow-Empfehlungen und Tool-Analysen zu erhalten.
|
||||||
|
</p>
|
||||||
|
<a href={`/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`}
|
||||||
|
class="btn btn-accent btn-lg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Anmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<AIQueryInterface />
|
||||||
|
)}
|
||||||
|
|
||||||
<section id="tools-grid" style="padding-bottom: 2rem;">
|
<section id="tools-grid" style="padding-bottom: 2rem;">
|
||||||
<div class="grid-auto-fit" id="tools-container">
|
<div class="grid-auto-fit" id="tools-container">
|
||||||
@@ -170,33 +252,34 @@ const phases = data.phases;
|
|||||||
<ToolMatrix data={data} />
|
<ToolMatrix data={data} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: data.tools, phases: data.phases }}>
|
<script define:vars={{ toolsData: data.tools, phases: data.phases, aiAuthRequired: aiAuthRequired, aiAuthenticated: aiAuthContext?.authenticated }}>
|
||||||
window.toolsData = toolsData;
|
window.toolsData = toolsData;
|
||||||
|
|
||||||
// Approach selection functionality
|
|
||||||
window.selectApproach = function(approach) {
|
window.selectApproach = function(approach) {
|
||||||
console.log(`Selected approach: ${approach}`);
|
console.log(`Selected approach: ${approach}`);
|
||||||
|
|
||||||
// Hide any existing results
|
|
||||||
const aiResults = document.getElementById('ai-results');
|
const aiResults = document.getElementById('ai-results');
|
||||||
if (aiResults) aiResults.style.display = 'none';
|
if (aiResults) aiResults.style.display = 'none';
|
||||||
|
|
||||||
// Visual feedback for selection
|
|
||||||
document.querySelectorAll('.approach-card').forEach(card => {
|
document.querySelectorAll('.approach-card').forEach(card => {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
});
|
});
|
||||||
document.querySelector(`.approach-card.${approach}`).classList.add('selected');
|
|
||||||
|
const selectedCard = document.querySelector(`.approach-card.${approach}`);
|
||||||
|
if (selectedCard) selectedCard.classList.add('selected');
|
||||||
|
|
||||||
|
const methodologySection = document.getElementById('methodology-section');
|
||||||
|
const targetedSection = document.getElementById('targeted-section');
|
||||||
|
|
||||||
|
if (methodologySection) methodologySection.classList.remove('active');
|
||||||
|
if (targetedSection) targetedSection.classList.remove('active');
|
||||||
|
|
||||||
if (approach === 'methodology') {
|
if (approach === 'methodology') {
|
||||||
// Show NIST methodology section
|
|
||||||
const methodologySection = document.getElementById('methodology-section');
|
|
||||||
if (methodologySection) {
|
if (methodologySection) {
|
||||||
methodologySection.classList.add('active');
|
methodologySection.classList.add('active');
|
||||||
window.scrollToElementById('methodology-section');
|
window.scrollToElementById('methodology-section');
|
||||||
}
|
}
|
||||||
} else if (approach === 'targeted') {
|
} else if (approach === 'targeted') {
|
||||||
// Show targeted scenarios section
|
|
||||||
const targetedSection = document.getElementById('targeted-section');
|
|
||||||
if (targetedSection) {
|
if (targetedSection) {
|
||||||
targetedSection.classList.add('active');
|
targetedSection.classList.add('active');
|
||||||
window.scrollToElementById('targeted-section');
|
window.scrollToElementById('targeted-section');
|
||||||
@@ -204,35 +287,34 @@ const phases = data.phases;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase selection function (integrates with existing ToolFilters)
|
|
||||||
window.selectPhase = function(phase) {
|
window.selectPhase = function(phase) {
|
||||||
console.log(`Selected NIST phase: ${phase}`);
|
console.log(`Selected NIST phase: ${phase}`);
|
||||||
|
|
||||||
// Remove active class from all phase cards
|
|
||||||
document.querySelectorAll('.phase-card').forEach(card => {
|
document.querySelectorAll('.phase-card').forEach(card => {
|
||||||
card.classList.remove('active');
|
card.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add active class to selected phase card
|
|
||||||
const selectedCard = document.querySelector(`.phase-card.phase-${phase}`);
|
const selectedCard = document.querySelector(`.phase-card.phase-${phase}`);
|
||||||
if (selectedCard) {
|
if (selectedCard) {
|
||||||
selectedCard.classList.add('active');
|
selectedCard.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use existing phase filter functionality
|
const phaseSelect = document.getElementById('phase-select');
|
||||||
const existingPhaseButton = document.querySelector(`[data-phase="${phase}"]`);
|
if (phaseSelect) {
|
||||||
if (existingPhaseButton && !existingPhaseButton.classList.contains('active')) {
|
phaseSelect.value = phase;
|
||||||
existingPhaseButton.click();
|
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
phaseSelect.dispatchEvent(changeEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to grid view to show results
|
|
||||||
const gridToggle = document.querySelector('.view-toggle[data-view="grid"]');
|
const gridToggle = document.querySelector('.view-toggle[data-view="grid"]');
|
||||||
if (gridToggle && !gridToggle.classList.contains('active')) {
|
if (gridToggle && !gridToggle.classList.contains('active')) {
|
||||||
gridToggle.click();
|
gridToggle.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to results using consolidated utility
|
setTimeout(() => {
|
||||||
window.scrollToElementById('tools-grid');
|
window.scrollToElementById('tools-grid');
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -243,96 +325,113 @@ const phases = data.phases;
|
|||||||
const filtersSection = document.getElementById('filters-section');
|
const filtersSection = document.getElementById('filters-section');
|
||||||
const noResults = document.getElementById('no-results');
|
const noResults = document.getElementById('no-results');
|
||||||
const aiQueryBtn = document.getElementById('ai-query-btn');
|
const aiQueryBtn = document.getElementById('ai-query-btn');
|
||||||
|
const aiLoginBtn = document.getElementById('ai-login-btn');
|
||||||
|
|
||||||
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
|
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
|
||||||
console.error('Required DOM elements not found');
|
console.error('Required DOM elements not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aiLoginBtn) {
|
||||||
|
aiLoginBtn.addEventListener('click', () => {
|
||||||
|
const currentUrl = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = `/api/auth/login?returnTo=${currentUrl}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (aiQueryBtn) {
|
if (aiQueryBtn) {
|
||||||
aiQueryBtn.addEventListener('click', async () => {
|
aiQueryBtn.addEventListener('click', () => {
|
||||||
if (typeof window.requireClientAuth === 'function') {
|
aiQueryBtn.classList.add('activated');
|
||||||
await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
|
setTimeout(() => aiQueryBtn.classList.remove('activated'), 400);
|
||||||
|
|
||||||
|
switchToView('ai');
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('viewChanged', { detail: 'ai' }));
|
||||||
|
|
||||||
|
if (window.scrollToElementById) {
|
||||||
|
window.scrollToElementById('ai-interface');
|
||||||
} else {
|
} else {
|
||||||
console.warn('[AUTH] requireClientAuth not available');
|
aiInterface.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
switchToView('ai');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToView(view) {
|
function switchToView(view) {
|
||||||
toolsGrid.style.display = 'none';
|
console.log('[VIEW] Switching to view:', view);
|
||||||
matrixContainer.style.display = 'none';
|
|
||||||
aiInterface.style.display = 'none';
|
const toolsGrid = document.getElementById('tools-grid');
|
||||||
filtersSection.style.display = 'none';
|
const matrixContainer = document.getElementById('matrix-container');
|
||||||
|
const aiInterface = document.getElementById('ai-interface');
|
||||||
const viewToggles = document.querySelectorAll('.view-toggle');
|
const filtersSection = document.getElementById('filters-section');
|
||||||
viewToggles.forEach(btn => {
|
const noResults = document.getElementById('no-results');
|
||||||
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
|
|
||||||
});
|
const methodologySection = document.getElementById('methodology-section');
|
||||||
|
const targetedSection = document.getElementById('targeted-section');
|
||||||
|
|
||||||
|
if (toolsGrid) toolsGrid.style.display = 'none';
|
||||||
|
if (matrixContainer) {
|
||||||
|
matrixContainer.style.display = 'none';
|
||||||
|
matrixContainer.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (aiInterface) aiInterface.style.display = 'none';
|
||||||
|
if (noResults) noResults.style.display = 'none';
|
||||||
|
|
||||||
|
if (methodologySection) methodologySection.classList.remove('active');
|
||||||
|
if (targetedSection) targetedSection.classList.remove('active');
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'ai':
|
case 'grid':
|
||||||
aiInterface.style.display = 'block';
|
console.log('[VIEW] Showing grid view');
|
||||||
filtersSection.style.display = 'block';
|
if (toolsGrid) toolsGrid.style.display = 'block';
|
||||||
hideFilterControls();
|
if (filtersSection) filtersSection.style.display = 'block';
|
||||||
if (window.restoreAIResults) {
|
|
||||||
window.restoreAIResults();
|
|
||||||
}
|
|
||||||
const aiInput = document.getElementById('ai-query-input');
|
|
||||||
if (aiInput) {
|
|
||||||
setTimeout(() => aiInput.focus(), 100);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'matrix':
|
case 'matrix':
|
||||||
matrixContainer.style.display = 'block';
|
console.log('[VIEW] Showing matrix view');
|
||||||
filtersSection.style.display = 'block';
|
if (matrixContainer) {
|
||||||
showFilterControls();
|
matrixContainer.style.display = 'block';
|
||||||
|
matrixContainer.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (filtersSection) filtersSection.style.display = 'block';
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
toolsGrid.style.display = 'block';
|
case 'ai':
|
||||||
filtersSection.style.display = 'block';
|
console.log('[VIEW] Showing AI view');
|
||||||
showFilterControls();
|
if (aiAuthRequired && !aiAuthenticated) {
|
||||||
|
console.log('[AUTH] AI access denied, redirecting to login');
|
||||||
|
const currentUrl = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = `/api/auth/login?returnTo=${currentUrl}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiInterface) aiInterface.style.display = 'block';
|
||||||
|
|
||||||
|
if (filtersSection) {
|
||||||
|
filtersSection.style.display = 'block';
|
||||||
|
|
||||||
|
const filterSections = filtersSection.querySelectorAll('.filter-section');
|
||||||
|
filterSections.forEach((section, index) => {
|
||||||
|
if (index === filterSections.length - 1) {
|
||||||
|
section.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
section.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('[VIEW] Unknown view:', view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add smooth scrolling to filters section after layout settles
|
if (view !== 'ai' && filtersSection) {
|
||||||
setTimeout(() => {
|
const filterSections = filtersSection.querySelectorAll('.filter-section');
|
||||||
window.scrollToElementById('filters-section');
|
filterSections.forEach(section => {
|
||||||
}, 150);
|
section.style.display = 'block';
|
||||||
|
});
|
||||||
if (window.location.search) {
|
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideFilterControls() {
|
|
||||||
const filterSections = document.querySelectorAll('.filter-section');
|
|
||||||
filterSections.forEach((section, index) => {
|
|
||||||
if (index < filterSections.length - 1) {
|
|
||||||
section.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFilterControls() {
|
|
||||||
const filterSections = document.querySelectorAll('.filter-section');
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
const tagCloud = document.querySelector('.tag-cloud');
|
|
||||||
const tagControls = document.querySelector('.tag-controls');
|
|
||||||
const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper');
|
|
||||||
const allInputs = filtersSection.querySelectorAll('input, select, textarea');
|
|
||||||
|
|
||||||
filterSections.forEach(section => section.style.display = 'block');
|
|
||||||
|
|
||||||
if (searchInput) searchInput.style.display = 'block';
|
|
||||||
if (tagCloud) tagCloud.style.display = 'flex';
|
|
||||||
if (tagControls) tagControls.style.display = 'flex';
|
|
||||||
|
|
||||||
allInputs.forEach(input => input.style.display = 'block');
|
|
||||||
checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.navigateToGrid = function(toolName) {
|
window.navigateToGrid = function(toolName) {
|
||||||
console.log('Navigating to grid for tool:', toolName);
|
console.log('Navigating to grid for tool:', toolName);
|
||||||
|
|
||||||
@@ -369,7 +468,6 @@ const phases = data.phases;
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Tool card not found in grid:', toolName);
|
console.warn('Tool card not found in grid:', toolName);
|
||||||
// Fallback to tools grid
|
|
||||||
window.scrollToElementById('tools-grid');
|
window.scrollToElementById('tools-grid');
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -407,13 +505,12 @@ const phases = data.phases;
|
|||||||
window.scrollToElement(firstMatch);
|
window.scrollToElement(firstMatch);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Tool chip not found in matrix:', toolName);
|
console.warn('Tool chip not found in matrix:', toolName);
|
||||||
// Fallback to matrix container
|
|
||||||
window.scrollToElementById('matrix-container');
|
window.scrollToElementById('matrix-container');
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleSharedURL() {
|
function handleSharedURL() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const toolParam = urlParams.get('tool');
|
const toolParam = urlParams.get('tool');
|
||||||
const viewParam = urlParams.get('view');
|
const viewParam = urlParams.get('view');
|
||||||
@@ -425,13 +522,19 @@ const phases = data.phases;
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!window.findToolByIdentifier) {
|
||||||
|
console.error('[SHARE] findToolByIdentifier not available, retrying...');
|
||||||
|
setTimeout(() => handleSharedURL(), 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tool = window.findToolByIdentifier(window.toolsData, toolParam);
|
const tool = window.findToolByIdentifier(window.toolsData, toolParam);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
console.warn('Shared tool not found:', toolParam);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
|
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
|
||||||
window.history.replaceState({}, document.title, cleanUrl);
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
|
|
||||||
@@ -453,11 +556,11 @@ const phases = data.phases;
|
|||||||
default:
|
default:
|
||||||
window.navigateToGrid(tool.name);
|
window.navigateToGrid(tool.name);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('toolsFiltered', (event) => {
|
window.addEventListener('toolsFiltered', (event) => {
|
||||||
const filtered = event.detail;
|
const { tools: filtered, semanticSearch } = event.detail;
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
||||||
|
|
||||||
if (currentView === 'matrix' || currentView === 'ai') {
|
if (currentView === 'matrix' || currentView === 'ai') {
|
||||||
@@ -466,33 +569,125 @@ const phases = data.phases;
|
|||||||
|
|
||||||
const allToolCards = document.querySelectorAll('.tool-card');
|
const allToolCards = document.querySelectorAll('.tool-card');
|
||||||
const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
|
const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
|
||||||
|
const toolsContainer = document.getElementById('tools-container');
|
||||||
|
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
allToolCards.forEach(card => {
|
if (semanticSearch && filtered.length > 0) {
|
||||||
const toolName = card.getAttribute('data-tool-name');
|
console.log('[SEMANTIC] Reordering tools by semantic similarity');
|
||||||
if (filteredNames.has(toolName)) {
|
|
||||||
card.style.display = 'block';
|
const orderedCards = [];
|
||||||
visibleCount++;
|
const remainingCards = [];
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
filtered.forEach(tool => {
|
||||||
|
const toolName = tool.name.toLowerCase();
|
||||||
|
const matchingCard = Array.from(allToolCards).find(card =>
|
||||||
|
card.getAttribute('data-tool-name') === toolName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingCard) {
|
||||||
|
matchingCard.style.display = 'block';
|
||||||
|
orderedCards.push(matchingCard);
|
||||||
|
visibleCount++;
|
||||||
|
|
||||||
|
if (tool._semanticSimilarity) {
|
||||||
|
matchingCard.setAttribute('data-semantic-similarity', tool._semanticSimilarity.toFixed(3));
|
||||||
|
matchingCard.setAttribute('data-semantic-rank', tool._semanticRank || '');
|
||||||
|
|
||||||
|
const header = matchingCard.querySelector('.tool-card-header h3');
|
||||||
|
if (header && tool._semanticRank <= 3) {
|
||||||
|
const existingIndicator = header.querySelector('.semantic-rank-indicator');
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicator = document.createElement('span');
|
||||||
|
indicator.className = 'semantic-rank-indicator';
|
||||||
|
indicator.style.cssText = `
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
opacity: ${1 - (tool._semanticRank - 1) * 0.3};
|
||||||
|
`;
|
||||||
|
indicator.title = `Semantische Relevanz: ${tool._semanticSimilarity.toFixed(3)}`;
|
||||||
|
header.appendChild(indicator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
allToolCards.forEach(card => {
|
||||||
|
const toolName = card.getAttribute('data-tool-name');
|
||||||
|
if (!filteredNames.has(toolName)) {
|
||||||
|
card.style.display = 'none';
|
||||||
|
remainingCards.push(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCards = [...orderedCards, ...remainingCards];
|
||||||
|
allCards.forEach(card => {
|
||||||
|
toolsContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
allToolCards.forEach(card => {
|
||||||
|
const toolName = card.getAttribute('data-tool-name');
|
||||||
|
|
||||||
|
card.removeAttribute('data-semantic-similarity');
|
||||||
|
card.removeAttribute('data-semantic-rank');
|
||||||
|
const semanticIndicator = card.querySelector('.semantic-rank-indicator');
|
||||||
|
if (semanticIndicator) {
|
||||||
|
semanticIndicator.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredNames.has(toolName)) {
|
||||||
|
card.style.display = 'block';
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!semanticSearch) {
|
||||||
|
const originalOrder = Array.from(allToolCards).sort((a, b) => {
|
||||||
|
const aIndex = Array.from(allToolCards).indexOf(a);
|
||||||
|
const bIndex = Array.from(allToolCards).indexOf(b);
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
originalOrder.forEach(card => {
|
||||||
|
toolsContainer.appendChild(card);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (visibleCount === 0) {
|
if (visibleCount === 0) {
|
||||||
noResults.style.display = 'block';
|
noResults.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
noResults.style.display = 'none';
|
noResults.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (semanticSearch) {
|
||||||
|
console.log(`[SEMANTIC] Displayed ${visibleCount} tools in semantic order`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('viewChanged', (event) => {
|
window.addEventListener('viewChanged', (event) => {
|
||||||
const view = event.detail;
|
const view = event.detail;
|
||||||
switchToView(view);
|
if (!event.triggeredByButton) {
|
||||||
|
switchToView(view);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.switchToAIView = () => switchToView('ai');
|
window.switchToAIView = () => switchToView('ai');
|
||||||
|
window.switchToView = switchToView;
|
||||||
|
|
||||||
handleSharedURL();
|
setTimeout(() => {
|
||||||
|
handleSharedURL();
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
</BaseLayout>
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
---
|
---
|
||||||
|
//src/pages/knowledgebase.astro
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
import ContributionButton from '../components/ContributionButton.astro';
|
import ContributionButton from '../components/ContributionButton.astro';
|
||||||
|
import { isGatedContentAuthRequired } from '../utils/auth.js';
|
||||||
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
|
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
|
||||||
return entry.data.published !== false;
|
return entry.data.published !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gatedContentAuthEnabled = isGatedContentAuthRequired();
|
||||||
|
|
||||||
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
||||||
const associatedTool = entry.data.tool_name
|
const associatedTool = entry.data.tool_name
|
||||||
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
|
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
|
||||||
@@ -23,7 +27,7 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
difficulty: entry.data.difficulty,
|
difficulty: entry.data.difficulty,
|
||||||
categories: entry.data.categories || [],
|
categories: entry.data.categories || [],
|
||||||
tags: entry.data.tags || [],
|
tags: entry.data.tags || [],
|
||||||
|
gated_content: entry.data.gated_content || false,
|
||||||
tool_name: entry.data.tool_name,
|
tool_name: entry.data.tool_name,
|
||||||
related_tools: entry.data.related_tools || [],
|
related_tools: entry.data.related_tools || [],
|
||||||
associatedTool,
|
associatedTool,
|
||||||
@@ -39,6 +43,9 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
const gatedCount = knowledgebaseEntries.filter(entry => entry.gated_content).length;
|
||||||
|
const publicCount = knowledgebaseEntries.length - gatedCount;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
|
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
|
||||||
@@ -52,6 +59,24 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
|
Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{gatedContentAuthEnabled && gatedCount > 0 && (
|
||||||
|
<div class="gated-content-info mb-4 p-3 rounded" style="background-color: var(--color-bg-secondary); border: 1px solid var(--color-border);">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm text-secondary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{gatedCount} geschützte Artikel • {publicCount} öffentliche Artikel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-secondary mt-1">
|
||||||
|
🔒 Geschützte Artikel erfordern Authentifizierung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="flex gap-4 justify-center flex-wrap">
|
<div class="flex gap-4 justify-center flex-wrap">
|
||||||
<ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
|
<ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
|
||||||
<button onclick="window.scrollToElementById('kb-entries')" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
|
<button onclick="window.scrollToElementById('kb-entries')" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
|
||||||
@@ -60,7 +85,7 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
Artikel durchsuchen
|
Artikel durchsuchen
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,6 +132,9 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
const isMethod = hasAssociatedTool && entry.associatedTool.type === 'method';
|
const isMethod = hasAssociatedTool && entry.associatedTool.type === 'method';
|
||||||
const isConcept = hasAssociatedTool && entry.associatedTool.type === 'concept';
|
const isConcept = hasAssociatedTool && entry.associatedTool.type === 'concept';
|
||||||
const isStandalone = !hasAssociatedTool;
|
const isStandalone = !hasAssociatedTool;
|
||||||
|
const isGated = entry.gated_content === true;
|
||||||
|
|
||||||
|
const articleUrl = `/knowledgebase/${entry.slug}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -114,7 +142,8 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
id={`kb-${entry.slug}`}
|
id={`kb-${entry.slug}`}
|
||||||
data-tool-name={entry.title.toLowerCase()}
|
data-tool-name={entry.title.toLowerCase()}
|
||||||
data-article-type={isStandalone ? 'standalone' : 'tool-associated'}
|
data-article-type={isStandalone ? 'standalone' : 'tool-associated'}
|
||||||
onclick={`window.location.href='/knowledgebase/${entry.slug}'`}
|
data-gated={isGated}
|
||||||
|
onclick={`window.location.href='${articleUrl}'`}
|
||||||
>
|
>
|
||||||
<!-- Card Header -->
|
<!-- Card Header -->
|
||||||
<div class="flex-between mb-3">
|
<div class="flex-between mb-3">
|
||||||
@@ -123,6 +152,11 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-lg font-semibold text-primary mb-1 leading-tight">
|
<h3 class="text-lg font-semibold text-primary mb-1 leading-tight">
|
||||||
{entry.title}
|
{entry.title}
|
||||||
|
{isGated && gatedContentAuthEnabled && (
|
||||||
|
<span class="gated-indicator ml-2" title="Geschützter Inhalt - Authentifizierung erforderlich">
|
||||||
|
🔒
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex gap-2 flex-wrap mb-2">
|
<div class="flex gap-2 flex-wrap mb-2">
|
||||||
{isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
|
{isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
|
||||||
@@ -132,26 +166,53 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
||||||
{hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
|
{hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
|
||||||
<span class="badge badge-error">📖</span>
|
<span class="badge badge-error">📖</span>
|
||||||
|
{isGated && gatedContentAuthEnabled && <span class="badge badge-warning">🔒</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 flex-shrink-0" onclick="event.stopPropagation();">
|
<div class="flex gap-2 flex-shrink-0" onclick="event.stopPropagation();">
|
||||||
<a href={`/knowledgebase/${entry.slug}`} class="btn btn-primary btn-sm">
|
<a href={articleUrl}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
title={isGated && isGatedContentAuthRequired() ? "Geschützter Inhalt - Anmeldung erforderlich" : "Artikel öffnen"}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
Öffnen
|
{isGated && gatedContentAuthEnabled ? 'Anmelden' : 'Öffnen'}
|
||||||
</a>
|
</a>
|
||||||
<ContributionButton type="edit" toolName={entry.tool_name || entry.title} variant="secondary" text="Edit" style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;" />
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
onclick="window.shareArticle(this, '${articleUrl}', '${entry.title}')"
|
||||||
|
title="Artikel teilen"
|
||||||
|
style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
|
||||||
|
<circle cx="18" cy="5" r="3"/>
|
||||||
|
<circle cx="6" cy="12" r="3"/>
|
||||||
|
<circle cx="18" cy="19" r="3"/>
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
|
</svg>
|
||||||
|
Teilen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="text-secondary mb-4 leading-relaxed">
|
<p class="text-secondary mb-4 leading-relaxed">
|
||||||
{entry.description}
|
{entry.description}
|
||||||
|
{isGated && gatedContentAuthEnabled && (
|
||||||
|
<span class="gated-content-hint ml-2 text-xs">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; margin-right: 0.25rem;">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Authentifizierung erforderlich
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Metadata Footer -->
|
<!-- Metadata Footer -->
|
||||||
@@ -284,4 +345,25 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gated-indicator {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-content-hint {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-entry[data-gated="true"] {
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-content-info {
|
||||||
|
border-left: 4px solid var(--color-warning) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getToolsData } from '../../utils/dataService.js';
|
import { getToolsData } from '../../utils/dataService.js';
|
||||||
|
import { isGatedContentAuthRequired } from '../../utils/auth.js';
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
@@ -20,6 +21,12 @@ export async function getStaticPaths() {
|
|||||||
|
|
||||||
const { entry }: { entry: any } = Astro.props;
|
const { entry }: { entry: any } = Astro.props;
|
||||||
|
|
||||||
|
const isGatedContent = entry.data.gated_content === true;
|
||||||
|
const gatedContentAuthRequired = isGatedContentAuthRequired();
|
||||||
|
const requiresAuth = isGatedContent && gatedContentAuthRequired;
|
||||||
|
|
||||||
|
console.log(`[GATED CONTENT] Article: ${entry.data.title}, Gated: ${isGatedContent}, Auth Required: ${requiresAuth}`);
|
||||||
|
|
||||||
const { Content } = await entry.render();
|
const { Content } = await entry.render();
|
||||||
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
@@ -47,192 +54,634 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
displayTool.projectUrl !== null &&
|
displayTool.projectUrl !== null &&
|
||||||
displayTool.projectUrl !== "" &&
|
displayTool.projectUrl !== "" &&
|
||||||
displayTool.projectUrl.trim() !== "";
|
displayTool.projectUrl.trim() !== "";
|
||||||
|
|
||||||
|
const currentUrl = Astro.url.href;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
||||||
<article style="max-width: 900px; margin: 0 auto;">
|
{requiresAuth && (
|
||||||
<header style="margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<script define:vars={{ requiresAuth, articleTitle: entry.data.title }}>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
<div style="flex: 1;">
|
if (!requiresAuth) return;
|
||||||
<h1 style="margin: 0 0 0.5rem 0; color: var(--color-primary);">
|
|
||||||
{displayTool?.icon && <span style="margin-right: 0.75rem; font-size: 1.5rem;">{displayTool.icon}</span>}
|
console.log('[GATED CONTENT] Checking client-side auth for: ' + articleTitle);
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const authSuccess = urlParams.get('auth') === 'success';
|
||||||
|
|
||||||
|
const contentArea = document.querySelector('.article-content');
|
||||||
|
const sidebar = document.querySelector('.article-sidebar');
|
||||||
|
|
||||||
|
if (contentArea) {
|
||||||
|
contentArea.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authSuccess) {
|
||||||
|
console.log('[GATED CONTENT] Auth success detected, waiting for session...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
|
||||||
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const authStatus = await response.json();
|
||||||
|
|
||||||
|
const isAuthenticated = authStatus.gatedContentAuthenticated || false;
|
||||||
|
const authRequired = authStatus.gatedContentAuthRequired || false;
|
||||||
|
|
||||||
|
console.log('[GATED CONTENT] Auth status - Required: ' + authRequired + ', Authenticated: ' + isAuthenticated);
|
||||||
|
|
||||||
|
if (authRequired && !isAuthenticated) {
|
||||||
|
console.log('[GATED CONTENT] Access denied - showing auth required message: ' + articleTitle);
|
||||||
|
|
||||||
|
if (contentArea) {
|
||||||
|
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
||||||
|
contentArea.innerHTML = [
|
||||||
|
'<div class="gated-content-block">',
|
||||||
|
'<div class="gated-icon">🔒</div>',
|
||||||
|
'<h3 class="gated-title">Authentifizierung erforderlich</h3>',
|
||||||
|
'<p class="gated-description">Dieser Artikel enthält geschützte Inhalte und ist nur für authentifizierte Benutzer zugänglich.</p>',
|
||||||
|
'<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-bottom: 1.5rem;">',
|
||||||
|
'<a href="' + loginUrl + '" class="btn btn-primary" style="min-width: 140px; text-align: center;">',
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">',
|
||||||
|
'<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>',
|
||||||
|
'<polyline points="10 17 15 12 10 7"/>',
|
||||||
|
'<line x1="15" y1="12" x2="3" y2="12"/>',
|
||||||
|
'</svg>',
|
||||||
|
'Anmelden',
|
||||||
|
'</a>',
|
||||||
|
'<a href="/knowledgebase" class="btn btn-secondary" style="min-width: 140px; text-align: center;">Zurück zur Übersicht</a>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="gated-help">',
|
||||||
|
'<small>Nach der Anmeldung werden Sie automatisch zu diesem Artikel zurückgeleitet.</small>',
|
||||||
|
'</div>',
|
||||||
|
'</div>'
|
||||||
|
].join('');
|
||||||
|
contentArea.style.display = 'block';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[GATED CONTENT] Access granted for: ' + articleTitle);
|
||||||
|
if (contentArea) {
|
||||||
|
contentArea.style.display = 'block';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof generateTOCContent === 'function') {
|
||||||
|
generateTOCContent();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GATED CONTENT] Auth check failed:', error);
|
||||||
|
if (requiresAuth && contentArea) {
|
||||||
|
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
||||||
|
contentArea.innerHTML = [
|
||||||
|
'<div class="gated-content-block">',
|
||||||
|
'<div class="gated-icon error">⚠️</div>',
|
||||||
|
'<h3 class="gated-title">Authentifizierungsfehler</h3>',
|
||||||
|
'<p class="gated-description">Es gab ein Problem bei der Überprüfung Ihrer Berechtigung. Bitte versuchen Sie es erneut.</p>',
|
||||||
|
'<div class="gated-actions">',
|
||||||
|
'<a href="' + loginUrl + '" class="btn btn-primary">Anmelden</a>',
|
||||||
|
'<button onclick="location.reload()" class="btn btn-secondary">Seite neu laden</button>',
|
||||||
|
'</div>',
|
||||||
|
'</div>'
|
||||||
|
].join('');
|
||||||
|
contentArea.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="article-layout">
|
||||||
|
<!-- Article Header -->
|
||||||
|
<header class="article-header">
|
||||||
|
<div class="article-header-content">
|
||||||
|
<div class="article-meta">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/knowledgebase" class="breadcrumb-link">Knowledgebase</a>
|
||||||
|
<span class="breadcrumb-separator">→</span>
|
||||||
|
<span class="breadcrumb-current">{entry.data.title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="article-tags">
|
||||||
|
{entry.data.categories?.map((cat: string) => (
|
||||||
|
<span class="article-tag article-tag-category">{cat}</span>
|
||||||
|
))}
|
||||||
|
{entry.data.tags?.map((tag: string) => (
|
||||||
|
<span class="article-tag">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-title-section">
|
||||||
|
<h1 class="article-title">
|
||||||
|
{displayTool?.icon && <span class="article-icon">{displayTool.icon}</span>}
|
||||||
{entry.data.title}
|
{entry.data.title}
|
||||||
</h1>
|
{isGatedContent && (
|
||||||
<p style="margin: 0; color: var(--color-text-secondary); font-size: 1.125rem;">
|
<span class="gated-indicator" title="Geschützter Inhalt - Authentifizierung erforderlich">
|
||||||
{entry.data.description}
|
🔒
|
||||||
</p>
|
</span>
|
||||||
</div>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
||||||
{isStandalone ? (
|
|
||||||
<span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
|
|
||||||
{isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
|
|
||||||
{!isMethod && !isConcept && !isStandalone && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
|
|
||||||
{!isMethod && !isConcept && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
|
||||||
{!isMethod && !isConcept && !isStandalone && displayTool?.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<span class="badge badge-error">📖</span>
|
</h1>
|
||||||
</div>
|
<p class="article-description">{entry.data.description}</p>
|
||||||
</div>
|
|
||||||
</div>
|
{isGatedContent && gatedContentAuthRequired && (
|
||||||
|
<div class="gated-content-notice">
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
{entry.data.difficulty && (
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
<div>
|
<circle cx="12" cy="16" r="1"/>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.difficulty}</p>
|
</svg>
|
||||||
</div>
|
<span>Dieser Artikel enthält geschützte Inhalte</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong>
|
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="article-metadata-grid">
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong>
|
<div class="metadata-item">
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p>
|
<span class="metadata-label">Typ</span>
|
||||||
</div>
|
<div class="metadata-badges">
|
||||||
|
{isStandalone ? (
|
||||||
<div>
|
<span class="badge badge-accent">Artikel</span>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Typ</strong>
|
) : (
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">
|
<>
|
||||||
{isStandalone ? 'Allgemeiner Artikel' :
|
{isConcept && <span class="badge badge-concept">Konzept</span>}
|
||||||
isConcept ? 'Konzept-Artikel' :
|
{isMethod && <span class="badge badge-method">Methode</span>}
|
||||||
isMethod ? 'Methoden-Artikel' :
|
{!isMethod && !isConcept && <span class="badge badge-primary">Software</span>}
|
||||||
'Software-Artikel'}
|
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
||||||
</p>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
<span class="badge badge-error">📖</span>
|
||||||
{entry.data.categories && entry.data.categories.length > 0 && (
|
{isGatedContent && <span class="badge badge-warning">🔒</span>}
|
||||||
<div style="grid-column: 1 / -1;">
|
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong>
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem;">
|
|
||||||
{entry.data.categories.map((cat: string) => (
|
|
||||||
<span class="tag" style="font-size: 0.75rem;">{cat}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{entry.data.difficulty && (
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Schwierigkeit</span>
|
||||||
|
<span class="metadata-value">{entry.data.difficulty}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Aktualisiert</span>
|
||||||
|
<span class="metadata-value">{entry.data.last_updated.toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Autor</span>
|
||||||
|
<span class="metadata-value">{entry.data.author}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Lesezeit</span>
|
||||||
|
<span class="metadata-value" id="reading-time">~5 min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-actions">
|
||||||
|
<button
|
||||||
|
id="share-article-btn"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick="window.shareCurrentArticle(this)"
|
||||||
|
title="Artikel teilen"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="18" cy="5" r="3"/>
|
||||||
|
<circle cx="6" cy="12" r="3"/>
|
||||||
|
<circle cx="18" cy="19" r="3"/>
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
|
</svg>
|
||||||
|
Teilen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/knowledgebase" class="btn btn-secondary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15,18 9,12 15,6"></polyline>
|
||||||
|
</svg>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav style="margin-bottom: 2rem; position: relative; z-index: 50;">
|
<!-- Main Content Area -->
|
||||||
<a href="/knowledgebase" class="btn btn-secondary">
|
<div class="article-content-wrapper">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<!-- Sidebar Navigation (will be populated by JS) -->
|
||||||
<polyline points="15,18 9,12 15,6"></polyline>
|
<aside class="article-sidebar">
|
||||||
</svg>
|
<!-- TOC will be inserted here by JavaScript -->
|
||||||
Zurück zur Knowledgebase
|
</aside>
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="card" style="padding: 2rem;">
|
|
||||||
<div class="kb-content markdown-content" style="line-height: 1.7;">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);">
|
|
||||||
<h3 style="margin: 0 0 1rem 0; color: var(--color-text);">
|
|
||||||
{isStandalone ? 'Verwandte Aktionen' : 'Tool-Aktionen'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
<!-- Article Content -->
|
||||||
{isStandalone ? (
|
<main class="article-main">
|
||||||
<a href="/knowledgebase" class="btn btn-primary">
|
<article class="article-content" style={requiresAuth ? "display: none;" : ""}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<div class="markdown-content">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<Content />
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
</div>
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
</article>
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
|
||||||
</svg>
|
|
||||||
Weitere Artikel
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isConcept ? (
|
|
||||||
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Mehr erfahren
|
|
||||||
</a>
|
|
||||||
) : isMethod ? (
|
|
||||||
<a href={displayTool.projectUrl || displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-method); border-color: var(--color-method);">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Zur Methode
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Software-Homepage
|
|
||||||
</a>
|
|
||||||
{hasValidProjectUrl && (
|
|
||||||
<a href={displayTool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<path d="M12 16l4-4-4-4"/>
|
|
||||||
<path d="M8 12h8"/>
|
|
||||||
</svg>
|
|
||||||
Zugreifen
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{relatedTools.length > 0 && relatedTools.length > (primaryTool ? 1 : 0) && (
|
<!-- Article Footer -->
|
||||||
<div style="margin-left: auto;">
|
<footer class="article-footer">
|
||||||
<details style="position: relative;">
|
<div class="article-footer-actions">
|
||||||
<summary class="btn btn-secondary" style="cursor: pointer; list-style: none;">
|
<h3>Links</h3>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<div class="footer-actions-grid">
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
{isStandalone ? (
|
||||||
<circle cx="8.5" cy="7" r="4"/>
|
<a href="/knowledgebase" class="btn btn-primary">
|
||||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
Weitere Artikel
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isConcept ? (
|
||||||
|
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-concept">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Mehr erfahren
|
||||||
|
</a>
|
||||||
|
) : isMethod ? (
|
||||||
|
<a href={displayTool.projectUrl || displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-method">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Zur Methode
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Homepage
|
||||||
|
</a>
|
||||||
|
{hasValidProjectUrl && (
|
||||||
|
<a href={displayTool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 16l4-4-4-4"/>
|
||||||
|
<path d="M8 12h8"/>
|
||||||
|
</svg>
|
||||||
|
Zugreifen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a href="/" class="btn btn-secondary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9,22 9,12 15,12 15,22"/>
|
||||||
</svg>
|
</svg>
|
||||||
Verwandte Tools ({relatedTools.length})
|
Zur Hauptseite
|
||||||
</summary>
|
</a>
|
||||||
<div style="position: absolute; top: 100%; left: 0; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; min-width: 200px; z-index: 100; box-shadow: var(--shadow-lg);">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relatedTools.length > 0 && (
|
||||||
|
<div class="related-tools">
|
||||||
|
<h3>Verwandte Tools</h3>
|
||||||
|
<div class="related-tools-grid">
|
||||||
{relatedTools.map((tool: any) => (
|
{relatedTools.map((tool: any) => (
|
||||||
<a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer"
|
<a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="related-tool-card">
|
||||||
style="display: block; padding: 0.5rem; border-radius: 0.25rem; text-decoration: none; color: var(--color-text); margin-bottom: 0.25rem;"
|
{tool.icon && <span class="tool-icon">{tool.icon}</span>}
|
||||||
onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'"
|
<span class="tool-name">{tool.name}</span>
|
||||||
onmouseout="this.style.backgroundColor='transparent'">
|
|
||||||
{tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
|
|
||||||
{tool.name}
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</footer>
|
||||||
|
</main>
|
||||||
<a href="/" class="btn btn-secondary">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
|
||||||
<polyline points="9,22 9,12 15,12 15,22"/>
|
|
||||||
</svg>
|
|
||||||
Zur Hauptseite
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
|
||||||
|
<script define:vars={{ requiresAuth }}>
|
||||||
|
/** @template {Element} T
|
||||||
|
* @param {string} sel
|
||||||
|
* @param {Document|Element} [root=document]
|
||||||
|
* @returns {T|null} */
|
||||||
|
const qs = (sel, root = document) => root.querySelector(sel);
|
||||||
|
|
||||||
|
/** @template {Element} T
|
||||||
|
* @param {string} sel
|
||||||
|
* @param {Document|Element} [root=document]
|
||||||
|
* @returns {T[]} */
|
||||||
|
const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
|
||||||
|
function calculateReadingTime() {
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
const content = qs('.markdown-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const text = (content.textContent || content.innerText || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const wordsPerMinute = 200;
|
||||||
|
const words = text.split(/\s+/).length;
|
||||||
|
const readingTime = Math.ceil(words / wordsPerMinute);
|
||||||
|
|
||||||
|
const readingTimeElement = document.getElementById('reading-time');
|
||||||
|
if (readingTimeElement) {
|
||||||
|
readingTimeElement.textContent = `~${readingTime} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSidebarTOC() {
|
||||||
|
if (requiresAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTOCContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTOCContent() {
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
const article = qs('.markdown-content');
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
const sidebar = qs('.article-sidebar');
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
const main = qs('.article-main');
|
||||||
|
|
||||||
|
if (!article || !sidebar || !main) return;
|
||||||
|
|
||||||
|
/** @type {HTMLHeadingElement[]} */
|
||||||
|
const headings = qsa('h1, h2, h3, h4, h5, h6', article);
|
||||||
|
|
||||||
|
if (headings.length < 2) {
|
||||||
|
sidebar.style.display = 'none';
|
||||||
|
main.style.maxWidth = '100%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
headings.forEach((h, i) => {
|
||||||
|
if (!h.id) h.id = `heading-${i}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tocHTML = `
|
||||||
|
<div class="sidebar-toc">
|
||||||
|
<h3 class="toc-title">Inhaltsverzeichnis</h3>
|
||||||
|
<nav class="toc-navigation">
|
||||||
|
${headings.map((h) => {
|
||||||
|
const level = parseInt(h.tagName.slice(1), 10);
|
||||||
|
const text = (h.textContent || '').trim();
|
||||||
|
const id = h.id;
|
||||||
|
return `
|
||||||
|
<a href="#${id}" class="toc-item toc-level-${level}" data-heading="${id}">
|
||||||
|
${text}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
sidebar.innerHTML = tocHTML;
|
||||||
|
|
||||||
|
/** @type {HTMLAnchorElement[]} */
|
||||||
|
const tocItems = qsa('.toc-item', sidebar);
|
||||||
|
|
||||||
|
tocItems.forEach((item) => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const href = item.getAttribute('href');
|
||||||
|
if (!href || !href.startsWith('#')) return;
|
||||||
|
|
||||||
|
const target = document.getElementById(href.slice(1));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
|
||||||
|
tocItems.forEach((i) => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateActiveSection = () => {
|
||||||
|
const scrollY = window.scrollY + 100;
|
||||||
|
let currentId = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < headings.length; i++) {
|
||||||
|
const h = headings[i];
|
||||||
|
const rect = h.getBoundingClientRect();
|
||||||
|
const absTop = rect.top + window.pageYOffset;
|
||||||
|
if (absTop <= scrollY) currentId = h.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentId) {
|
||||||
|
tocItems.forEach((i) => i.classList.remove('active'));
|
||||||
|
/** @type {HTMLAnchorElement|null} */
|
||||||
|
const active = qs(`.toc-item[data-heading="${currentId}"]`, sidebar);
|
||||||
|
if (active) active.classList.add('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ticking = false;
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateActiveSection();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateActiveSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceCodeCopy() {
|
||||||
|
/** @type {HTMLPreElement[]} */
|
||||||
|
const pres = qsa('.markdown-content pre');
|
||||||
|
|
||||||
|
pres.forEach((pre) => {
|
||||||
|
if (pre.dataset.copyEnhanced === 'true') return;
|
||||||
|
pre.dataset.copyEnhanced = 'true';
|
||||||
|
pre.style.position ||= 'relative';
|
||||||
|
|
||||||
|
let btn =
|
||||||
|
pre.querySelector('.copy-btn') ||
|
||||||
|
pre.querySelector('.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]');
|
||||||
|
|
||||||
|
if (btn && !btn.classList.contains('copy-btn')) {
|
||||||
|
btn.classList.add('copy-btn');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btn) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'copy-btn';
|
||||||
|
btn.setAttribute('aria-label', 'Code kopieren');
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
<span>Copy</span>
|
||||||
|
`;
|
||||||
|
pre.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleOldButtons = pre.querySelectorAll(
|
||||||
|
'.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]'
|
||||||
|
);
|
||||||
|
possibleOldButtons.forEach((b) => {
|
||||||
|
if (b !== btn) b.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pre.querySelector('.copied-pill')) {
|
||||||
|
const pill = document.createElement('div');
|
||||||
|
pill.className = 'copied-pill';
|
||||||
|
pill.textContent = '✓ Kopiert';
|
||||||
|
pre.appendChild(pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pre.querySelector('.sr-live')) {
|
||||||
|
const live = document.createElement('div');
|
||||||
|
live.className = 'sr-live';
|
||||||
|
live.setAttribute('aria-live', 'polite');
|
||||||
|
Object.assign(live.style, {
|
||||||
|
position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px',
|
||||||
|
overflow: 'hidden', clip: 'rect(0,0,0,0)', border: '0'
|
||||||
|
});
|
||||||
|
pre.appendChild(live);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const code = pre.querySelector('code');
|
||||||
|
const text = code ? code.innerText : pre.innerText;
|
||||||
|
const live = pre.querySelector('.sr-live');
|
||||||
|
|
||||||
|
const copyText = async (t) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(t);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = t;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.top = '-1000px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = await copyText(text);
|
||||||
|
pre.dataset.copied = ok ? 'true' : 'false';
|
||||||
|
if (live) live.textContent = ok ? 'Code in die Zwischenablage kopiert' : 'Kopieren fehlgeschlagen';
|
||||||
|
|
||||||
|
window.setTimeout(() => { pre.dataset.copied = 'false'; }, 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.generateTOCContent = generateTOCContent;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
calculateReadingTime();
|
||||||
|
generateSidebarTOC();
|
||||||
|
enhanceCodeCopy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gated-indicator {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-content-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
|
||||||
|
border: 1px solid var(--color-warning, #ffc107);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--color-warning-text, #856404);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-content-block {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-icon.error {
|
||||||
|
color: var(--color-error, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-description {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-actions .btn {
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-help {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-help small {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.gated-content-block {
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gated-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
//src/pages/status.astro
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
|
|||||||
407
src/styles/auditTrail.css
Normal file
407
src/styles/auditTrail.css
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
/* src/styles/auditTrail.css - Reusable Audit Trail Styles */
|
||||||
|
|
||||||
|
.audit-trail-container {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-header.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: -0.25rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-header.clickable:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-icon-gradient {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-primary) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-icon h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-time {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
transition: transform var(--transition-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-details {
|
||||||
|
display: block;
|
||||||
|
transition: all var(--transition-medium);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-trail-details.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-summary {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.success {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-header {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-header.success {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-header.warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-list li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-process-flow {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-group:not(.last-phase)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 13px;
|
||||||
|
bottom: -8px;
|
||||||
|
width: 2px;
|
||||||
|
height: 16px;
|
||||||
|
background: linear-gradient(to bottom, var(--color-border) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.phase-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-divider {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-bar {
|
||||||
|
width: 48px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-entries {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-entry {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-entry:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-action {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-value {
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-time {
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-toggle {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-toggle-btn:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-details.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-entry {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-phase {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-time {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-row {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.audit-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technical-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/styles/autocomplete.css
Normal file
121
src/styles/autocomplete.css
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
AUTOCOMPLETE COMPONENT STYLES
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-option:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-option:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-item {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-no-results {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-selected {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-tag {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
animation: fadeInScale 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-remove:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for tag appearance */
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure autocomplete container has relative positioning */
|
||||||
|
.autocomplete-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for autocomplete dropdown */
|
||||||
|
.autocomplete-dropdown::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
794
src/styles/knowledgebase.css
Normal file
794
src/styles/knowledgebase.css
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
0) FOUNDATIONS
|
||||||
|
- Typography, rhythm, utility tokens, base elements
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Optional: gentle defaults if not already defined upstream */
|
||||||
|
--radius-xs: 0.25rem;
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
--shadow-md: 0 6px 18px rgba(0,0,0,0.10);
|
||||||
|
--shadow-lg: 0 12px 30px rgba(0,0,0,0.16);
|
||||||
|
|
||||||
|
--ease-quick: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base text container for articles */
|
||||||
|
:where(.markdown-content) {
|
||||||
|
max-width: 70ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) * {
|
||||||
|
scroll-margin-top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
1) HEADINGS & TEXT
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:where(.markdown-content) h1,
|
||||||
|
:where(.markdown-content) h2,
|
||||||
|
:where(.markdown-content) h3,
|
||||||
|
:where(.markdown-content) h4,
|
||||||
|
:where(.markdown-content) h5,
|
||||||
|
:where(.markdown-content) h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 3rem 0 1.5rem 0;
|
||||||
|
border-bottom: 3px solid var(--color-primary);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 2.5rem 0 1rem 0;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 2rem 0 0.75rem 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) h4 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 1.5rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) h5,
|
||||||
|
:where(.markdown-content) h6 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) strong { font-weight: 700; color: var(--color-text); }
|
||||||
|
:where(.markdown-content) em { font-style: italic; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
|
/* Links with refined hover state */
|
||||||
|
:where(.markdown-content) a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
transition: var(--ease-quick);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) a:hover {
|
||||||
|
text-decoration-color: var(--color-primary);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
/* External link indicator */
|
||||||
|
:where(.markdown-content) a[href^="http"]:not([href*="localhost"]):not([href*="127.0.0.1"])::after {
|
||||||
|
content: " ↗";
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
2) LISTS, DEFINITIONS, TABLES, QUOTES
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:where(.markdown-content) ul,
|
||||||
|
:where(.markdown-content) ol {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) li {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
:where(.markdown-content) li > ul,
|
||||||
|
:where(.markdown-content) li > ol {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) dl { margin: 1.5rem 0; }
|
||||||
|
:where(.markdown-content) dt {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) dd {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) th,
|
||||||
|
:where(.markdown-content) td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) th {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) blockquote {
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
3) CODE & INLINE CODE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:where(.markdown-content) pre {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) pre::before {
|
||||||
|
content: attr(data-language);
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: auto;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) pre[data-language]::before {
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) code:not(pre code) {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: 'SF Mono','Monaco','Menlo','Consolas',monospace;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language badges (keep if your renderer sets data-language) */
|
||||||
|
:where(.markdown-content) pre[data-language="bash"]::before { content: "BASH"; color: #4EAA25; }
|
||||||
|
:where(.markdown-content) pre[data-language="javascript"]::before { content: "JS"; color: #F7DF1E; }
|
||||||
|
:where(.markdown-content) pre[data-language="python"]::before { content: "PYTHON"; color: #3776AB; }
|
||||||
|
:where(.markdown-content) pre[data-language="sql"]::before { content: "SQL"; color: #336791; }
|
||||||
|
:where(.markdown-content) pre[data-language="yaml"]::before { content: "YAML"; color: #CB171E; }
|
||||||
|
:where(.markdown-content) pre[data-language="json"]::before { content: "JSON"; color: #000000; }
|
||||||
|
|
||||||
|
/* Keyboard UI hints */
|
||||||
|
:where(.markdown-content) kbd {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
margin: 0 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem; right: 0.5rem;
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
line-height: 1;
|
||||||
|
z-index: 2;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .copy-btn:hover {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .copy-btn:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .copy-btn svg {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] :where(.markdown-content) .copy-btn {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 80%, transparent);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-bg-tertiary) 40%, transparent), var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) pre .copied-pill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem; right: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0; transform: translateY(-6px) scale(0.98);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Success state driven by [data-copied="true"] on <pre> */
|
||||||
|
:where(.markdown-content) pre[data-copied="true"] {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 30%, transparent) inset, var(--shadow-sm);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) pre[data-copied="true"] .copy-btn { opacity: 0; }
|
||||||
|
:where(.markdown-content) pre[data-copied="true"] .copied-pill {
|
||||||
|
opacity: 1; transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth but gentle motion */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:where(.markdown-content) pre,
|
||||||
|
:where(.markdown-content) .copy-btn,
|
||||||
|
:where(.markdown-content) pre .copied-pill {
|
||||||
|
transition: 180ms ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
4) CALLOUTS, STEPS, HIGHLIGHT BOX
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout {
|
||||||
|
border-left: 4px solid;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Callout variants */
|
||||||
|
:where(.markdown-content) .callout-note {
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .callout-note .callout-header { color: var(--color-primary); }
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout-tip {
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .callout-tip .callout-header { color: var(--color-accent); }
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout-warning {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 8%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .callout-warning .callout-header { color: var(--color-warning); }
|
||||||
|
|
||||||
|
:where(.markdown-content) .callout-danger {
|
||||||
|
border-left-color: var(--color-error);
|
||||||
|
background-color: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .callout-danger .callout-header { color: var(--color-error); }
|
||||||
|
|
||||||
|
/* Highlight feature box */
|
||||||
|
:where(.markdown-content) .highlight-box {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-primary) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .highlight-box h3 { margin: 0; color: white; }
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
:where(.markdown-content) .steps { counter-reset: step-counter; margin: 2rem 0; }
|
||||||
|
:where(.markdown-content) .step {
|
||||||
|
counter-increment: step-counter;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .step::before {
|
||||||
|
content: counter(step-counter);
|
||||||
|
position: absolute;
|
||||||
|
left: -0.75rem;
|
||||||
|
top: 1rem;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
:where(.markdown-content) .step h4 { margin-top: 0; color: var(--color-primary); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
5) ARTICLE LAYOUT (Header, Meta, Sidebar, Footer)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.article-layout {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.article-header {
|
||||||
|
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.article-header-content { padding: 2rem; }
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--ease-quick);
|
||||||
|
}
|
||||||
|
.breadcrumb-link:hover { text-decoration: underline; }
|
||||||
|
.breadcrumb-separator { color: var(--color-text-secondary); }
|
||||||
|
.breadcrumb-current { color: var(--color-text-secondary); font-weight: 500; }
|
||||||
|
|
||||||
|
.article-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-tag {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.article-tag-category {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title-section { margin-bottom: 2rem; }
|
||||||
|
.article-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.article-icon { margin-right: 1rem; font-size: 2rem; }
|
||||||
|
.article-description { font-size: 1.25rem; color: var(--color-text-secondary); margin: 0; line-height: 1.5; }
|
||||||
|
|
||||||
|
.article-metadata-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.metadata-item { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.metadata-label {
|
||||||
|
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.metadata-value { font-size: 0.9375rem; font-weight: 500; color: var(--color-text); }
|
||||||
|
.metadata-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.article-actions { display: flex; gap: 1rem; justify-content: center; }
|
||||||
|
|
||||||
|
/* Content wrapper with sidebar */
|
||||||
|
.article-content-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--main-grid-columns, 280px 1fr);
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.article-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0.1rem;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar TOC */
|
||||||
|
.sidebar-toc {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.toc-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.toc-navigation { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.toc-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: var(--ease-quick);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.toc-item:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.toc-item.active {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indentation by level (JS sets .toc-level-X) */
|
||||||
|
.toc-level-1 { padding-left: 0.75rem; font-weight: 600; }
|
||||||
|
.toc-level-2 { padding-left: 1rem; font-weight: 500; }
|
||||||
|
.toc-level-3 { padding-left: 1.5rem; }
|
||||||
|
.toc-level-4 { padding-left: 2rem; font-size: 0.8125rem; }
|
||||||
|
.toc-level-5 { padding-left: 2.5rem; font-size: 0.8125rem; opacity: 0.85; }
|
||||||
|
.toc-level-6 { padding-left: 3rem; font-size: 0.8125rem; opacity: 0.8; }
|
||||||
|
|
||||||
|
/* Main article column */
|
||||||
|
.article-main {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 95ch;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
padding: 2.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: linear-gradient(135deg, var(--color-bg) 0%, var(--color-bg-secondary) 100%);
|
||||||
|
}
|
||||||
|
.article-footer {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-footer h3 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.footer-actions-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.footer-actions-grid .btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
/* Tool button variants (keep since template uses them) */
|
||||||
|
.btn-concept { background-color: var(--color-concept); color: white; border-color: var(--color-concept); }
|
||||||
|
.btn-concept:hover { opacity: 0.9; }
|
||||||
|
.btn-method { background-color: var(--color-method); color: white; border-color: var(--color-method); }
|
||||||
|
.btn-method:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
.related-tools { margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border); }
|
||||||
|
.related-tools h3 { margin: 0 0 1rem 0; color: var(--color-text); }
|
||||||
|
.related-tools-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;
|
||||||
|
}
|
||||||
|
.related-tool-card {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; padding: 1rem;
|
||||||
|
background-color: var(--color-bg-secondary); border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem; color: var(--color-text); text-decoration: none;
|
||||||
|
transition: var(--ease-quick);
|
||||||
|
}
|
||||||
|
.related-tool-card:hover {
|
||||||
|
background-color: var(--color-bg-tertiary); border-color: var(--color-primary);
|
||||||
|
transform: translateY(-1px); box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.tool-icon { font-size: 1.25rem; flex-shrink: 0; }
|
||||||
|
.tool-name { font-weight: 500; font-size: 0.9375rem; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
6) RESPONSIVE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.article-content-wrapper { grid-template-columns: 240px 1fr; gap: 2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.article-content-wrapper { grid-template-columns: 1fr; gap: 0; }
|
||||||
|
.article-sidebar { position: static; max-height: none; margin-bottom: 2rem; }
|
||||||
|
.sidebar-toc { max-width: 500px; margin: 0 auto; }
|
||||||
|
.article-main { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.article-layout { padding: 0 0.5rem; }
|
||||||
|
.article-header-content { padding: 1.5rem; }
|
||||||
|
.article-title { font-size: 2rem; }
|
||||||
|
.article-description { font-size: 1.125rem; }
|
||||||
|
.article-metadata-grid { grid-template-columns: 1fr; gap: 1rem; padding: 1rem; }
|
||||||
|
.article-actions { flex-direction: column; }
|
||||||
|
.footer-actions-grid { flex-direction: column; }
|
||||||
|
.footer-actions-grid .btn { min-width: auto; }
|
||||||
|
.article-content { padding: 1.5rem; }
|
||||||
|
.article-footer { padding: 1.5rem; }
|
||||||
|
.footer-actions-grid { flex-direction: column; }
|
||||||
|
.footer-actions-grid .btn { min-width: auto; }
|
||||||
|
|
||||||
|
:where(.markdown-content) {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
:where(.markdown-content) pre { padding: 1rem; margin: 1.5rem 0; font-size: 0.8125rem; }
|
||||||
|
:where(.markdown-content) .callout { padding: 1rem; margin: 1.5rem 0; }
|
||||||
|
:where(.markdown-content) .step { padding: 1rem; margin: 1rem 0; }
|
||||||
|
:where(.markdown-content) .step::before { left: -0.5rem; top: 0.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.article-title { font-size: 1.75rem; }
|
||||||
|
.article-icon { font-size: 1.5rem; margin-right: 0.75rem; }
|
||||||
|
.breadcrumb { font-size: 0.8125rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
7) PRINT
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Hide interactive chrome not used in print */
|
||||||
|
.article-sidebar { display: none !important; }
|
||||||
|
.article-actions,
|
||||||
|
.article-footer .footer-actions-grid { display: none !important; }
|
||||||
|
|
||||||
|
/* Expand content */
|
||||||
|
.article-main { max-width: 100% !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
VIDEO EMBEDDING - ULTRA SIMPLE: Just full width, natural aspect ratios
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Video Container - just a styled wrapper */
|
||||||
|
:where(.markdown-content) .video-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-tertiary, #000);
|
||||||
|
box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Element - full width, natural aspect ratio */
|
||||||
|
:where(.markdown-content) .video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
background-color: #000;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YouTube iframe - full width, preserve embedded dimensions ratio */
|
||||||
|
:where(.markdown-content) .video-container iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9; /* Only for iframes since they don't have intrinsic ratio */
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states for accessibility */
|
||||||
|
:where(.markdown-content) .video-container video:focus,
|
||||||
|
:where(.markdown-content) .video-container iframe:focus {
|
||||||
|
outline: 3px solid var(--color-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Metadata */
|
||||||
|
:where(.markdown-content) .video-metadata {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-top: none;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: 0 0 var(--radius-lg, 0.75rem) var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .video-metadata .video-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:where(.markdown-content) .video-container {
|
||||||
|
margin: 1.5rem -0.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .video-metadata {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme */
|
||||||
|
[data-theme="dark"] :where(.markdown-content) .video-container {
|
||||||
|
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] :where(.markdown-content) .video-metadata {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Media */
|
||||||
|
@media print {
|
||||||
|
:where(.markdown-content) .video-container {
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .video-container video,
|
||||||
|
:where(.markdown-content) .video-container iframe {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.markdown-content) .video-container::before {
|
||||||
|
content: "📹 Video: " attr(data-video-title, "Embedded Video");
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/styles/palette.css
Normal file
72
src/styles/palette.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* PALETTE OPTION 1: BLUEPRINT & AMBER */
|
||||||
|
:root {
|
||||||
|
/* Light Theme */
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-secondary: #f1f5f9; /* Slate 100 */
|
||||||
|
--color-bg-tertiary: #e2e8f0; /* Slate 200 */
|
||||||
|
--color-text: #0f172a; /* Slate 900 */
|
||||||
|
--color-text-secondary: #475569; /* Slate 600 */
|
||||||
|
--color-border: #cbd5e1; /* Slate 300 */
|
||||||
|
|
||||||
|
--color-primary: #334155; /* Slate 700 - A strong, serious primary */
|
||||||
|
--color-primary-hover: #1e293b; /* Slate 800 */
|
||||||
|
|
||||||
|
--color-accent: #b45309; /* A sharp, focused amber for highlights */
|
||||||
|
--color-accent-hover: #92400e;
|
||||||
|
|
||||||
|
--color-warning: #d97706;
|
||||||
|
--color-error: #be123c; /* A deeper, more serious red */
|
||||||
|
|
||||||
|
/* Card/Tag Category Colors */
|
||||||
|
--color-hosted: #4f46e5; /* Indigo */
|
||||||
|
--color-hosted-bg: #eef2ff;
|
||||||
|
--color-oss: #0d9488; /* Teal */
|
||||||
|
--color-oss-bg: #f0fdfa;
|
||||||
|
--color-method: #0891b2; /* Cyan */
|
||||||
|
--color-method-bg: #ecfeff;
|
||||||
|
--color-concept: #c2410c; /* Orange */
|
||||||
|
--color-concept-bg: #fff7ed;
|
||||||
|
|
||||||
|
/* Shadows (Crisper) */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 6%);
|
||||||
|
--shadow-md: 0 3px 5px -1px rgb(0 0 0 / 8%);
|
||||||
|
--shadow-lg: 0 8px 12px -3px rgb(0 0 0 / 10%);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: all 0.2s ease;
|
||||||
|
--transition-medium: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Dark Theme */
|
||||||
|
--color-bg: #0f172a; /* Slate 900 */
|
||||||
|
--color-bg-secondary: #1e293b; /* Slate 800 */
|
||||||
|
--color-bg-tertiary: #334155; /* Slate 700 */
|
||||||
|
--color-text: #f1f5f9; /* Slate 100 */
|
||||||
|
--color-text-secondary: #94a3b8; /* Slate 400 */
|
||||||
|
--color-border: #475569; /* Slate 600 */
|
||||||
|
|
||||||
|
--color-primary: #64748b; /* Slate 500 */
|
||||||
|
--color-primary-hover: #94a3b8; /* Slate 400 */
|
||||||
|
|
||||||
|
--color-accent: #f59e0b; /* A brighter amber for dark mode contrast */
|
||||||
|
--color-accent-hover: #fbbf24;
|
||||||
|
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #f43f5e;
|
||||||
|
|
||||||
|
/* Card/Tag Category Colors */
|
||||||
|
--color-hosted: #818cf8; /* Indigo */
|
||||||
|
--color-hosted-bg: #3730a3;
|
||||||
|
--color-oss: #2dd4bf; /* Teal */
|
||||||
|
--color-oss-bg: #115e59;
|
||||||
|
--color-method: #22d3ee; /* Cyan */
|
||||||
|
--color-method-bg: #164e63;
|
||||||
|
--color-concept: #fb923c; /* Orange */
|
||||||
|
--color-concept-bg: #7c2d12;
|
||||||
|
|
||||||
|
/* Shadows (Subtler for dark mode) */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 20%);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 30%);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 40%);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
137
src/utils/aiService.ts
Normal file
137
src/utils/aiService.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// src/utils/aiService.ts
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export interface AIServiceConfig {
|
||||||
|
endpoint: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AICallOptions {
|
||||||
|
temperature?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIResponse {
|
||||||
|
content: string;
|
||||||
|
usage?: {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AIService {
|
||||||
|
private config: AIServiceConfig;
|
||||||
|
private defaultOptions: AICallOptions;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = {
|
||||||
|
endpoint: this.getRequiredEnv('AI_ANALYZER_ENDPOINT'),
|
||||||
|
apiKey: this.getRequiredEnv('AI_ANALYZER_API_KEY'),
|
||||||
|
model: this.getRequiredEnv('AI_ANALYZER_MODEL')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.defaultOptions = {
|
||||||
|
temperature: 0.3,
|
||||||
|
timeout: 60000
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[AI-SERVICE] Initialized with model:', this.config.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequiredEnv(key: string): string {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callAI(prompt: string, options: AICallOptions = {}): Promise<AIResponse> {
|
||||||
|
const mergedOptions = { ...this.defaultOptions, ...options };
|
||||||
|
|
||||||
|
console.log('[AI-SERVICE] Making API call:', {
|
||||||
|
promptLength: prompt.length,
|
||||||
|
temperature: mergedOptions.temperature
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.apiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model: this.config.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: mergedOptions.temperature
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), mergedOptions.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.endpoint}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[AI-SERVICE] API Error:', response.status, errorText);
|
||||||
|
throw new Error(`AI API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
console.error('[AI-SERVICE] No response content from AI model');
|
||||||
|
throw new Error('No response from AI model');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AI-SERVICE] API call successful:', {
|
||||||
|
responseLength: content.length,
|
||||||
|
usage: data.usage
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: content.trim(),
|
||||||
|
usage: data.usage
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.error('[AI-SERVICE] Request timeout');
|
||||||
|
throw new Error('AI request timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[AI-SERVICE] API call failed:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callMicroTaskAI(prompt: string): Promise<AIResponse> {
|
||||||
|
return this.callAI(prompt, {
|
||||||
|
temperature: 0.3,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): AIServiceConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiService = new AIService();
|
||||||
@@ -83,26 +83,21 @@ export const apiServerError = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiSpecial = {
|
export const apiSpecial = {
|
||||||
// JSON parsing error
|
|
||||||
invalidJSON: (): Response =>
|
invalidJSON: (): Response =>
|
||||||
apiError.badRequest('Invalid JSON in request body'),
|
apiError.badRequest('Invalid JSON in request body'),
|
||||||
|
|
||||||
// Missing required fields
|
|
||||||
missingRequired: (fields: string[]): Response =>
|
missingRequired: (fields: string[]): Response =>
|
||||||
apiError.badRequest(`Missing required fields: ${fields.join(', ')}`),
|
apiError.badRequest(`Missing required fields: ${fields.join(', ')}`),
|
||||||
|
|
||||||
// Empty request body
|
|
||||||
emptyBody: (): Response =>
|
emptyBody: (): Response =>
|
||||||
apiError.badRequest('Request body cannot be empty'),
|
apiError.badRequest('Request body cannot be empty'),
|
||||||
|
|
||||||
// File upload responses
|
|
||||||
uploadSuccess: (data: { url: string; filename: string; size: number; storage: string }): Response =>
|
uploadSuccess: (data: { url: string; filename: string; size: number; storage: string }): Response =>
|
||||||
apiResponse.created(data),
|
apiResponse.created(data),
|
||||||
|
|
||||||
uploadFailed: (error: string): Response =>
|
uploadFailed: (error: string): Response =>
|
||||||
apiServerError.internal(`Upload failed: ${error}`),
|
apiServerError.internal(`Upload failed: ${error}`),
|
||||||
|
|
||||||
// Contribution responses
|
|
||||||
contributionSuccess: (data: { prUrl?: string; branchName?: string; message: string }): Response =>
|
contributionSuccess: (data: { prUrl?: string; branchName?: string; message: string }): Response =>
|
||||||
apiResponse.created({ success: true, ...data }),
|
apiResponse.created({ success: true, ...data }),
|
||||||
|
|
||||||
@@ -111,11 +106,9 @@ export const apiSpecial = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiWithHeaders = {
|
export const apiWithHeaders = {
|
||||||
// Success with custom headers (e.g., Set-Cookie)
|
|
||||||
successWithHeaders: (data: any, headers: Record<string, string>): Response =>
|
successWithHeaders: (data: any, headers: Record<string, string>): Response =>
|
||||||
createAPIResponse(data, 200, headers),
|
createAPIResponse(data, 200, headers),
|
||||||
|
|
||||||
// Redirect response
|
|
||||||
redirect: (location: string, temporary: boolean = true): Response =>
|
redirect: (location: string, temporary: boolean = true): Response =>
|
||||||
new Response(null, {
|
new Response(null, {
|
||||||
status: temporary ? 302 : 301,
|
status: temporary ? 302 : 301,
|
||||||
|
|||||||
760
src/utils/auditService.ts
Normal file
760
src/utils/auditService.ts
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
// src/utils/auditService.ts
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
function env(key: string, fallback: string | undefined = undefined): string | undefined {
|
||||||
|
if (typeof process !== 'undefined' && process.env?.[key] !== undefined) {
|
||||||
|
return process.env[key];
|
||||||
|
}
|
||||||
|
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.[key] !== undefined) {
|
||||||
|
return (import.meta as any).env[key];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
timestamp: number;
|
||||||
|
phase: string;
|
||||||
|
action: string;
|
||||||
|
input: any;
|
||||||
|
output: any;
|
||||||
|
confidence: number;
|
||||||
|
processingTimeMs: number;
|
||||||
|
metadata: {
|
||||||
|
aiModel?: string;
|
||||||
|
aiParameters?: any;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
toolsDataHash?: string;
|
||||||
|
embeddingsUsed?: boolean;
|
||||||
|
microTaskType?: string;
|
||||||
|
confidenceFactors?: string[];
|
||||||
|
reasoning?: string;
|
||||||
|
aiPrompt?: string;
|
||||||
|
aiResponse?: string;
|
||||||
|
toolSelectionCriteria?: string;
|
||||||
|
availableToolsCount?: number;
|
||||||
|
selectedToolsCount?: number;
|
||||||
|
phaseId?: string;
|
||||||
|
toolsAdded?: string[];
|
||||||
|
completionReasoning?: string;
|
||||||
|
similarityScores?: Record<string, number>;
|
||||||
|
contextLength?: number;
|
||||||
|
decisionBasis?: 'ai-analysis' | 'semantic-search' | 'hybrid' | 'rule-based';
|
||||||
|
inputSummary?: string;
|
||||||
|
outputSummary?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
retentionHours: number;
|
||||||
|
maxEntries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuditService {
|
||||||
|
private config: AuditConfig;
|
||||||
|
private activeAuditTrail: AuditEntry[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = this.loadConfig();
|
||||||
|
console.log('[AUDIT-SERVICE] Initialized with meaningful audit logging');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): AuditConfig {
|
||||||
|
const enabled = env('FORENSIC_AUDIT_ENABLED', 'true') === 'true';
|
||||||
|
const retentionHours = parseInt(env('FORENSIC_AUDIT_RETENTION_HOURS', '72') || '72', 10);
|
||||||
|
const maxEntries = parseInt(env('FORENSIC_AUDIT_MAX_ENTRIES', '50') || '50', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
retentionHours,
|
||||||
|
maxEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry(
|
||||||
|
phase: string,
|
||||||
|
action: string,
|
||||||
|
input: any,
|
||||||
|
output: any,
|
||||||
|
confidence: number,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
if (action === 'pipeline-start' || action === 'pipeline-end') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhancedMetadata = {
|
||||||
|
...metadata,
|
||||||
|
inputSummary: this.createSpecificSummary(input, action, 'input'),
|
||||||
|
outputSummary: this.createSpecificSummary(output, action, 'output'),
|
||||||
|
decisionBasis: metadata.decisionBasis || this.inferDecisionBasis(metadata),
|
||||||
|
reasoning: metadata.reasoning || this.generateSpecificReasoning(action, input, output, metadata, confidence)
|
||||||
|
};
|
||||||
|
|
||||||
|
const entry: AuditEntry = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
phase,
|
||||||
|
action,
|
||||||
|
input: input,
|
||||||
|
output: output,
|
||||||
|
confidence: Math.round(confidence),
|
||||||
|
processingTimeMs: Date.now() - startTime,
|
||||||
|
metadata: enhancedMetadata
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activeAuditTrail.push(entry);
|
||||||
|
|
||||||
|
if (this.activeAuditTrail.length > this.config.maxEntries) {
|
||||||
|
this.activeAuditTrail.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AUDIT-SERVICE] ${phase}/${action}: ${confidence}% confidence, ${entry.processingTimeMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addAIDecision(
|
||||||
|
phase: string,
|
||||||
|
aiPrompt: string,
|
||||||
|
aiResponse: string,
|
||||||
|
confidence: number,
|
||||||
|
reasoning: string,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
this.addEntry(
|
||||||
|
phase,
|
||||||
|
'ai-decision',
|
||||||
|
{ prompt: this.createPromptSummary(aiPrompt) },
|
||||||
|
{ response: aiResponse },
|
||||||
|
confidence,
|
||||||
|
startTime,
|
||||||
|
{
|
||||||
|
...metadata,
|
||||||
|
reasoning,
|
||||||
|
aiPrompt: aiPrompt,
|
||||||
|
aiResponse: aiResponse,
|
||||||
|
decisionBasis: 'ai-analysis'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToolSelection(
|
||||||
|
selectedTools: string[],
|
||||||
|
availableTools: string[],
|
||||||
|
confidence: number,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
const calculatedConfidence = this.calculateSelectionConfidence(
|
||||||
|
selectedTools,
|
||||||
|
availableTools,
|
||||||
|
metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
const decisionBasis =
|
||||||
|
metadata.embeddingsUsed || metadata.similarityScores
|
||||||
|
? 'semantic-search'
|
||||||
|
: (metadata.aiPrompt || metadata.microTaskType ? 'ai-analysis' : 'rule-based');
|
||||||
|
|
||||||
|
this.addEntry(
|
||||||
|
'tool-selection',
|
||||||
|
'selection-decision',
|
||||||
|
{
|
||||||
|
availableTools: availableTools.slice(0, 10),
|
||||||
|
totalAvailable: availableTools.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectedTools: selectedTools,
|
||||||
|
selectionRatio: selectedTools.length / availableTools.length
|
||||||
|
},
|
||||||
|
calculatedConfidence,
|
||||||
|
startTime,
|
||||||
|
{
|
||||||
|
...metadata,
|
||||||
|
availableToolsCount: availableTools.length,
|
||||||
|
selectedToolsCount: selectedTools.length,
|
||||||
|
decisionBasis
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPhaseCompletion(
|
||||||
|
phaseId: string,
|
||||||
|
addedTools: string[],
|
||||||
|
reasoning: string,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
if (!addedTools || addedTools.length === 0) {
|
||||||
|
console.log(`[AUDIT-SERVICE] Skipping phase completion for ${phaseId} - no tools added`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculatedConfidence = this.calculatePhaseCompletionConfidence(addedTools, reasoning, metadata);
|
||||||
|
|
||||||
|
this.addEntry(
|
||||||
|
'phase-completion',
|
||||||
|
'phase-enhancement',
|
||||||
|
{
|
||||||
|
phaseId: phaseId,
|
||||||
|
phaseName: this.getPhaseDisplayName(phaseId),
|
||||||
|
searchStrategy: 'semantic-search-with-ai-reasoning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
addedTools: addedTools,
|
||||||
|
toolsAddedCount: addedTools.length
|
||||||
|
},
|
||||||
|
calculatedConfidence,
|
||||||
|
startTime,
|
||||||
|
{
|
||||||
|
...metadata,
|
||||||
|
reasoning: reasoning,
|
||||||
|
decisionBasis: 'hybrid'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEmbeddingsSearch(
|
||||||
|
query: string,
|
||||||
|
similarResults: any[],
|
||||||
|
threshold: number,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
const calculatedConfidence = this.calculateEmbeddingsConfidence(similarResults, threshold);
|
||||||
|
|
||||||
|
this.addEntry(
|
||||||
|
'embeddings',
|
||||||
|
'similarity-search',
|
||||||
|
{
|
||||||
|
query: query,
|
||||||
|
threshold: threshold
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resultsCount: similarResults.length,
|
||||||
|
topMatches: similarResults.slice(0, 5).map(r => `${r.name} (${Math.round(r.similarity * 100)}%)`)
|
||||||
|
},
|
||||||
|
calculatedConfidence,
|
||||||
|
startTime,
|
||||||
|
{
|
||||||
|
...metadata,
|
||||||
|
embeddingsUsed: true,
|
||||||
|
searchThreshold: threshold,
|
||||||
|
totalMatches: similarResults.length,
|
||||||
|
decisionBasis: 'semantic-search'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addConfidenceCalculation(
|
||||||
|
toolName: string,
|
||||||
|
confidence: any,
|
||||||
|
startTime: number,
|
||||||
|
metadata: Record<string, any> = {}
|
||||||
|
): void {
|
||||||
|
this.addEntry(
|
||||||
|
'confidence-scoring',
|
||||||
|
'tool-confidence',
|
||||||
|
{
|
||||||
|
toolName: toolName,
|
||||||
|
semanticSimilarity: confidence.semanticRelevance,
|
||||||
|
taskRelevance: confidence.taskSuitability
|
||||||
|
},
|
||||||
|
{
|
||||||
|
overallConfidence: confidence.overall,
|
||||||
|
strengthIndicators: confidence.strengthIndicators?.slice(0, 2) || [],
|
||||||
|
uncertaintyFactors: confidence.uncertaintyFactors?.slice(0, 2) || []
|
||||||
|
},
|
||||||
|
confidence.overall,
|
||||||
|
startTime,
|
||||||
|
{
|
||||||
|
...metadata,
|
||||||
|
confidenceCalculation: true,
|
||||||
|
decisionBasis: 'ai-analysis'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSelectionConfidence(
|
||||||
|
selectedTools: string[],
|
||||||
|
availableTools: string[],
|
||||||
|
metadata: Record<string, any>
|
||||||
|
): number {
|
||||||
|
let confidence = 50;
|
||||||
|
|
||||||
|
const selectionRatio = selectedTools.length / availableTools.length;
|
||||||
|
|
||||||
|
if (selectionRatio >= 0.05 && selectionRatio <= 0.20) {
|
||||||
|
confidence += 25;
|
||||||
|
} else if (selectionRatio < 0.05) {
|
||||||
|
confidence += 15;
|
||||||
|
} else if (selectionRatio > 0.30) {
|
||||||
|
confidence -= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTools.length >= 5 && selectedTools.length <= 25) {
|
||||||
|
confidence += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(95, Math.max(40, confidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculatePhaseCompletionConfidence(
|
||||||
|
addedTools: string[],
|
||||||
|
reasoning: string,
|
||||||
|
metadata: Record<string, any>
|
||||||
|
): number {
|
||||||
|
let confidence = 60;
|
||||||
|
|
||||||
|
if (addedTools.length > 0) {
|
||||||
|
confidence += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoning && reasoning.length > 50) {
|
||||||
|
confidence += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.aiReasoningUsed) {
|
||||||
|
confidence += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedTools.length <= 2) {
|
||||||
|
confidence += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(90, Math.max(50, confidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateEmbeddingsConfidence(similarResults: any[], threshold: number): number {
|
||||||
|
let confidence = 50;
|
||||||
|
|
||||||
|
if (similarResults.length > 0) {
|
||||||
|
confidence += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarResults.length >= 5 && similarResults.length <= 30) {
|
||||||
|
confidence += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgSimilarity = similarResults.length > 0 ?
|
||||||
|
similarResults.reduce((sum, r) => sum + r.similarity, 0) / similarResults.length : 0;
|
||||||
|
|
||||||
|
if (avgSimilarity > 0.7) {
|
||||||
|
confidence += 15;
|
||||||
|
} else if (avgSimilarity > 0.5) {
|
||||||
|
confidence += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threshold >= 0.3 && threshold <= 0.5) {
|
||||||
|
confidence += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(95, Math.max(30, confidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSpecificSummary(data: any, action: string, type: 'input' | 'output'): string {
|
||||||
|
if (!data) return 'Leer';
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'selection-decision':
|
||||||
|
if (type === 'input') {
|
||||||
|
if (data.availableTools && Array.isArray(data.availableTools)) {
|
||||||
|
const preview = data.availableTools.slice(0, 3).join(', ');
|
||||||
|
return `${data.totalAvailable || data.availableTools.length} Tools: ${preview}${data.availableTools.length > 3 ? '...' : ''}`;
|
||||||
|
} else if (data.totalAvailable) {
|
||||||
|
return `${data.totalAvailable} Tools verfügbar, Methode: ${data.selectionMethod}`;
|
||||||
|
}
|
||||||
|
return `${data.candidateCount || 0} Kandidaten für Auswahl`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(data.selectedTools)) {
|
||||||
|
return `${data.selectedTools.length} ausgewählt: ${data.selectedTools.slice(0, 3).join(', ')}${data.selectedTools.length > 3 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
return `Auswahl: ${data.selectionRatio ? Math.round(data.selectionRatio * 100) + '%' : 'unbekannt'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'phase-tool-selection':
|
||||||
|
if (type === 'input') {
|
||||||
|
if (Array.isArray(data.availableTools)) {
|
||||||
|
const toolPreview = data.availableTools.slice(0, 3).join(', ');
|
||||||
|
return `${data.availableTools.length} Tools für ${data.phaseName || data.phaseId}: ${toolPreview}${data.availableTools.length > 3 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
return `Phase: ${data.phaseName || data.phaseId} (${data.toolCount || 0} Tools)`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(data.selectedTools)) {
|
||||||
|
return `${data.selectedTools.length} ausgewählt: ${data.selectedTools.join(', ')}`;
|
||||||
|
}
|
||||||
|
return `${data.selectionCount || 0} Tools, Ø ${data.avgTaskRelevance || 0}% Relevanz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'similarity-search':
|
||||||
|
if (type === 'input') {
|
||||||
|
return `Suche: "${data.query}" (Min. ${data.threshold} Ähnlichkeit)`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(data.topMatches)) {
|
||||||
|
return `${data.resultsCount} Treffer: ${data.topMatches.slice(0, 2).join(', ')}${data.topMatches.length > 2 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
return `${data.resultsCount || 0} semantische Treffer gefunden`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'phase-enhancement':
|
||||||
|
if (type === 'input') {
|
||||||
|
return `Vervollständige Phase: ${data.phaseName || data.phaseId}`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(data.addedTools) && data.addedTools.length > 0) {
|
||||||
|
return `${data.addedTools.length} hinzugefügt: ${data.addedTools.join(', ')}`;
|
||||||
|
}
|
||||||
|
return `${data.toolsAddedCount || 0} Tools für Phase hinzugefügt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ai-decision':
|
||||||
|
if (type === 'input') {
|
||||||
|
if (data.prompt) {
|
||||||
|
const promptPreview = data.prompt.slice(0, 80).replace(/\n/g, ' ');
|
||||||
|
return `KI-Prompt: ${promptPreview}${data.prompt.length > 80 ? ' [Vorschau]' : ''}`;
|
||||||
|
}
|
||||||
|
return 'KI-Analyse angefordert';
|
||||||
|
} else {
|
||||||
|
if (data.response) {
|
||||||
|
const responsePreview = data.response.slice(0, 80).replace(/\n/g, ' ');
|
||||||
|
return `KI-Antwort: ${responsePreview}${data.response.length > 80 ? ' [Vorschau]' : ''}`;
|
||||||
|
}
|
||||||
|
return 'KI-Analyse abgeschlossen';
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-confidence':
|
||||||
|
if (type === 'input') {
|
||||||
|
return `Tool: ${data.toolName} (Sem: ${data.semanticSimilarity}%, Task: ${data.taskRelevance}%)`;
|
||||||
|
} else {
|
||||||
|
const strengthCount = data.strengthIndicators?.length || 0;
|
||||||
|
const uncertaintyCount = data.uncertaintyFactors?.length || 0;
|
||||||
|
return `${data.overallConfidence}% Vertrauen (${strengthCount} Stärken, ${uncertaintyCount} Unsicherheiten)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-added-to-phase':
|
||||||
|
if (type === 'output') {
|
||||||
|
const justificationPreview = data.justification ?
|
||||||
|
data.justification.slice(0, 60).replace(/\n/g, ' ') + (data.justification.length > 60 ? ' [Vorschau]' : '') : 'Hinzugefügt';
|
||||||
|
return `Begründung: ${justificationPreview}`;
|
||||||
|
} else {
|
||||||
|
const justificationPreview = data.justification ?
|
||||||
|
data.justification.slice(0, 60).replace(/\n/g, ' ') + '...' : 'Hinzugefügt';
|
||||||
|
return `Begründung: ${justificationPreview}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'concept-selection':
|
||||||
|
if (type === 'input') {
|
||||||
|
const conceptCount = Array.isArray(data.availableConcepts) ? data.availableConcepts.length : 0;
|
||||||
|
const toolContext = Array.isArray(data.selectedToolsContext) ? data.selectedToolsContext.length : 0;
|
||||||
|
return `${conceptCount} Konzepte verfügbar, ${toolContext} Tools als Kontext`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(data.selectedConcepts)) {
|
||||||
|
return `${data.selectedConcepts.length} ausgewählt: ${data.selectedConcepts.slice(0, 2).join(', ')}${data.selectedConcepts.length > 2 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
return `Konzeptauswahl abgeschlossen`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data.length > 100 ? data.slice(0, 100) + '...' : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length === 0) return 'Leeres Array';
|
||||||
|
if (data.length <= 3) return data.join(', ');
|
||||||
|
return `${data.slice(0, 3).join(', ')} + ${data.length - 3} weitere`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
if (keys.length === 0) return 'Leeres Objekt';
|
||||||
|
|
||||||
|
if (keys.length <= 2) {
|
||||||
|
const pairs = keys.map(key => {
|
||||||
|
const value = data[key];
|
||||||
|
if (typeof value === 'string' && value.length > 30) {
|
||||||
|
return `${key}: ${value.slice(0, 30)}...`;
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
return `${key}: [${value.length} Items]`;
|
||||||
|
} else {
|
||||||
|
return `${key}: ${value}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pairs.join(', ');
|
||||||
|
} else {
|
||||||
|
const sampleKeys = keys.slice(0, 3);
|
||||||
|
const sampleValues = sampleKeys.map(key => {
|
||||||
|
const value = data[key];
|
||||||
|
if (typeof value === 'string' && value.length > 20) {
|
||||||
|
return `${key}: ${value.slice(0, 20)}...`;
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
return `${key}: [${value.length}]`;
|
||||||
|
} else {
|
||||||
|
return `${key}: ${value}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${sampleValues.join(', ')}${keys.length > 3 ? ` + ${keys.length - 3} weitere` : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPromptSummary(prompt: string): string {
|
||||||
|
if (!prompt || prompt.length <= 200) return prompt;
|
||||||
|
return prompt.slice(0, 200) + ' [Eingabe-Vorschau]';
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSpecificReasoning(
|
||||||
|
action: string,
|
||||||
|
input: any,
|
||||||
|
output: any,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
confidence: number
|
||||||
|
): string {
|
||||||
|
if (metadata.reasoning && metadata.reasoning.length > 20 && !metadata.reasoning.includes('completed with')) {
|
||||||
|
return metadata.reasoning;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'selection-decision':
|
||||||
|
const selectionRatio = metadata.selectedToolsCount / metadata.availableToolsCount;
|
||||||
|
const method = metadata.selectionMethod === 'embeddings_candidates' ? 'Semantische Analyse' : 'KI-Analyse';
|
||||||
|
return `${method} wählte ${metadata.selectedToolsCount} von ${metadata.availableToolsCount} Tools (${Math.round(selectionRatio * 100)}%) - ausgewogene Auswahl für forensische Aufgabenstellung`;
|
||||||
|
|
||||||
|
case 'similarity-search': {
|
||||||
|
const totalMatches =
|
||||||
|
typeof metadata.totalMatches === 'number' ? metadata.totalMatches : 0;
|
||||||
|
|
||||||
|
const scoresObj = (metadata.similarityScores ?? {}) as Record<string, number>;
|
||||||
|
const scores = Object.values(scoresObj) as number[];
|
||||||
|
|
||||||
|
const denom = totalMatches > 0 ? totalMatches : scores.length;
|
||||||
|
|
||||||
|
const sum = scores.reduce((acc, v) => acc + (typeof v === 'number' ? v : 0), 0);
|
||||||
|
const avgSim = denom > 0 ? sum / denom : 0;
|
||||||
|
|
||||||
|
return `Semantische Suche fand ${totalMatches} relevante Items mit durchschnittlicher Ähnlichkeit von ${Math.round(avgSim * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ai-decision':
|
||||||
|
const taskType = metadata.microTaskType;
|
||||||
|
if (taskType) {
|
||||||
|
const typeNames = {
|
||||||
|
'scenario-analysis': 'Szenario-Analyse',
|
||||||
|
'investigation-approach': 'Untersuchungsansatz',
|
||||||
|
'critical-considerations': 'Kritische Überlegungen',
|
||||||
|
'tool-evaluation': 'Tool-Bewertung',
|
||||||
|
'background-knowledge': 'Hintergrundwissen-Auswahl',
|
||||||
|
'final-recommendations': 'Abschließende Empfehlungen'
|
||||||
|
};
|
||||||
|
return `KI analysierte ${typeNames[taskType] || taskType} mit ${confidence}% Vertrauen - fundierte Empfehlung`;
|
||||||
|
}
|
||||||
|
return `KI-Entscheidung mit ${confidence}% Vertrauen basierend auf agentischer Expertenanalyse`;
|
||||||
|
|
||||||
|
case 'phase-enhancement':
|
||||||
|
const phaseData = input?.phaseName || input?.phaseId;
|
||||||
|
const toolCount = output?.toolsAddedCount || 0;
|
||||||
|
return `${phaseData}-Phase durch ${toolCount} zusätzliche Tools vervollständigt - ursprüngliche Auswahl war zu spezifisch und übersah wichtige Methoden`;
|
||||||
|
|
||||||
|
case 'tool-confidence':
|
||||||
|
return `Vertrauenswertung für ${input?.toolName}: ${confidence}% basierend auf semantischer Relevanz (${input?.semanticSimilarity}%) und Aufgabeneignung (${input?.taskRelevance}%)`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `${action} mit ${confidence}% Vertrauen abgeschlossen`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPhaseDisplayName(phaseId: string): string {
|
||||||
|
const phaseNames: Record<string, string> = {
|
||||||
|
'preparation': 'Vorbereitung',
|
||||||
|
'acquisition': 'Datensammlung',
|
||||||
|
'examination': 'Untersuchung',
|
||||||
|
'analysis': 'Analyse',
|
||||||
|
'reporting': 'Dokumentation',
|
||||||
|
'presentation': 'Präsentation'
|
||||||
|
};
|
||||||
|
return phaseNames[phaseId] || phaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferDecisionBasis(metadata: Record<string, any>): string {
|
||||||
|
if (metadata.embeddingsUsed) return 'semantic-search';
|
||||||
|
if (metadata.aiPrompt || metadata.microTaskType) return 'ai-analysis';
|
||||||
|
if (metadata.semanticQuery && metadata.aiReasoningUsed) return 'hybrid';
|
||||||
|
return 'rule-based';
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentAuditTrail(): AuditEntry[] {
|
||||||
|
return [...this.activeAuditTrail];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuditTrail(): void {
|
||||||
|
if (this.activeAuditTrail.length > 0) {
|
||||||
|
console.log(`[AUDIT-SERVICE] Cleared ${this.activeAuditTrail.length} audit entries`);
|
||||||
|
this.activeAuditTrail = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeAuditTrail(): AuditEntry[] {
|
||||||
|
const finalTrail = [...this.activeAuditTrail];
|
||||||
|
console.log(`[AUDIT-SERVICE] Finalized audit trail with ${finalTrail.length} meaningful entries`);
|
||||||
|
this.clearAuditTrail();
|
||||||
|
return finalTrail;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): AuditConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateAIResponseConfidence(
|
||||||
|
response: string,
|
||||||
|
expectedLength: { min: number; max: number },
|
||||||
|
taskType: string
|
||||||
|
): number {
|
||||||
|
let confidence = 50;
|
||||||
|
|
||||||
|
if (response.length >= expectedLength.min) {
|
||||||
|
confidence += 20;
|
||||||
|
if (response.length <= expectedLength.max) {
|
||||||
|
confidence += 10;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
confidence -= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.includes('...') || response.endsWith('...')) {
|
||||||
|
confidence -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (taskType) {
|
||||||
|
case 'scenario-analysis':
|
||||||
|
case 'investigation-approach':
|
||||||
|
case 'critical-considerations':
|
||||||
|
const forensicTerms = ['forensisch', 'beweis', 'evidence', 'analyse', 'untersuchung', 'methodik'];
|
||||||
|
const termsFound = forensicTerms.filter(term =>
|
||||||
|
response.toLowerCase().includes(term)
|
||||||
|
).length;
|
||||||
|
confidence += Math.min(15, termsFound * 3);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool-evaluation':
|
||||||
|
if (response.includes('detailed_explanation') || response.includes('implementation_approach')) {
|
||||||
|
confidence += 15;
|
||||||
|
}
|
||||||
|
if (response.includes('pros') && response.includes('limitations')) {
|
||||||
|
confidence += 10;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'background-knowledge':
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(response);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
confidence += 20;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
confidence -= 20;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(95, Math.max(25, confidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuditStatistics(auditTrail: AuditEntry[]): any {
|
||||||
|
if (!auditTrail || auditTrail.length === 0) {
|
||||||
|
return {
|
||||||
|
totalTime: 0,
|
||||||
|
avgConfidence: 0,
|
||||||
|
stepCount: 0,
|
||||||
|
highConfidenceSteps: 0,
|
||||||
|
lowConfidenceSteps: 0,
|
||||||
|
phaseBreakdown: {},
|
||||||
|
aiDecisionCount: 0,
|
||||||
|
embeddingsUsageCount: 0,
|
||||||
|
toolSelectionCount: 0,
|
||||||
|
qualityMetrics: {
|
||||||
|
avgProcessingTime: 0,
|
||||||
|
confidenceDistribution: { high: 0, medium: 0, low: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTime = auditTrail.reduce((sum, entry) => sum + (entry.processingTimeMs || 0), 0);
|
||||||
|
const validConfidenceEntries = auditTrail.filter(entry => typeof entry.confidence === 'number');
|
||||||
|
const avgConfidence = validConfidenceEntries.length > 0
|
||||||
|
? Math.round(validConfidenceEntries.reduce((sum, entry) => sum + entry.confidence, 0) / validConfidenceEntries.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTime,
|
||||||
|
avgConfidence,
|
||||||
|
stepCount: auditTrail.length,
|
||||||
|
highConfidenceSteps: auditTrail.filter(entry => (entry.confidence || 0) >= 80).length,
|
||||||
|
lowConfidenceSteps: auditTrail.filter(entry => (entry.confidence || 0) < 60).length,
|
||||||
|
phaseBreakdown: {},
|
||||||
|
aiDecisionCount: auditTrail.filter(entry => entry.action === 'ai-decision').length,
|
||||||
|
embeddingsUsageCount: auditTrail.filter(entry => entry.metadata?.embeddingsUsed).length,
|
||||||
|
toolSelectionCount: auditTrail.filter(entry => entry.action === 'selection-decision').length,
|
||||||
|
qualityMetrics: {
|
||||||
|
avgProcessingTime: auditTrail.length > 0 ? totalTime / auditTrail.length : 0,
|
||||||
|
confidenceDistribution: {
|
||||||
|
high: auditTrail.filter(entry => (entry.confidence || 0) >= 80).length,
|
||||||
|
medium: auditTrail.filter(entry => (entry.confidence || 0) >= 60 && (entry.confidence || 0) < 80).length,
|
||||||
|
low: auditTrail.filter(entry => (entry.confidence || 0) < 60).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAuditTrail(auditTrail: AuditEntry[]): {
|
||||||
|
isValid: boolean;
|
||||||
|
issues: string[];
|
||||||
|
warnings: string[];
|
||||||
|
} {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!Array.isArray(auditTrail)) {
|
||||||
|
issues.push('Audit trail is not an array');
|
||||||
|
return { isValid: false, issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auditTrail.length === 0) {
|
||||||
|
warnings.push('Audit trail is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
auditTrail.forEach((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
issues.push(`Entry ${index} is not a valid object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFields = ['timestamp', 'phase', 'action'];
|
||||||
|
requiredFields.forEach(field => {
|
||||||
|
if (!(field in entry)) {
|
||||||
|
issues.push(`Entry ${index} missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 100) {
|
||||||
|
warnings.push(`Entry ${index} has invalid confidence value: ${entry.confidence}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditService = new AuditService();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/utils/auth.js (FIXED - Cleaned up and enhanced debugging)
|
// src/utils/auth.js
|
||||||
import type { AstroGlobal } from 'astro';
|
import type { AstroGlobal } from 'astro';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
@@ -27,7 +27,7 @@ export interface AuthContext {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthContextType = 'contributions' | 'ai' | 'general';
|
export type AuthContextType = 'contributions' | 'ai' | 'general' | 'gatedcontent';
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
sub?: string;
|
sub?: string;
|
||||||
@@ -52,22 +52,17 @@ function getEnv(key: string): string {
|
|||||||
|
|
||||||
export function getSessionFromRequest(request: Request): string | null {
|
export function getSessionFromRequest(request: Request): string | null {
|
||||||
const cookieHeader = request.headers.get('cookie');
|
const cookieHeader = request.headers.get('cookie');
|
||||||
console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
|
|
||||||
|
|
||||||
if (!cookieHeader) return null;
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
const cookies = parseCookie(cookieHeader);
|
const cookies = parseCookie(cookieHeader);
|
||||||
console.log('[DEBUG] Parsed cookies:', Object.keys(cookies));
|
|
||||||
console.log('[DEBUG] Session cookie found:', !!cookies.session);
|
|
||||||
|
|
||||||
return cookies.session || null;
|
return cookies.session || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifySession(sessionToken: string): Promise<SessionData | null> {
|
export async function verifySession(sessionToken: string): Promise<SessionData | null> {
|
||||||
try {
|
try {
|
||||||
console.log('[DEBUG] Verifying session token, length:', sessionToken.length);
|
|
||||||
const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
|
const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
|
||||||
console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof payload.userId === 'string' &&
|
typeof payload.userId === 'string' &&
|
||||||
@@ -75,7 +70,6 @@ export async function verifySession(sessionToken: string): Promise<SessionData |
|
|||||||
typeof payload.authenticated === 'boolean' &&
|
typeof payload.authenticated === 'boolean' &&
|
||||||
typeof payload.exp === 'number'
|
typeof payload.exp === 'number'
|
||||||
) {
|
) {
|
||||||
console.log('[DEBUG] Session validation successful for user:', payload.userId);
|
|
||||||
return {
|
return {
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
@@ -84,17 +78,14 @@ export async function verifySession(sessionToken: string): Promise<SessionData |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] Session payload validation failed, payload:', payload);
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[DEBUG] Session verification failed:', error.message);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(userId: string, email: string): Promise<string> {
|
export async function createSession(userId: string, email: string): Promise<string> {
|
||||||
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
|
||||||
console.log('[DEBUG] Creating session for user:', userId, 'exp:', exp);
|
|
||||||
|
|
||||||
const token = await new SignJWT({
|
const token = await new SignJWT({
|
||||||
userId,
|
userId,
|
||||||
@@ -106,7 +97,6 @@ export async function createSession(userId: string, email: string): Promise<stri
|
|||||||
.setExpirationTime(exp)
|
.setExpirationTime(exp)
|
||||||
.sign(SECRET_KEY);
|
.sign(SECRET_KEY);
|
||||||
|
|
||||||
console.log('[DEBUG] Session token created, length:', token.length);
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +113,6 @@ export function createSessionCookie(sessionToken: string): string {
|
|||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DEBUG] Created session cookie:', cookie.substring(0, 100) + '...');
|
|
||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +249,8 @@ function getAuthRequirement(context: AuthContextType): boolean {
|
|||||||
return process.env.AUTHENTICATION_NECESSARY_CONTRIBUTIONS !== 'false';
|
return process.env.AUTHENTICATION_NECESSARY_CONTRIBUTIONS !== 'false';
|
||||||
case 'ai':
|
case 'ai':
|
||||||
return process.env.AUTHENTICATION_NECESSARY_AI !== 'false';
|
return process.env.AUTHENTICATION_NECESSARY_AI !== 'false';
|
||||||
case 'general':
|
case 'gatedcontent':
|
||||||
return process.env.AUTHENTICATION_NECESSARY !== 'false';
|
return process.env.AUTHENTICATION_NECESSARY_GATEDCONTENT !== 'false';
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -292,8 +281,6 @@ export async function createSessionWithCookie(userInfo: UserInfo): Promise<{
|
|||||||
|
|
||||||
export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'general'): Promise<AuthContext | Response> {
|
export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'general'): Promise<AuthContext | Response> {
|
||||||
const authRequired = getAuthRequirement(context);
|
const authRequired = getAuthRequirement(context);
|
||||||
console.log(`[DEBUG PAGE] Auth required for ${context}:`, authRequired);
|
|
||||||
console.log('[DEBUG PAGE] Request URL:', Astro.url.toString());
|
|
||||||
|
|
||||||
if (!authRequired) {
|
if (!authRequired) {
|
||||||
return {
|
return {
|
||||||
@@ -305,10 +292,8 @@ export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'g
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = getSessionFromRequest(Astro.request);
|
const sessionToken = getSessionFromRequest(Astro.request);
|
||||||
console.log('[DEBUG PAGE] Session token found:', !!sessionToken);
|
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
console.log('[DEBUG PAGE] No session token, redirecting to login');
|
|
||||||
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
|
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -317,10 +302,8 @@ export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'g
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await verifySession(sessionToken);
|
const session = await verifySession(sessionToken);
|
||||||
console.log('[DEBUG PAGE] Session verification result:', !!session);
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.log('[DEBUG PAGE] Session verification failed, redirecting to login');
|
|
||||||
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
|
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -328,7 +311,6 @@ export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'g
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DEBUG PAGE] Page authentication successful for ${context}:`, session.userId);
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
session,
|
session,
|
||||||
@@ -354,10 +336,8 @@ export async function withAPIAuth(request: Request, context: AuthContextType = '
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = getSessionFromRequest(request);
|
const sessionToken = getSessionFromRequest(request);
|
||||||
console.log(`[DEBUG API] Session token found for ${context}:`, !!sessionToken);
|
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
console.log(`[DEBUG API] No session token found for ${context}`);
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -366,10 +346,8 @@ export async function withAPIAuth(request: Request, context: AuthContextType = '
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await verifySession(sessionToken);
|
const session = await verifySession(sessionToken);
|
||||||
console.log(`[DEBUG API] Session verification result for ${context}:`, !!session);
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.log(`[DEBUG API] Session verification failed for ${context}`);
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -377,7 +355,6 @@ export async function withAPIAuth(request: Request, context: AuthContextType = '
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DEBUG API] Authentication successful for ${context}:`, session.userId);
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
@@ -388,4 +365,12 @@ export async function withAPIAuth(request: Request, context: AuthContextType = '
|
|||||||
|
|
||||||
export function getAuthRequirementForContext(context: AuthContextType): boolean {
|
export function getAuthRequirementForContext(context: AuthContextType): boolean {
|
||||||
return getAuthRequirement(context);
|
return getAuthRequirement(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGatedContentAuthRequired(): boolean {
|
||||||
|
return getAuthRequirement('gatedcontent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldGateContent(isGatedContent: boolean): boolean {
|
||||||
|
return isGatedContent && isGatedContentAuthRequired();
|
||||||
}
|
}
|
||||||
428
src/utils/clientUtils.ts
Normal file
428
src/utils/clientUtils.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
// src/utils/clientUtils.ts
|
||||||
|
|
||||||
|
export function createToolSlug(toolName: string): string {
|
||||||
|
if (!toolName || typeof toolName !== 'string') {
|
||||||
|
console.warn('[CLIENT-UTILS] Invalid toolName provided to createToolSlug:', toolName);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolName.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
|
||||||
|
if (!identifier || !Array.isArray(tools)) return undefined;
|
||||||
|
|
||||||
|
return tools.find((tool: any) =>
|
||||||
|
tool.name === identifier ||
|
||||||
|
createToolSlug(tool.name) === identifier.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolHosted(tool: any): boolean {
|
||||||
|
return tool.projectUrl !== undefined &&
|
||||||
|
tool.projectUrl !== null &&
|
||||||
|
tool.projectUrl !== "" &&
|
||||||
|
tool.projectUrl.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeText(text: string): string {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.+?)\*/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/```[\s\S]*?```/g, '[CODE BLOCK]')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
if (typeof text !== 'string') return String(text);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (!text || text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeData(data: any): string {
|
||||||
|
if (data === null || data === undefined) return 'null';
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data.length > 100 ? data.slice(0, 100) + '...' : data;
|
||||||
|
}
|
||||||
|
if (typeof data === 'number' || typeof data === 'boolean') {
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length === 0) return '[]';
|
||||||
|
if (data.length <= 3) return JSON.stringify(data);
|
||||||
|
return `[${data.slice(0, 3).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join(', ')}, ...+${data.length - 3}]`;
|
||||||
|
}
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
if (keys.length === 0) return '{}';
|
||||||
|
if (keys.length <= 3) {
|
||||||
|
return '{' + keys.map(k => `${k}: ${typeof data[k] === 'string' ? data[k].slice(0, 20) + (data[k].length > 20 ? '...' : '') : JSON.stringify(data[k])}`).join(', ') + '}';
|
||||||
|
}
|
||||||
|
return `{${keys.slice(0, 3).join(', ')}, ...+${keys.length - 3} keys}`;
|
||||||
|
}
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return '< 1s';
|
||||||
|
if (ms < 60000) return `${Math.ceil(ms / 1000)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.ceil((ms % 60000) / 1000);
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showElement(element: HTMLElement | null): void {
|
||||||
|
if (element) {
|
||||||
|
element.style.display = 'block';
|
||||||
|
element.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideElement(element: HTMLElement | null): void {
|
||||||
|
if (element) {
|
||||||
|
element.style.display = 'none';
|
||||||
|
element.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutocompleteOptions {
|
||||||
|
minLength?: number;
|
||||||
|
maxResults?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
separator?: string;
|
||||||
|
filterFunction?: (query: string) => any[];
|
||||||
|
renderFunction?: (item: any) => string;
|
||||||
|
hiddenInput?: HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutocompleteManager {
|
||||||
|
public input: HTMLInputElement;
|
||||||
|
public dataSource: any[];
|
||||||
|
public options: AutocompleteOptions;
|
||||||
|
public isOpen: boolean = false;
|
||||||
|
public selectedIndex: number = -1;
|
||||||
|
public filteredData: any[] = [];
|
||||||
|
public selectedItems: Set<string> = new Set();
|
||||||
|
public dropdown!: HTMLElement;
|
||||||
|
public selectedContainer!: HTMLElement;
|
||||||
|
|
||||||
|
constructor(inputElement: HTMLInputElement, dataSource: any[], options: AutocompleteOptions = {}) {
|
||||||
|
this.input = inputElement;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.options = {
|
||||||
|
minLength: 1,
|
||||||
|
maxResults: 10,
|
||||||
|
placeholder: 'Type to search...',
|
||||||
|
allowMultiple: false,
|
||||||
|
separator: ', ',
|
||||||
|
filterFunction: this.defaultFilter.bind(this),
|
||||||
|
renderFunction: this.defaultRender.bind(this),
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
this.createDropdown();
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.initMultipleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createDropdown(): void {
|
||||||
|
this.dropdown = document.createElement('div');
|
||||||
|
this.dropdown.className = 'autocomplete-dropdown';
|
||||||
|
this.dropdown.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const parentElement = this.input.parentNode as HTMLElement;
|
||||||
|
parentElement.style.position = 'relative';
|
||||||
|
parentElement.insertBefore(this.dropdown, this.input.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents(): void {
|
||||||
|
this.input.addEventListener('input', (e) => {
|
||||||
|
this.handleInput((e.target as HTMLInputElement).value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('keydown', (e) => {
|
||||||
|
this.handleKeydown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('focus', () => {
|
||||||
|
if (this.input.value.length >= (this.options.minLength || 1)) {
|
||||||
|
this.showDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement || !this.dropdown.contains(activeElement)) {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (!this.input.contains(target) && !this.dropdown.contains(target)) {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initMultipleMode(): void {
|
||||||
|
this.selectedContainer = document.createElement('div');
|
||||||
|
this.selectedContainer.className = 'autocomplete-selected';
|
||||||
|
this.selectedContainer.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const parentElement = this.input.parentNode as HTMLElement;
|
||||||
|
parentElement.insertBefore(this.selectedContainer, this.input);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(value: string): void {
|
||||||
|
if (value.length >= (this.options.minLength || 1)) {
|
||||||
|
this.filteredData = this.options.filterFunction!(value);
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
this.renderDropdown();
|
||||||
|
this.showDropdown();
|
||||||
|
} else {
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(e: KeyboardEvent): void {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredData.length - 1);
|
||||||
|
this.updateHighlight();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||||
|
this.updateHighlight();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.selectedIndex >= 0) {
|
||||||
|
this.selectItem(this.filteredData[this.selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
this.hideDropdown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultFilter(query: string): any[] {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
return this.dataSource
|
||||||
|
.filter(item => {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
return text.toLowerCase().includes(searchTerm) &&
|
||||||
|
(!this.options.allowMultiple || !this.selectedItems.has(text));
|
||||||
|
})
|
||||||
|
.slice(0, this.options.maxResults || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRender(item: any): string {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
return `<div class="autocomplete-item">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDropdown(): void {
|
||||||
|
if (this.filteredData.length === 0) {
|
||||||
|
this.dropdown.innerHTML = '<div class="autocomplete-no-results">No results found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dropdown.innerHTML = this.filteredData
|
||||||
|
.map((item, index) => {
|
||||||
|
const content = this.options.renderFunction!(item);
|
||||||
|
return `<div class="autocomplete-option" data-index="${index}" style="
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
">${content}</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
this.selectItem(this.filteredData[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
option.addEventListener('mouseenter', () => {
|
||||||
|
this.selectedIndex = index;
|
||||||
|
this.updateHighlight();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHighlight(): void {
|
||||||
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
|
(option as HTMLElement).style.backgroundColor = index === this.selectedIndex
|
||||||
|
? 'var(--color-bg-secondary)'
|
||||||
|
: 'transparent';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(item: any): void {
|
||||||
|
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||||||
|
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.selectedItems.add(text);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
this.input.value = '';
|
||||||
|
} else {
|
||||||
|
this.input.value = text;
|
||||||
|
this.hideDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
||||||
|
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(text: string): void {
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.selectedItems.delete(text);
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedDisplay(): void {
|
||||||
|
if (!this.options.allowMultiple || !this.selectedContainer) return;
|
||||||
|
|
||||||
|
this.selectedContainer.innerHTML = Array.from(this.selectedItems)
|
||||||
|
.map(item => `
|
||||||
|
<span class="autocomplete-tag" style="
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
">
|
||||||
|
${escapeHtml(item)}
|
||||||
|
<button type="button" class="autocomplete-remove" data-item="${escapeHtml(item)}" style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
">×</button>
|
||||||
|
</span>
|
||||||
|
`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.removeItem((btn as HTMLElement).getAttribute('data-item')!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInputValue(): void {
|
||||||
|
if (this.options.allowMultiple && this.options.hiddenInput) {
|
||||||
|
this.options.hiddenInput.value = Array.from(this.selectedItems).join(this.options.separator || ', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDropdown(): void {
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDropdown(): void {
|
||||||
|
this.dropdown.style.display = 'none';
|
||||||
|
this.isOpen = false;
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDataSource(newDataSource: any[]): void {
|
||||||
|
this.dataSource = newDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedItems(): string[] {
|
||||||
|
return Array.from(this.selectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItems(items: string[]): void {
|
||||||
|
this.selectedItems = new Set(items);
|
||||||
|
if (this.options.allowMultiple) {
|
||||||
|
this.updateSelectedDisplay();
|
||||||
|
this.updateInputValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.dropdown && this.dropdown.parentNode) {
|
||||||
|
this.dropdown.parentNode.removeChild(this.dropdown);
|
||||||
|
}
|
||||||
|
if (this.selectedContainer && this.selectedContainer.parentNode) {
|
||||||
|
this.selectedContainer.parentNode.removeChild(this.selectedContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/utils/confidenceScoring.ts
Normal file
225
src/utils/confidenceScoring.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// src/utils/confidenceScoring.ts
|
||||||
|
import { isToolHosted } from './clientUtils.js';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export interface ConfidenceMetrics {
|
||||||
|
overall: number;
|
||||||
|
semanticRelevance: number;
|
||||||
|
taskSuitability: number;
|
||||||
|
uncertaintyFactors: string[];
|
||||||
|
strengthIndicators: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfidenceConfig {
|
||||||
|
semanticWeight: number;
|
||||||
|
suitabilityWeight: number;
|
||||||
|
minimumThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
highThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisContext {
|
||||||
|
userQuery: string;
|
||||||
|
mode: string;
|
||||||
|
embeddingsSimilarities: Map<string, number>;
|
||||||
|
selectedTools?: Array<{
|
||||||
|
tool: any;
|
||||||
|
phase: string;
|
||||||
|
priority: string;
|
||||||
|
justification?: string;
|
||||||
|
taskRelevance?: number;
|
||||||
|
limitations?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfidenceScoring {
|
||||||
|
private config: ConfidenceConfig;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = {
|
||||||
|
semanticWeight: this.getEnvFloat('CONFIDENCE_SEMANTIC_WEIGHT', 0.3),
|
||||||
|
suitabilityWeight: this.getEnvFloat('CONFIDENCE_SUITABILITY_WEIGHT', 0.7),
|
||||||
|
minimumThreshold: this.getEnvInt('CONFIDENCE_MINIMUM_THRESHOLD', 40),
|
||||||
|
mediumThreshold: this.getEnvInt('CONFIDENCE_MEDIUM_THRESHOLD', 60),
|
||||||
|
highThreshold: this.getEnvInt('CONFIDENCE_HIGH_THRESHOLD', 80)
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[CONFIDENCE-SCORING] Initialized with restored config:', this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEnvFloat(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseFloat(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEnvInt(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseInt(value, 10) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateRecommendationConfidence(
|
||||||
|
tool: any,
|
||||||
|
context: AnalysisContext,
|
||||||
|
taskRelevance: number = 70,
|
||||||
|
limitations: string[] = []
|
||||||
|
): ConfidenceMetrics {
|
||||||
|
console.log('[CONFIDENCE-SCORING] Calculating confidence for tool:', tool.name);
|
||||||
|
|
||||||
|
const rawSemanticRelevance = context.embeddingsSimilarities.has(tool.name) ?
|
||||||
|
context.embeddingsSimilarities.get(tool.name)! * 100 : 50;
|
||||||
|
|
||||||
|
let enhancedTaskSuitability = taskRelevance;
|
||||||
|
|
||||||
|
if (context.mode === 'workflow') {
|
||||||
|
const toolSelection = context.selectedTools?.find((st: any) => st.tool && st.tool.name === tool.name);
|
||||||
|
if (toolSelection && tool.phases && Array.isArray(tool.phases) && tool.phases.includes(toolSelection.phase)) {
|
||||||
|
const phaseBonus = Math.min(15, 100 - taskRelevance);
|
||||||
|
enhancedTaskSuitability = Math.min(100, taskRelevance + phaseBonus);
|
||||||
|
console.log('[CONFIDENCE-SCORING] Phase alignment bonus applied:', phaseBonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = (
|
||||||
|
rawSemanticRelevance * this.config.semanticWeight +
|
||||||
|
enhancedTaskSuitability * this.config.suitabilityWeight
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncertaintyFactors = this.identifyUncertaintyFactors(tool, context, limitations, overall);
|
||||||
|
const strengthIndicators = this.identifyStrengthIndicators(tool, context, overall);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
overall: Math.round(overall),
|
||||||
|
semanticRelevance: Math.round(rawSemanticRelevance),
|
||||||
|
taskSuitability: Math.round(enhancedTaskSuitability),
|
||||||
|
uncertaintyFactors,
|
||||||
|
strengthIndicators
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[CONFIDENCE-SCORING] Confidence calculated:', {
|
||||||
|
tool: tool.name,
|
||||||
|
overall: result.overall,
|
||||||
|
semantic: result.semanticRelevance,
|
||||||
|
task: result.taskSuitability,
|
||||||
|
uncertaintyCount: uncertaintyFactors.length,
|
||||||
|
strengthCount: strengthIndicators.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private identifyUncertaintyFactors(
|
||||||
|
tool: any,
|
||||||
|
context: AnalysisContext,
|
||||||
|
limitations: string[],
|
||||||
|
confidence: number
|
||||||
|
): string[] {
|
||||||
|
const factors: string[] = [];
|
||||||
|
|
||||||
|
if (limitations?.length > 0) {
|
||||||
|
factors.push(...limitations.slice(0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarity = context.embeddingsSimilarities.get(tool.name) || 0.5;
|
||||||
|
if (similarity < 0.7) {
|
||||||
|
factors.push('Geringe semantische Ähnlichkeit zur Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.skillLevel === 'expert' && /schnell|rapid|triage|urgent|sofort/i.test(context.userQuery)) {
|
||||||
|
factors.push('Experten-Tool für zeitkritisches Szenario');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.skillLevel === 'novice' && /komplex|erweitert|tiefgehend|advanced|forensisch/i.test(context.userQuery)) {
|
||||||
|
factors.push('Einsteiger-Tool für komplexe Analyse');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.type === 'software' && !isToolHosted(tool) && tool.accessType === 'download') {
|
||||||
|
factors.push('Installation und Setup erforderlich');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.license === 'Proprietary') {
|
||||||
|
factors.push('Kommerzielle Software - Lizenzkosten zu beachten');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confidence < 60) {
|
||||||
|
factors.push('Moderate Gesamtbewertung - alternative Ansätze empfohlen');
|
||||||
|
}
|
||||||
|
|
||||||
|
return factors.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private identifyStrengthIndicators(tool: any, context: AnalysisContext, confidence: number): string[] {
|
||||||
|
const indicators: string[] = [];
|
||||||
|
|
||||||
|
const similarity = context.embeddingsSimilarities.get(tool.name) || 0.5;
|
||||||
|
if (similarity >= 0.7) {
|
||||||
|
indicators.push('Sehr gute semantische Übereinstimmung mit Ihrer Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.knowledgebase === true) {
|
||||||
|
indicators.push('Umfassende Dokumentation und Wissensbasis verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolHosted(tool)) {
|
||||||
|
indicators.push('Sofort verfügbar über gehostete Lösung');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.skillLevel === 'intermediate' || tool.skillLevel === 'advanced') {
|
||||||
|
indicators.push('Ausgewogenes Verhältnis zwischen Funktionalität und Benutzerfreundlichkeit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.type === 'method' && /methodik|vorgehen|prozess|ansatz/i.test(context.userQuery)) {
|
||||||
|
indicators.push('Methodischer Ansatz passt zu Ihrer prozeduralen Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicators.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateSelectionConfidence(result: any, candidateCount: number): number {
|
||||||
|
if (!result?.selectedTools) {
|
||||||
|
console.log('[CONFIDENCE-SCORING] No selected tools for confidence calculation');
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionRatio = result.selectedTools.length / candidateCount;
|
||||||
|
const hasReasoning = result.reasoning && result.reasoning.length > 50;
|
||||||
|
|
||||||
|
let confidence = 60;
|
||||||
|
|
||||||
|
if (selectionRatio > 0.05 && selectionRatio < 0.3) confidence += 20;
|
||||||
|
else if (selectionRatio <= 0.05) confidence -= 10;
|
||||||
|
else confidence -= 15;
|
||||||
|
|
||||||
|
if (hasReasoning) confidence += 15;
|
||||||
|
if (result.selectedConcepts?.length > 0) confidence += 5;
|
||||||
|
|
||||||
|
const finalConfidence = Math.min(95, Math.max(25, confidence));
|
||||||
|
|
||||||
|
console.log('[CONFIDENCE-SCORING] Selection confidence calculated:', {
|
||||||
|
candidateCount,
|
||||||
|
selectedCount: result.selectedTools.length,
|
||||||
|
selectionRatio: selectionRatio.toFixed(3),
|
||||||
|
hasReasoning,
|
||||||
|
confidence: finalConfidence
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfidenceLevel(confidence: number): 'weak' | 'moderate' | 'strong' {
|
||||||
|
if (confidence >= this.config.highThreshold) return 'strong';
|
||||||
|
if (confidence >= this.config.mediumThreshold) return 'moderate';
|
||||||
|
return 'weak';
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfidenceColor(confidence: number): string {
|
||||||
|
if (confidence >= this.config.highThreshold) return 'var(--color-accent)';
|
||||||
|
if (confidence >= this.config.mediumThreshold) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): ConfidenceConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confidenceScoring = new ConfidenceScoring();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/utils/dataService.ts - Enhanced for micro-task AI pipeline
|
// src/utils/dataService.ts
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { load } from 'js-yaml';
|
import { load } from 'js-yaml';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -77,40 +77,15 @@ interface EnhancedCompressedToolsData {
|
|||||||
domains: any[];
|
domains: any[];
|
||||||
phases: any[];
|
phases: any[];
|
||||||
'domain-agnostic-software': any[];
|
'domain-agnostic-software': any[];
|
||||||
scenarios?: any[]; // Optional for AI processing
|
scenarios?: any[];
|
||||||
skill_levels: any;
|
skill_levels: any;
|
||||||
// Enhanced context for micro-tasks
|
|
||||||
domain_relationships: DomainRelationship[];
|
|
||||||
phase_dependencies: PhaseDependency[];
|
|
||||||
tool_compatibility_matrix: CompatibilityMatrix[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DomainRelationship {
|
|
||||||
domain_id: string;
|
|
||||||
tool_count: number;
|
|
||||||
common_tags: string[];
|
|
||||||
skill_distribution: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhaseDependency {
|
|
||||||
phase_id: string;
|
|
||||||
order: number;
|
|
||||||
depends_on: string | null;
|
|
||||||
enables: string | null;
|
|
||||||
is_parallel_capable: boolean;
|
|
||||||
typical_duration: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompatibilityMatrix {
|
|
||||||
type: string;
|
|
||||||
groups: Record<string, string[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedData: ToolsData | null = null;
|
let cachedData: ToolsData | null = null;
|
||||||
let cachedRandomizedData: ToolsData | null = null;
|
let cachedRandomizedData: ToolsData | null = null;
|
||||||
let cachedCompressedData: EnhancedCompressedToolsData | null = null;
|
let cachedCompressedData: EnhancedCompressedToolsData | null = null;
|
||||||
let lastRandomizationDate: string | null = null;
|
let lastRandomizationDate: string | null = null;
|
||||||
let dataVersion: string | null = null;
|
let cachedToolsHash: string | null = null;
|
||||||
|
|
||||||
function seededRandom(seed: number): () => number {
|
function seededRandom(seed: number): () => number {
|
||||||
let x = Math.sin(seed) * 10000;
|
let x = Math.sin(seed) * 10000;
|
||||||
@@ -135,115 +110,6 @@ function shuffleArray<T>(array: T[], randomFn: () => number): T[] {
|
|||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDataVersion(data: any): string {
|
|
||||||
const str = JSON.stringify(data, Object.keys(data).sort());
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced: Generate domain relationships for better AI understanding
|
|
||||||
function generateDomainRelationships(domains: any[], tools: any[]): DomainRelationship[] {
|
|
||||||
const relationships: DomainRelationship[] = [];
|
|
||||||
|
|
||||||
for (const domain of domains) {
|
|
||||||
const domainTools = tools.filter(tool =>
|
|
||||||
tool.domains && tool.domains.includes(domain.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const commonTags = domainTools
|
|
||||||
.flatMap(tool => tool.tags || [])
|
|
||||||
.reduce((acc: any, tag: string) => {
|
|
||||||
acc[tag] = (acc[tag] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const topTags = Object.entries(commonTags)
|
|
||||||
.sort(([,a], [,b]) => (b as number) - (a as number))
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(([tag]) => tag);
|
|
||||||
|
|
||||||
relationships.push({
|
|
||||||
domain_id: domain.id,
|
|
||||||
tool_count: domainTools.length,
|
|
||||||
common_tags: topTags,
|
|
||||||
skill_distribution: domainTools.reduce((acc: any, tool: any) => {
|
|
||||||
acc[tool.skillLevel] = (acc[tool.skillLevel] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return relationships;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced: Generate phase dependencies
|
|
||||||
function generatePhaseDependencies(phases: any[]): PhaseDependency[] {
|
|
||||||
const dependencies: PhaseDependency[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < phases.length; i++) {
|
|
||||||
const phase = phases[i];
|
|
||||||
const nextPhase = phases[i + 1];
|
|
||||||
const prevPhase = phases[i - 1];
|
|
||||||
|
|
||||||
dependencies.push({
|
|
||||||
phase_id: phase.id,
|
|
||||||
order: i + 1,
|
|
||||||
depends_on: prevPhase?.id || null,
|
|
||||||
enables: nextPhase?.id || null,
|
|
||||||
is_parallel_capable: ['examination', 'analysis'].includes(phase.id), // Some phases can run in parallel
|
|
||||||
typical_duration: phase.id === 'data-collection' ? 'hours-days' :
|
|
||||||
phase.id === 'examination' ? 'hours-weeks' :
|
|
||||||
phase.id === 'analysis' ? 'days-weeks' :
|
|
||||||
'hours-days'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return dependencies;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced: Generate tool compatibility matrix
|
|
||||||
function generateToolCompatibilityMatrix(tools: any[]): CompatibilityMatrix[] {
|
|
||||||
const matrix: CompatibilityMatrix[] = [];
|
|
||||||
|
|
||||||
// Group tools by common characteristics
|
|
||||||
const platformGroups = tools.reduce((acc: any, tool: any) => {
|
|
||||||
if (tool.platforms) {
|
|
||||||
tool.platforms.forEach((platform: string) => {
|
|
||||||
if (!acc[platform]) acc[platform] = [];
|
|
||||||
acc[platform].push(tool.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const phaseGroups = tools.reduce((acc: any, tool: any) => {
|
|
||||||
if (tool.phases) {
|
|
||||||
tool.phases.forEach((phase: string) => {
|
|
||||||
if (!acc[phase]) acc[phase] = [];
|
|
||||||
acc[phase].push(tool.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
matrix.push({
|
|
||||||
type: 'platform_compatibility',
|
|
||||||
groups: platformGroups
|
|
||||||
});
|
|
||||||
|
|
||||||
matrix.push({
|
|
||||||
type: 'phase_synergy',
|
|
||||||
groups: phaseGroups
|
|
||||||
});
|
|
||||||
|
|
||||||
return matrix;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRawData(): Promise<ToolsData> {
|
async function loadRawData(): Promise<ToolsData> {
|
||||||
if (!cachedData) {
|
if (!cachedData) {
|
||||||
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
|
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
|
||||||
@@ -251,9 +117,10 @@ async function loadRawData(): Promise<ToolsData> {
|
|||||||
const rawData = load(yamlContent);
|
const rawData = load(yamlContent);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Attempting to validate YAML structure...');
|
||||||
cachedData = ToolsDataSchema.parse(rawData);
|
cachedData = ToolsDataSchema.parse(rawData);
|
||||||
|
console.log('Validation successful!');
|
||||||
|
|
||||||
// Enhanced: Add default skill level descriptions if not provided
|
|
||||||
if (!cachedData.skill_levels || Object.keys(cachedData.skill_levels).length === 0) {
|
if (!cachedData.skill_levels || Object.keys(cachedData.skill_levels).length === 0) {
|
||||||
cachedData.skill_levels = {
|
cachedData.skill_levels = {
|
||||||
novice: "Minimal technical background required, guided interfaces",
|
novice: "Minimal technical background required, guided interfaces",
|
||||||
@@ -264,12 +131,20 @@ async function loadRawData(): Promise<ToolsData> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
dataVersion = generateDataVersion(cachedData);
|
const { getToolsFileHash } = await import('./hashUtils.js');
|
||||||
console.log(`[DATA SERVICE] Loaded enhanced data version: ${dataVersion}`);
|
cachedToolsHash = await getToolsFileHash();
|
||||||
|
console.log(`[DATA SERVICE] Loaded data with hash: ${cachedToolsHash.slice(0, 12)}...`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('YAML validation failed:', error);
|
if (error instanceof z.ZodError) {
|
||||||
throw new Error('Invalid tools.yaml structure');
|
console.error('ToolsDataSchema validation errors:');
|
||||||
|
error.errors.forEach((err, index) => {
|
||||||
|
console.error(`${index + 1}. Path: ${err.path.join('.')}, Error: ${err.message}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Non-Zod validation error:', error);
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid tools.yaml structure: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cachedData;
|
return cachedData;
|
||||||
@@ -301,21 +176,18 @@ export async function getCompressedToolsDataForAI(): Promise<EnhancedCompressedT
|
|||||||
if (!cachedCompressedData) {
|
if (!cachedCompressedData) {
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
// Enhanced: More detailed tool information for micro-tasks
|
|
||||||
const compressedTools = data.tools
|
const compressedTools = data.tools
|
||||||
.filter(tool => tool.type !== 'concept')
|
.filter(tool => tool.type !== 'concept')
|
||||||
.map(tool => {
|
.map(tool => {
|
||||||
const { projectUrl, statusUrl, ...compressedTool } = tool;
|
const { projectUrl, statusUrl, ...compressedTool } = tool;
|
||||||
return {
|
return {
|
||||||
...compressedTool,
|
...compressedTool,
|
||||||
// Enhanced: Add computed fields for AI
|
|
||||||
is_hosted: projectUrl !== undefined && projectUrl !== null && projectUrl !== "" && projectUrl.trim() !== "",
|
is_hosted: projectUrl !== undefined && projectUrl !== null && projectUrl !== "" && projectUrl.trim() !== "",
|
||||||
is_open_source: tool.license && tool.license !== 'Proprietary',
|
is_open_source: tool.license && tool.license !== 'Proprietary',
|
||||||
complexity_score: tool.skillLevel === 'expert' ? 5 :
|
complexity_score: tool.skillLevel === 'expert' ? 5 :
|
||||||
tool.skillLevel === 'advanced' ? 4 :
|
tool.skillLevel === 'advanced' ? 4 :
|
||||||
tool.skillLevel === 'intermediate' ? 3 :
|
tool.skillLevel === 'intermediate' ? 3 :
|
||||||
tool.skillLevel === 'beginner' ? 2 : 1,
|
tool.skillLevel === 'beginner' ? 2 : 1,
|
||||||
// Enhanced: Phase-specific suitability hints
|
|
||||||
phase_suitability: tool.phases?.map(phase => ({
|
phase_suitability: tool.phases?.map(phase => ({
|
||||||
phase,
|
phase,
|
||||||
primary_use: tool.tags?.find(tag => tag.includes(phase)) ? 'primary' : 'secondary'
|
primary_use: tool.tags?.find(tag => tag.includes(phase)) ? 'primary' : 'secondary'
|
||||||
@@ -329,7 +201,6 @@ export async function getCompressedToolsDataForAI(): Promise<EnhancedCompressedT
|
|||||||
const { projectUrl, statusUrl, platforms, accessType, license, ...compressedConcept } = concept;
|
const { projectUrl, statusUrl, platforms, accessType, license, ...compressedConcept } = concept;
|
||||||
return {
|
return {
|
||||||
...compressedConcept,
|
...compressedConcept,
|
||||||
// Enhanced: Learning difficulty indicator
|
|
||||||
learning_complexity: concept.skillLevel === 'expert' ? 'very_high' :
|
learning_complexity: concept.skillLevel === 'expert' ? 'very_high' :
|
||||||
concept.skillLevel === 'advanced' ? 'high' :
|
concept.skillLevel === 'advanced' ? 'high' :
|
||||||
concept.skillLevel === 'intermediate' ? 'medium' :
|
concept.skillLevel === 'intermediate' ? 'medium' :
|
||||||
@@ -337,34 +208,23 @@ export async function getCompressedToolsDataForAI(): Promise<EnhancedCompressedT
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enhanced: Add rich context data
|
|
||||||
const domainRelationships = generateDomainRelationships(data.domains, compressedTools);
|
|
||||||
const phaseDependencies = generatePhaseDependencies(data.phases);
|
|
||||||
const toolCompatibilityMatrix = generateToolCompatibilityMatrix(compressedTools);
|
|
||||||
|
|
||||||
cachedCompressedData = {
|
cachedCompressedData = {
|
||||||
tools: compressedTools,
|
tools: compressedTools,
|
||||||
concepts: concepts,
|
concepts: concepts,
|
||||||
domains: data.domains,
|
domains: data.domains,
|
||||||
phases: data.phases,
|
phases: data.phases,
|
||||||
'domain-agnostic-software': data['domain-agnostic-software'],
|
'domain-agnostic-software': data['domain-agnostic-software'],
|
||||||
scenarios: data.scenarios, // Include scenarios for context
|
scenarios: data.scenarios,
|
||||||
skill_levels: data.skill_levels || {},
|
skill_levels: data.skill_levels || {},
|
||||||
// Enhanced context for micro-tasks
|
|
||||||
domain_relationships: domainRelationships,
|
|
||||||
phase_dependencies: phaseDependencies,
|
|
||||||
tool_compatibility_matrix: toolCompatibilityMatrix
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[DATA SERVICE] Generated enhanced compressed data: ${compressedTools.length} tools, ${concepts.length} concepts`);
|
|
||||||
console.log(`[DATA SERVICE] Added context: ${domainRelationships.length} domain relationships, ${phaseDependencies.length} phase dependencies`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedCompressedData;
|
return cachedCompressedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDataVersion(): string | null {
|
export function getDataVersion(): string | null {
|
||||||
return dataVersion;
|
return cachedToolsHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearCache(): void {
|
export function clearCache(): void {
|
||||||
@@ -372,7 +232,7 @@ export function clearCache(): void {
|
|||||||
cachedRandomizedData = null;
|
cachedRandomizedData = null;
|
||||||
cachedCompressedData = null;
|
cachedCompressedData = null;
|
||||||
lastRandomizationDate = null;
|
lastRandomizationDate = null;
|
||||||
dataVersion = null;
|
cachedToolsHash = null;
|
||||||
|
|
||||||
console.log('[DATA SERVICE] Enhanced cache cleared');
|
console.log('[DATA SERVICE] Enhanced cache cleared');
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
// src/utils/embeddings.ts
|
// src/utils/embeddings.ts - Refactored
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getCompressedToolsDataForAI } from './dataService.js';
|
import { getCompressedToolsDataForAI } from './dataService.js';
|
||||||
|
import 'dotenv/config';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
interface EmbeddingData {
|
export interface EmbeddingData {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tool' | 'concept';
|
type: 'tool' | 'concept';
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,63 +20,110 @@ interface EmbeddingData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimilarityResult extends EmbeddingData {
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface EmbeddingsDatabase {
|
interface EmbeddingsDatabase {
|
||||||
version: string;
|
version: string;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
embeddings: EmbeddingData[];
|
embeddings: EmbeddingData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmbeddingsConfig {
|
||||||
|
endpoint?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
batchSize: number;
|
||||||
|
batchDelay: number;
|
||||||
|
}
|
||||||
|
|
||||||
class EmbeddingsService {
|
class EmbeddingsService {
|
||||||
private embeddings: EmbeddingData[] = [];
|
private embeddings: EmbeddingData[] = [];
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
private initializationPromise: Promise<void> | null = null;
|
||||||
private readonly embeddingsPath = path.join(process.cwd(), 'data', 'embeddings.json');
|
private readonly embeddingsPath = path.join(process.cwd(), 'data', 'embeddings.json');
|
||||||
private readonly batchSize: number;
|
private config: EmbeddingsConfig;
|
||||||
private readonly batchDelay: number;
|
|
||||||
private readonly enabled: boolean;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.enabled = process.env.AI_EMBEDDINGS_ENABLED === 'true';
|
this.config = this.loadConfig();
|
||||||
this.batchSize = parseInt(process.env.AI_EMBEDDINGS_BATCH_SIZE || '20', 10);
|
console.log('[EMBEDDINGS-SERVICE] Initialized:', {
|
||||||
this.batchDelay = parseInt(process.env.AI_EMBEDDINGS_BATCH_DELAY_MS || '1000', 10);
|
hasEndpoint: !!this.config.endpoint,
|
||||||
|
hasModel: !!this.config.model
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): EmbeddingsConfig {
|
||||||
|
const endpoint = process.env.AI_EMBEDDINGS_ENDPOINT;
|
||||||
|
const apiKey = process.env.AI_EMBEDDINGS_API_KEY;
|
||||||
|
const model = process.env.AI_EMBEDDINGS_MODEL;
|
||||||
|
const batchSize = parseInt(process.env.AI_EMBEDDINGS_BATCH_SIZE || '20', 10);
|
||||||
|
const batchDelay = parseInt(process.env.AI_EMBEDDINGS_BATCH_DELAY_MS || '1000', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
batchSize,
|
||||||
|
batchDelay
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (!this.enabled) {
|
if (this.initializationPromise) {
|
||||||
console.log('[EMBEDDINGS] Embeddings disabled, skipping initialization');
|
return this.initializationPromise;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.isInitialized) {
|
||||||
console.log('[EMBEDDINGS] Initializing embeddings system...');
|
return Promise.resolve();
|
||||||
|
|
||||||
// Create data directory if it doesn't exist
|
|
||||||
await fs.mkdir(path.dirname(this.embeddingsPath), { recursive: true });
|
|
||||||
|
|
||||||
const toolsData = await getCompressedToolsDataForAI();
|
|
||||||
const currentDataHash = this.hashData(toolsData);
|
|
||||||
|
|
||||||
// Try to load existing embeddings
|
|
||||||
const existingEmbeddings = await this.loadEmbeddings();
|
|
||||||
|
|
||||||
if (existingEmbeddings && existingEmbeddings.version === currentDataHash) {
|
|
||||||
console.log('[EMBEDDINGS] Using cached embeddings');
|
|
||||||
this.embeddings = existingEmbeddings.embeddings;
|
|
||||||
} else {
|
|
||||||
console.log('[EMBEDDINGS] Generating new embeddings...');
|
|
||||||
await this.generateEmbeddings(toolsData, currentDataHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
|
||||||
console.log(`[EMBEDDINGS] Initialized with ${this.embeddings.length} embeddings`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[EMBEDDINGS] Failed to initialize:', error);
|
|
||||||
this.isInitialized = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.initializationPromise = this.performInitialization();
|
||||||
|
return this.initializationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashData(data: any): string {
|
private async performInitialization(): Promise<void> {
|
||||||
return Buffer.from(JSON.stringify(data)).toString('base64').slice(0, 32);
|
const initStart = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Starting initialization');
|
||||||
|
|
||||||
|
/*if (!this.config.enabled) {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Service disabled via configuration');
|
||||||
|
return;
|
||||||
|
}*/
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(this.embeddingsPath), { recursive: true });
|
||||||
|
|
||||||
|
const toolsData = await getCompressedToolsDataForAI();
|
||||||
|
const { getToolsFileHash } = await import('./hashUtils.js');
|
||||||
|
const currentDataHash = await getToolsFileHash();
|
||||||
|
|
||||||
|
const existing = await this.loadEmbeddings();
|
||||||
|
|
||||||
|
const cacheIsUsable = existing &&
|
||||||
|
existing.version === currentDataHash &&
|
||||||
|
Array.isArray(existing.embeddings) &&
|
||||||
|
existing.embeddings.length > 0;
|
||||||
|
|
||||||
|
if (cacheIsUsable) {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Using cached embeddings');
|
||||||
|
this.embeddings = existing.embeddings;
|
||||||
|
} else {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Generating new embeddings');
|
||||||
|
await this.generateEmbeddings(toolsData, currentDataHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log(`[EMBEDDINGS-SERVICE] Initialized successfully with ${this.embeddings.length} embeddings in ${Date.now() - initStart}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EMBEDDINGS-SERVICE] Initialization failed:', error);
|
||||||
|
this.isInitialized = false;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.initializationPromise = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadEmbeddings(): Promise<EmbeddingsDatabase | null> {
|
private async loadEmbeddings(): Promise<EmbeddingsDatabase | null> {
|
||||||
@@ -82,7 +131,7 @@ class EmbeddingsService {
|
|||||||
const data = await fs.readFile(this.embeddingsPath, 'utf8');
|
const data = await fs.readFile(this.embeddingsPath, 'utf8');
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[EMBEDDINGS] No existing embeddings found');
|
console.log('[EMBEDDINGS-SERVICE] No existing embeddings file found');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +144,7 @@ class EmbeddingsService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fs.writeFile(this.embeddingsPath, JSON.stringify(database, null, 2));
|
await fs.writeFile(this.embeddingsPath, JSON.stringify(database, null, 2));
|
||||||
console.log(`[EMBEDDINGS] Saved ${this.embeddings.length} embeddings to disk`);
|
console.log(`[EMBEDDINGS-SERVICE] Saved ${this.embeddings.length} embeddings to disk`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createContentString(item: any): string {
|
private createContentString(item: any): string {
|
||||||
@@ -111,22 +160,23 @@ class EmbeddingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateEmbeddingsBatch(contents: string[]): Promise<number[][]> {
|
private async generateEmbeddingsBatch(contents: string[]): Promise<number[][]> {
|
||||||
const endpoint = process.env.AI_EMBEDDINGS_ENDPOINT;
|
if (!this.config.endpoint || !this.config.model) {
|
||||||
const apiKey = process.env.AI_EMBEDDINGS_API_KEY;
|
|
||||||
const model = process.env.AI_EMBEDDINGS_MODEL;
|
|
||||||
|
|
||||||
if (!endpoint || !apiKey || !model) {
|
|
||||||
throw new Error('Missing embeddings API configuration');
|
throw new Error('Missing embeddings API configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.apiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.config.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model: this.config.model,
|
||||||
input: contents
|
input: contents
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -137,7 +187,16 @@ class EmbeddingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.data.map((item: any) => item.embedding);
|
|
||||||
|
if (Array.isArray(data.embeddings)) {
|
||||||
|
return data.embeddings;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.data)) {
|
||||||
|
return data.data.map((item: any) => item.embedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown embeddings API response format');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateEmbeddings(toolsData: any, version: string): Promise<void> {
|
private async generateEmbeddings(toolsData: any, version: string): Promise<void> {
|
||||||
@@ -149,12 +208,16 @@ class EmbeddingsService {
|
|||||||
const contents = allItems.map(item => this.createContentString(item));
|
const contents = allItems.map(item => this.createContentString(item));
|
||||||
this.embeddings = [];
|
this.embeddings = [];
|
||||||
|
|
||||||
// Process in batches to respect rate limits
|
console.log(`[EMBEDDINGS-SERVICE] Generating embeddings for ${contents.length} items`);
|
||||||
for (let i = 0; i < contents.length; i += this.batchSize) {
|
|
||||||
const batch = contents.slice(i, i + this.batchSize);
|
for (let i = 0; i < contents.length; i += this.config.batchSize) {
|
||||||
const batchItems = allItems.slice(i, i + this.batchSize);
|
const batch = contents.slice(i, i + this.config.batchSize);
|
||||||
|
const batchItems = allItems.slice(i, i + this.config.batchSize);
|
||||||
|
|
||||||
console.log(`[EMBEDDINGS] Processing batch ${Math.ceil((i + 1) / this.batchSize)} of ${Math.ceil(contents.length / this.batchSize)}`);
|
const batchNumber = Math.ceil((i + 1) / this.config.batchSize);
|
||||||
|
const totalBatches = Math.ceil(contents.length / this.config.batchSize);
|
||||||
|
|
||||||
|
console.log(`[EMBEDDINGS-SERVICE] Processing batch ${batchNumber}/${totalBatches}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const embeddings = await this.generateEmbeddingsBatch(batch);
|
const embeddings = await this.generateEmbeddingsBatch(batch);
|
||||||
@@ -177,13 +240,12 @@ class EmbeddingsService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate limiting delay between batches
|
if (i + this.config.batchSize < contents.length) {
|
||||||
if (i + this.batchSize < contents.length) {
|
await new Promise(resolve => setTimeout(resolve, this.config.batchDelay));
|
||||||
await new Promise(resolve => setTimeout(resolve, this.batchDelay));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EMBEDDINGS] Failed to process batch ${Math.ceil((i + 1) / this.batchSize)}:`, error);
|
console.error(`[EMBEDDINGS-SERVICE] Batch ${batchNumber} failed:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,12 +253,28 @@ class EmbeddingsService {
|
|||||||
await this.saveEmbeddings(version);
|
await this.saveEmbeddings(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async embedText(text: string): Promise<number[]> {
|
async embedText(text: string): Promise<number[]> {
|
||||||
// Re‑use the private batch helper to avoid auth duplication
|
if (!this.isInitialized) {
|
||||||
|
throw new Error('Embeddings service not available');
|
||||||
|
}
|
||||||
|
|
||||||
const [embedding] = await this.generateEmbeddingsBatch([text.toLowerCase()]);
|
const [embedding] = await this.generateEmbeddingsBatch([text.toLowerCase()]);
|
||||||
return embedding;
|
return embedding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForInitialization(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.initializationPromise) {
|
||||||
|
await this.initializationPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
private cosineSimilarity(a: number[], b: number[]): number {
|
private cosineSimilarity(a: number[], b: number[]): number {
|
||||||
let dotProduct = 0;
|
let dotProduct = 0;
|
||||||
let normA = 0;
|
let normA = 0;
|
||||||
@@ -211,57 +289,63 @@ class EmbeddingsService {
|
|||||||
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSimilar(query: string, maxResults: number = 30, threshold: number = 0.3): Promise<EmbeddingData[]> {
|
async findSimilar(query: string, maxResults: number = 30, threshold: number = 0.3): Promise<SimilarityResult[]> {
|
||||||
if (!this.enabled || !this.isInitialized || this.embeddings.length === 0) {
|
/*if (!this.config.enabled) {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Service disabled, returning empty results');
|
||||||
|
return [];
|
||||||
|
}*/
|
||||||
|
|
||||||
|
if (!this.isInitialized || this.embeddings.length === 0) {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Not initialized or no embeddings available');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate embedding for query
|
console.log(`[EMBEDDINGS-SERVICE] Finding similar items for query: "${query}"`);
|
||||||
|
|
||||||
const queryEmbeddings = await this.generateEmbeddingsBatch([query.toLowerCase()]);
|
const queryEmbeddings = await this.generateEmbeddingsBatch([query.toLowerCase()]);
|
||||||
const queryEmbedding = queryEmbeddings[0];
|
const queryEmbedding = queryEmbeddings[0];
|
||||||
|
|
||||||
// Calculate similarities
|
const similarities: SimilarityResult[] = this.embeddings.map(item => ({
|
||||||
const similarities = this.embeddings.map(item => ({
|
|
||||||
...item,
|
...item,
|
||||||
similarity: this.cosineSimilarity(queryEmbedding, item.embedding)
|
similarity: this.cosineSimilarity(queryEmbedding, item.embedding)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter by threshold and sort by similarity
|
const topScore = Math.max(...similarities.map(s => s.similarity));
|
||||||
return similarities
|
const dynamicThreshold = Math.max(threshold, topScore * 0.85);
|
||||||
.filter(item => item.similarity >= threshold)
|
|
||||||
|
const results = similarities
|
||||||
|
.filter(item => item.similarity >= dynamicThreshold)
|
||||||
.sort((a, b) => b.similarity - a.similarity)
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
.slice(0, maxResults);
|
.slice(0, maxResults);
|
||||||
|
|
||||||
|
console.log(`[EMBEDDINGS-SERVICE] Found ${results.length} similar items (threshold: ${dynamicThreshold.toFixed(3)})`);
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
console.log('[EMBEDDINGS-SERVICE] Top 5 matches:');
|
||||||
|
results.slice(0, 5).forEach((item, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${item.name} (${item.type}) = ${item.similarity.toFixed(4)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EMBEDDINGS] Failed to find similar items:', error);
|
console.error('[EMBEDDINGS-SERVICE] Similarity search failed:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(): boolean {
|
getStats(): {initialized: boolean; count: number } {
|
||||||
return this.enabled && this.isInitialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStats(): { enabled: boolean; initialized: boolean; count: number } {
|
|
||||||
return {
|
return {
|
||||||
enabled: this.enabled,
|
|
||||||
initialized: this.isInitialized,
|
initialized: this.isInitialized,
|
||||||
count: this.embeddings.length
|
count: this.embeddings.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConfig(): EmbeddingsConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const embeddingsService = new EmbeddingsService();
|
||||||
|
|
||||||
// Global instance
|
|
||||||
const embeddingsService = new EmbeddingsService();
|
|
||||||
|
|
||||||
export { embeddingsService, type EmbeddingData };
|
|
||||||
|
|
||||||
// Auto-initialize on import in server environment
|
|
||||||
if (typeof window === 'undefined' && process.env.NODE_ENV !== 'test') {
|
|
||||||
embeddingsService.initialize().catch(error => {
|
|
||||||
console.error('[EMBEDDINGS] Auto-initialization failed:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,23 @@ export interface ContributionData {
|
|||||||
type: 'add' | 'edit';
|
type: 'add' | 'edit';
|
||||||
tool: {
|
tool: {
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string | null;
|
||||||
type: 'software' | 'method' | 'concept';
|
type: 'software' | 'method' | 'concept';
|
||||||
description: string;
|
description: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
phases: string[];
|
phases: string[];
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
skillLevel: string;
|
skillLevel: string;
|
||||||
accessType?: string;
|
accessType?: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
projectUrl?: string;
|
projectUrl?: string | null;
|
||||||
license?: string;
|
license?: string | null;
|
||||||
knowledgebase?: boolean;
|
knowledgebase?: boolean | null;
|
||||||
'domain-agnostic-software'?: string[];
|
'domain-agnostic-software'?: string[] | null;
|
||||||
related_concepts?: string[];
|
related_concepts?: string[] | null;
|
||||||
|
related_software?: string[] | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
statusUrl?: string;
|
statusUrl?: string | null;
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
submitter: string;
|
submitter: string;
|
||||||
@@ -134,6 +135,7 @@ export class GitContributionManager {
|
|||||||
if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
|
if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
|
||||||
if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
|
if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
|
||||||
if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
|
if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
|
||||||
|
if (tool.related_software?.length) cleanTool.related_software = tool.related_software;
|
||||||
if (tool.tags?.length) cleanTool.tags = tool.tags;
|
if (tool.tags?.length) cleanTool.tags = tool.tags;
|
||||||
if (tool['domain-agnostic-software']?.length) {
|
if (tool['domain-agnostic-software']?.length) {
|
||||||
cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
|
cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
|
||||||
@@ -272,6 +274,8 @@ ${data.tool.platforms?.length ? `- **Platforms:** ${data.tool.platforms.join(',
|
|||||||
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
|
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
|
||||||
${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
|
${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
|
||||||
${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
|
${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
|
||||||
|
${data.tool.related_concepts?.length ? `- **Related Concepts:** ${data.tool.related_concepts.join(', ')}` : ''}
|
||||||
|
${data.tool.related_software?.length ? `- **Related Software:** ${data.tool.related_software.join(', ')}` : ''}
|
||||||
|
|
||||||
${data.metadata.reason ? `### Reason
|
${data.metadata.reason ? `### Reason
|
||||||
${data.metadata.reason}
|
${data.metadata.reason}
|
||||||
@@ -299,9 +303,6 @@ ${data.metadata.contact}
|
|||||||
private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string {
|
private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Header */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`);
|
sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`);
|
||||||
sections.push('');
|
sections.push('');
|
||||||
sections.push(`**Submitted by:** ${data.submitter}`);
|
sections.push(`**Submitted by:** ${data.submitter}`);
|
||||||
@@ -310,18 +311,12 @@ ${data.metadata.contact}
|
|||||||
if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`);
|
if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`);
|
||||||
sections.push('');
|
sections.push('');
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Description */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
sections.push('### Description');
|
sections.push('### Description');
|
||||||
sections.push(data.description);
|
sections.push(data.description);
|
||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Content */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
sections.push('### Article Content');
|
sections.push('### Article Content');
|
||||||
sections.push('```markdown');
|
sections.push('```markdown');
|
||||||
@@ -330,18 +325,12 @@ ${data.metadata.contact}
|
|||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* External resources */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
if (data.externalLink) {
|
if (data.externalLink) {
|
||||||
sections.push('### External Resource');
|
sections.push('### External Resource');
|
||||||
sections.push(`- [External Documentation](${data.externalLink})`);
|
sections.push(`- [External Documentation](${data.externalLink})`);
|
||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Uploaded files */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) {
|
if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) {
|
||||||
sections.push('### Uploaded Files');
|
sections.push('### Uploaded Files');
|
||||||
data.uploadedFiles.forEach((file) => {
|
data.uploadedFiles.forEach((file) => {
|
||||||
@@ -359,9 +348,6 @@ ${data.metadata.contact}
|
|||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Categories & Tags */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
|
const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
|
||||||
const hasTags = Array.isArray(data.tags) && data.tags.length > 0;
|
const hasTags = Array.isArray(data.tags) && data.tags.length > 0;
|
||||||
|
|
||||||
@@ -372,18 +358,12 @@ ${data.metadata.contact}
|
|||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Reason */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
if (data.reason) {
|
if (data.reason) {
|
||||||
sections.push('### Reason for Contribution');
|
sections.push('### Reason for Contribution');
|
||||||
sections.push(data.reason);
|
sections.push(data.reason);
|
||||||
sections.push('');
|
sections.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Footer */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
sections.push('### For Maintainers');
|
sections.push('### For Maintainers');
|
||||||
sections.push('1. Review the content for quality and accuracy');
|
sections.push('1. Review the content for quality and accuracy');
|
||||||
sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`');
|
sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`');
|
||||||
@@ -395,4 +375,4 @@ ${data.metadata.contact}
|
|||||||
|
|
||||||
return sections.join('\n');
|
return sections.join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
20
src/utils/hashUtils.ts
Normal file
20
src/utils/hashUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// src/utils/hashUtils.ts
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export async function getToolsFileHash(): Promise<string> {
|
||||||
|
const file = path.join(process.cwd(), 'src', 'data', 'tools.yaml');
|
||||||
|
const raw = await fs.readFile(file, 'utf8');
|
||||||
|
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolsFileHashSync(): string | null {
|
||||||
|
try {
|
||||||
|
const file = path.join(process.cwd(), 'src', 'data', 'tools.yaml');
|
||||||
|
const raw = require('fs').readFileSync(file, 'utf8');
|
||||||
|
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
356
src/utils/jsonUtils.ts
Normal file
356
src/utils/jsonUtils.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
// src/utils/jsonUtils.ts
|
||||||
|
export class JSONParser {
|
||||||
|
static safeParseJSON(jsonString: string, fallback: any = null): any {
|
||||||
|
try {
|
||||||
|
let cleaned = jsonString.trim();
|
||||||
|
|
||||||
|
const jsonBlockPatterns = [
|
||||||
|
/```json\s*([\s\S]*?)\s*```/i,
|
||||||
|
/```\s*([\s\S]*?)\s*```/i,
|
||||||
|
/\{[\s\S]*\}/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of jsonBlockPatterns) {
|
||||||
|
const match = cleaned.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
cleaned = match[1] || match[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cleaned.endsWith('}') && !cleaned.endsWith(']')) {
|
||||||
|
console.warn('[JSON-PARSER] JSON appears truncated, attempting recovery');
|
||||||
|
cleaned = this.repairTruncatedJSON(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
if (!parsed.selectedTools) parsed.selectedTools = [];
|
||||||
|
if (!parsed.selectedConcepts) parsed.selectedConcepts = [];
|
||||||
|
if (!Array.isArray(parsed.selectedTools)) parsed.selectedTools = [];
|
||||||
|
if (!Array.isArray(parsed.selectedConcepts)) parsed.selectedConcepts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[JSON-PARSER] JSON parsing failed:', error.message);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static repairTruncatedJSON(cleaned: string): string {
|
||||||
|
let braceCount = 0;
|
||||||
|
let bracketCount = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
let lastCompleteStructure = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < cleaned.length; i++) {
|
||||||
|
const char = cleaned[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"' && !escaped) {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString) {
|
||||||
|
if (char === '{') braceCount++;
|
||||||
|
if (char === '}') braceCount--;
|
||||||
|
if (char === '[') bracketCount++;
|
||||||
|
if (char === ']') bracketCount--;
|
||||||
|
|
||||||
|
if (braceCount === 0 && bracketCount === 0 && (char === '}' || char === ']')) {
|
||||||
|
lastCompleteStructure = cleaned.substring(0, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCompleteStructure) {
|
||||||
|
return lastCompleteStructure;
|
||||||
|
} else {
|
||||||
|
if (braceCount > 0) cleaned += '}';
|
||||||
|
if (bracketCount > 0) cleaned += ']';
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static extractToolsFromMalformedJSON(jsonString: string): { selectedTools: string[]; selectedConcepts: string[] } {
|
||||||
|
const selectedTools: string[] = [];
|
||||||
|
const selectedConcepts: string[] = [];
|
||||||
|
|
||||||
|
const toolsMatch = jsonString.match(/"selectedTools"\s*:\s*\[([\s\S]*?)\]/i);
|
||||||
|
if (toolsMatch) {
|
||||||
|
const toolMatches = toolsMatch[1].match(/"([^"]+)"/g);
|
||||||
|
if (toolMatches) {
|
||||||
|
selectedTools.push(...toolMatches.map(match => match.replace(/"/g, '')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conceptsMatch = jsonString.match(/"selectedConcepts"\s*:\s*\[([\s\S]*?)\]/i);
|
||||||
|
if (conceptsMatch) {
|
||||||
|
const conceptMatches = conceptsMatch[1].match(/"([^"]+)"/g);
|
||||||
|
if (conceptMatches) {
|
||||||
|
selectedConcepts.push(...conceptMatches.map(match => match.replace(/"/g, '')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTools.length === 0 && selectedConcepts.length === 0) {
|
||||||
|
const allMatches = jsonString.match(/"([^"]+)"/g);
|
||||||
|
if (allMatches) {
|
||||||
|
const possibleNames = allMatches
|
||||||
|
.map(match => match.replace(/"/g, ''))
|
||||||
|
.filter(name =>
|
||||||
|
name.length > 2 &&
|
||||||
|
!['selectedTools', 'selectedConcepts', 'reasoning'].includes(name) &&
|
||||||
|
!name.includes(':') &&
|
||||||
|
!name.match(/^\d+$/)
|
||||||
|
)
|
||||||
|
.slice(0, 15);
|
||||||
|
|
||||||
|
selectedTools.push(...possibleNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selectedTools, selectedConcepts };
|
||||||
|
}
|
||||||
|
|
||||||
|
static secureParseJSON(jsonString: string, maxSize: number = 10 * 1024 * 1024): any {
|
||||||
|
if (typeof jsonString !== 'string') {
|
||||||
|
throw new Error('Input must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString.length > maxSize) {
|
||||||
|
throw new Error(`JSON string too large (${jsonString.length} bytes, max ${maxSize})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suspiciousPatterns = [
|
||||||
|
/<script/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/eval\(/i,
|
||||||
|
/function\s*\(/i,
|
||||||
|
/__proto__/i,
|
||||||
|
/constructor/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of suspiciousPatterns) {
|
||||||
|
if (pattern.test(jsonString)) {
|
||||||
|
throw new Error('Potentially malicious content detected in JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
throw new Error('JSON must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error(`Invalid JSON syntax: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static sanitizeForAudit(obj: any, maxDepth: number = 5, currentDepth: number = 0): any {
|
||||||
|
if (currentDepth >= maxDepth) {
|
||||||
|
return '[Max depth reached]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
if (obj.length > 500) {
|
||||||
|
return obj.slice(0, 500) + '...[truncated]';
|
||||||
|
}
|
||||||
|
return obj.replace(/<script[\s\S]*?<\/script>/gi, '[script removed]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'number' || typeof obj === 'boolean') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
if (obj.length > 20) {
|
||||||
|
return [
|
||||||
|
...obj.slice(0, 20).map(item => this.sanitizeForAudit(item, maxDepth, currentDepth + 1)),
|
||||||
|
`...[${obj.length - 20} more items]`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return obj.map(item => this.sanitizeForAudit(item, maxDepth, currentDepth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
if (keys.length > 50) {
|
||||||
|
const sanitized: any = {};
|
||||||
|
keys.slice(0, 50).forEach(key => {
|
||||||
|
sanitized[key] = this.sanitizeForAudit(obj[key], maxDepth, currentDepth + 1);
|
||||||
|
});
|
||||||
|
sanitized['[truncated]'] = `${keys.length - 50} more properties`;
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: any = {};
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitized[key] = this.sanitizeForAudit(obj[key], maxDepth, currentDepth + 1);
|
||||||
|
});
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateAuditExportStructure(data: any): { isValid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
errors.push('Export data must be an object');
|
||||||
|
return { isValid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredProps = ['metadata', 'recommendation', 'auditTrail'];
|
||||||
|
for (const prop of requiredProps) {
|
||||||
|
if (!(prop in data)) {
|
||||||
|
errors.push(`Missing required property: ${prop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metadata && typeof data.metadata === 'object') {
|
||||||
|
const requiredMetadataProps = ['timestamp', 'version', 'userQuery', 'mode'];
|
||||||
|
for (const prop of requiredMetadataProps) {
|
||||||
|
if (!(prop in data.metadata)) {
|
||||||
|
errors.push(`Missing required metadata property: ${prop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push('Invalid metadata structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.auditTrail)) {
|
||||||
|
errors.push('auditTrail must be an array');
|
||||||
|
} else {
|
||||||
|
data.auditTrail.forEach((entry: any, index: number) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
errors.push(`Audit entry ${index} is not a valid object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEntryProps = ['timestamp', 'phase', 'action', 'confidence', 'processingTimeMs'];
|
||||||
|
for (const prop of requiredEntryProps) {
|
||||||
|
if (!(prop in entry)) {
|
||||||
|
errors.push(`Audit entry ${index} missing required property: ${prop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static prepareAuditExport(
|
||||||
|
recommendation: any,
|
||||||
|
userQuery: string,
|
||||||
|
mode: string,
|
||||||
|
auditTrail: any[] = [],
|
||||||
|
additionalMetadata: any = {}
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: "1.0",
|
||||||
|
userQuery: userQuery.slice(0, 1000),
|
||||||
|
mode,
|
||||||
|
exportedBy: 'ForensicPathways',
|
||||||
|
toolsDataHash: additionalMetadata.toolsDataHash || 'unknown',
|
||||||
|
aiModel: additionalMetadata.aiModel || 'unknown',
|
||||||
|
aiParameters: additionalMetadata.aiParameters || {},
|
||||||
|
processingStats: additionalMetadata.processingStats || {}
|
||||||
|
},
|
||||||
|
recommendation: this.sanitizeForAudit(recommendation, 6),
|
||||||
|
auditTrail: auditTrail.map(entry => this.sanitizeForAudit(entry, 4)),
|
||||||
|
rawContext: {
|
||||||
|
selectedTools: additionalMetadata.selectedTools || [],
|
||||||
|
backgroundKnowledge: additionalMetadata.backgroundKnowledge || [],
|
||||||
|
contextHistory: additionalMetadata.contextHistory || [],
|
||||||
|
embeddingsSimilarities: additionalMetadata.embeddingsSimilarities || {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateUploadedAnalysis(data: any): { isValid: boolean; issues: string[]; warnings: string[] } {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const structureValidation = this.validateAuditExportStructure(data);
|
||||||
|
if (!structureValidation.isValid) {
|
||||||
|
issues.push(...structureValidation.errors);
|
||||||
|
return { isValid: false, issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metadata) {
|
||||||
|
const timestamp = new Date(data.metadata.timestamp);
|
||||||
|
if (isNaN(timestamp.getTime())) {
|
||||||
|
warnings.push('Invalid timestamp in metadata');
|
||||||
|
} else {
|
||||||
|
const age = Date.now() - timestamp.getTime();
|
||||||
|
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
if (age > maxAge) {
|
||||||
|
warnings.push(`Analysis is ${Math.floor(age / (24 * 60 * 60 * 1000))} days old`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['workflow', 'tool'].includes(data.metadata.mode)) {
|
||||||
|
warnings.push(`Unknown analysis mode: ${data.metadata.mode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.auditTrail)) {
|
||||||
|
const aiDecisions = data.auditTrail.filter(e => e.action === 'ai-decision').length;
|
||||||
|
const toolSelections = data.auditTrail.filter(e => e.action === 'selection-decision').length;
|
||||||
|
|
||||||
|
if (aiDecisions === 0) {
|
||||||
|
warnings.push('No AI decisions found in audit trail');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolSelections === 0) {
|
||||||
|
warnings.push('No tool selections found in audit trail');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entriesWithConfidence = data.auditTrail.filter(e => typeof e.confidence === 'number').length;
|
||||||
|
const confidenceRatio = entriesWithConfidence / data.auditTrail.length;
|
||||||
|
|
||||||
|
if (confidenceRatio < 0.8) {
|
||||||
|
warnings.push(`Only ${Math.round(confidenceRatio * 100)}% of audit entries have confidence scores`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// src/utils/nextcloud.ts
|
// src/utils/nextcloud.ts
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
@@ -338,7 +337,7 @@ export class NextcloudUploader {
|
|||||||
info: {
|
info: {
|
||||||
path: remotePath,
|
path: remotePath,
|
||||||
exists: true,
|
exists: true,
|
||||||
response: text.substring(0, 200) + '...' // Truncated for safety
|
response: text.substring(0, 200) + '...'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// src/utils/rateLimitedQueue.ts - FIXED: Memory leak and better cleanup
|
// src/utils/rateLimitedQueue.ts
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
const RATE_LIMIT_DELAY_MS =
|
||||||
|
Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
||||||
|
|
||||||
|
const TASK_TIMEOUT_MS =
|
||||||
|
Number.parseInt(process.env.AI_TASK_TIMEOUT_MS ?? "300000", 10) || 300000;
|
||||||
|
|
||||||
export type Task<T = unknown> = () => Promise<T>;
|
export type Task<T = unknown> = () => Promise<T>;
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ interface QueuedTask {
|
|||||||
id: string;
|
id: string;
|
||||||
task: Task;
|
task: Task;
|
||||||
addedAt: number;
|
addedAt: number;
|
||||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
status: "queued" | "processing" | "completed" | "failed" | "timedout";
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
}
|
}
|
||||||
@@ -29,11 +32,12 @@ class RateLimitedQueue {
|
|||||||
private tasks: QueuedTask[] = [];
|
private tasks: QueuedTask[] = [];
|
||||||
private isProcessing = false;
|
private isProcessing = false;
|
||||||
private delayMs = RATE_LIMIT_DELAY_MS;
|
private delayMs = RATE_LIMIT_DELAY_MS;
|
||||||
|
private taskTimeoutMs = TASK_TIMEOUT_MS;
|
||||||
private lastProcessedAt = 0;
|
private lastProcessedAt = 0;
|
||||||
private currentlyProcessingTaskId: string | null = null;
|
private currentlyProcessingTaskId: string | null = null;
|
||||||
|
|
||||||
private cleanupInterval: NodeJS.Timeout;
|
private cleanupInterval: NodeJS.Timeout;
|
||||||
private readonly TASK_RETENTION_MS = 30000;
|
private readonly TASK_RETENTION_MS = 300000; // 5 minutes
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
@@ -44,19 +48,19 @@ class RateLimitedQueue {
|
|||||||
private cleanupOldTasks(): void {
|
private cleanupOldTasks(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const initialLength = this.tasks.length;
|
const initialLength = this.tasks.length;
|
||||||
|
|
||||||
this.tasks = this.tasks.filter(task => {
|
this.tasks = this.tasks.filter((task) => {
|
||||||
if (task.status === 'queued' || task.status === 'processing') {
|
if (task.status === "queued" || task.status === "processing") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.completedAt && (now - task.completedAt) > this.TASK_RETENTION_MS) {
|
if (task.completedAt && now - task.completedAt > this.TASK_RETENTION_MS) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleaned = initialLength - this.tasks.length;
|
const cleaned = initialLength - this.tasks.length;
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) {
|
||||||
console.log(`[QUEUE] Cleaned up ${cleaned} old tasks, ${this.tasks.length} remaining`);
|
console.log(`[QUEUE] Cleaned up ${cleaned} old tasks, ${this.tasks.length} remaining`);
|
||||||
@@ -86,11 +90,11 @@ class RateLimitedQueue {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
status: 'queued'
|
status: "queued",
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tasks.push(queuedTask);
|
this.tasks.push(queuedTask);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -98,12 +102,12 @@ class RateLimitedQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getStatus(taskId?: string): QueueStatus {
|
getStatus(taskId?: string): QueueStatus {
|
||||||
const queuedTasks = this.tasks.filter(t => t.status === 'queued');
|
const queuedTasks = this.tasks.filter((t) => t.status === "queued");
|
||||||
const processingTasks = this.tasks.filter(t => t.status === 'processing');
|
const processingTasks = this.tasks.filter((t) => t.status === "processing");
|
||||||
const queueLength = queuedTasks.length + processingTasks.length;
|
const queueLength = queuedTasks.length + processingTasks.length;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
let estimatedWaitTime = 0;
|
let estimatedWaitTime = 0;
|
||||||
if (queueLength > 0) {
|
if (queueLength > 0) {
|
||||||
if (this.isProcessing && this.lastProcessedAt > 0) {
|
if (this.isProcessing && this.lastProcessedAt > 0) {
|
||||||
@@ -118,38 +122,34 @@ class RateLimitedQueue {
|
|||||||
const status: QueueStatus = {
|
const status: QueueStatus = {
|
||||||
queueLength,
|
queueLength,
|
||||||
isProcessing: this.isProcessing,
|
isProcessing: this.isProcessing,
|
||||||
estimatedWaitTime
|
estimatedWaitTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find((t) => t.id === taskId);
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
status.taskStatus = task.status;
|
status.taskStatus = task.status;
|
||||||
|
|
||||||
if (task.status === 'processing') {
|
if (task.status === "processing") {
|
||||||
status.currentPosition = 1;
|
status.currentPosition = 1;
|
||||||
} else if (task.status === 'queued') {
|
} else if (task.status === "queued") {
|
||||||
const queuedTasksInOrder = this.tasks
|
const queuedTasksInOrder = this.tasks
|
||||||
.filter(t => t.status === 'queued')
|
.filter((t) => t.status === "queued")
|
||||||
.sort((a, b) => a.addedAt - b.addedAt);
|
.sort((a, b) => a.addedAt - b.addedAt);
|
||||||
|
|
||||||
const positionInQueue = queuedTasksInOrder.findIndex(t => t.id === taskId);
|
const positionInQueue = queuedTasksInOrder.findIndex((t) => t.id === taskId);
|
||||||
|
|
||||||
if (positionInQueue >= 0) {
|
if (positionInQueue >= 0) {
|
||||||
const processingOffset = processingTasks.length > 0 ? 1 : 0;
|
const processingOffset = processingTasks.length > 0 ? 1 : 0;
|
||||||
status.currentPosition = processingOffset + positionInQueue + 1;
|
status.currentPosition = processingOffset + positionInQueue + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const taskTimestamp = taskId.match(/ai_(\d+)_/)?.[1];
|
const taskTimestamp = taskId.match(/ai_(\d+)_/)?.[1];
|
||||||
if (taskTimestamp) {
|
if (taskTimestamp) {
|
||||||
const taskAge = now - parseInt(taskTimestamp);
|
const taskAge = now - parseInt(taskTimestamp);
|
||||||
if (taskAge < 30000) {
|
status.taskStatus = taskAge < 300000 ? "starting" : "unknown";
|
||||||
status.taskStatus = 'starting';
|
|
||||||
} else {
|
|
||||||
status.taskStatus = 'unknown';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,51 +157,48 @@ class RateLimitedQueue {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDelay(ms: number): void {
|
|
||||||
if (!Number.isFinite(ms) || ms < 0) return;
|
|
||||||
this.delayMs = ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDelay(): number {
|
|
||||||
return this.delayMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processQueue(): Promise<void> {
|
private async processQueue(): Promise<void> {
|
||||||
if (this.isProcessing) {
|
if (this.isProcessing) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const nextTask = this.tasks
|
const nextTask = this.tasks
|
||||||
.filter(t => t.status === 'queued')
|
.filter((t) => t.status === "queued")
|
||||||
.sort((a, b) => a.addedAt - b.addedAt)[0];
|
.sort((a, b) => a.addedAt - b.addedAt)[0];
|
||||||
|
|
||||||
if (!nextTask) {
|
if (!nextTask) break;
|
||||||
break;
|
|
||||||
}
|
nextTask.status = "processing";
|
||||||
|
|
||||||
nextTask.status = 'processing';
|
|
||||||
nextTask.startedAt = Date.now();
|
nextTask.startedAt = Date.now();
|
||||||
this.currentlyProcessingTaskId = nextTask.id;
|
this.currentlyProcessingTaskId = nextTask.id;
|
||||||
this.lastProcessedAt = Date.now();
|
this.lastProcessedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await nextTask.task();
|
await Promise.race([
|
||||||
nextTask.status = 'completed';
|
nextTask.task(),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`Task ${nextTask.id} timed out after ${this.taskTimeoutMs} ms`)),
|
||||||
|
this.taskTimeoutMs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
nextTask.status = "completed";
|
||||||
nextTask.completedAt = Date.now();
|
nextTask.completedAt = Date.now();
|
||||||
console.log(`[QUEUE] Task ${nextTask.id} completed`);
|
console.log(`[QUEUE] Task ${nextTask.id} completed`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
nextTask.status = 'failed';
|
const err = error as Error;
|
||||||
|
nextTask.status = err.message.includes("timed out") ? "timedout" : "failed";
|
||||||
nextTask.completedAt = Date.now();
|
nextTask.completedAt = Date.now();
|
||||||
console.error(`[QUEUE] Task ${nextTask.id} failed:`, error);
|
console.error(`[QUEUE] Task ${nextTask.id} failed:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentlyProcessingTaskId = null;
|
this.currentlyProcessingTaskId = null;
|
||||||
|
|
||||||
const hasMoreQueued = this.tasks.some(t => t.status === 'queued');
|
const hasMoreQueued = this.tasks.some((t) => t.status === "queued");
|
||||||
if (hasMoreQueued) {
|
if (hasMoreQueued) {
|
||||||
console.log(`[QUEUE] Waiting ${this.delayMs}ms before next task`);
|
console.log(`[QUEUE] Waiting ${this.delayMs}ms before next task`);
|
||||||
await new Promise((r) => setTimeout(r, this.delayMs));
|
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||||
@@ -232,4 +229,4 @@ export function shutdownQueue(): void {
|
|||||||
queue.shutdown();
|
queue.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default queue;
|
export default queue;
|
||||||
|
|||||||
83
src/utils/remarkVideoPlugin.ts
Normal file
83
src/utils/remarkVideoPlugin.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// src/utils/remarkVideoPlugin.ts
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
import type { Plugin } from 'unified';
|
||||||
|
import type { Root } from 'hast';
|
||||||
|
|
||||||
|
function escapeHtml(unsafe: string): string {
|
||||||
|
if (typeof unsafe !== 'string') return '';
|
||||||
|
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remarkVideoPlugin: Plugin<[], Root> = () => {
|
||||||
|
return (tree: Root) => {
|
||||||
|
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
|
||||||
|
if (node.value && node.value.includes('<video') && typeof index === 'number') {
|
||||||
|
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
|
||||||
|
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
|
||||||
|
|
||||||
|
if (srcMatch) {
|
||||||
|
const originalSrc = srcMatch[1];
|
||||||
|
const title = titleMatch?.[1] || 'Video';
|
||||||
|
|
||||||
|
const hasControls = node.value.includes('controls');
|
||||||
|
const hasAutoplay = node.value.includes('autoplay');
|
||||||
|
const hasMuted = node.value.includes('muted');
|
||||||
|
const hasLoop = node.value.includes('loop');
|
||||||
|
const preloadMatch = node.value.match(/preload=["']([^"']+)["']/);
|
||||||
|
|
||||||
|
const enhancedHTML = `
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
src="${escapeHtml(originalSrc)}"
|
||||||
|
${hasControls ? 'controls' : ''}
|
||||||
|
${hasAutoplay ? 'autoplay' : ''}
|
||||||
|
${hasMuted ? 'muted' : ''}
|
||||||
|
${hasLoop ? 'loop' : ''}
|
||||||
|
${preloadMatch ? `preload="${preloadMatch[1]}"` : 'preload="metadata"'}
|
||||||
|
data-video-title="${escapeHtml(title)}"
|
||||||
|
>
|
||||||
|
<p>Your browser does not support the video element.</p>
|
||||||
|
</video>
|
||||||
|
${title !== 'Video' ? `
|
||||||
|
<div class="video-metadata">
|
||||||
|
<div class="video-title">${escapeHtml(title)}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
parent.children[index] = { type: 'html', value: enhancedHTML };
|
||||||
|
console.log(`[VIDEO] Enhanced: ${title} (${originalSrc})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.value && node.value.includes('<iframe') && typeof index === 'number' && parent) {
|
||||||
|
|
||||||
|
if (node.value.includes('video-container')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
|
||||||
|
const title = titleMatch?.[1] || 'Embedded Video';
|
||||||
|
|
||||||
|
const enhancedHTML = `
|
||||||
|
<div class="video-container">
|
||||||
|
${node.value}
|
||||||
|
</div>
|
||||||
|
<div class="video-metadata">
|
||||||
|
<div class="video-title">${escapeHtml(title)}</div>
|
||||||
|
</div>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
parent.children[index] = { type: 'html', value: enhancedHTML };
|
||||||
|
console.log(`[VIDEO] Enhanced iframe: ${title}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* CONSOLIDATED Tool utility functions for consistent tool operations across the app
|
|
||||||
* Works in both server (Node.js) and client (browser) environments
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Tool {
|
|
||||||
name: string;
|
|
||||||
type?: 'software' | 'method' | 'concept';
|
|
||||||
projectUrl?: string | null;
|
|
||||||
license?: string;
|
|
||||||
knowledgebase?: boolean;
|
|
||||||
domains?: string[];
|
|
||||||
phases?: string[];
|
|
||||||
platforms?: string[];
|
|
||||||
skillLevel?: string;
|
|
||||||
description?: string;
|
|
||||||
tags?: string[];
|
|
||||||
related_concepts?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a URL-safe slug from a tool name
|
|
||||||
* Used for URLs, IDs, and file names consistently across the app
|
|
||||||
*/
|
|
||||||
export function createToolSlug(toolName: string): string {
|
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
|
||||||
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return toolName.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a tool by name or slug from tools array
|
|
||||||
*/
|
|
||||||
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
|
|
||||||
if (!identifier || !Array.isArray(tools)) return undefined;
|
|
||||||
|
|
||||||
return tools.find(tool =>
|
|
||||||
tool.name === identifier ||
|
|
||||||
createToolSlug(tool.name) === identifier.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if tool has a valid project URL (hosted on CC24 server)
|
|
||||||
*/
|
|
||||||
export function isToolHosted(tool: Tool): boolean {
|
|
||||||
return tool.projectUrl !== undefined &&
|
|
||||||
tool.projectUrl !== null &&
|
|
||||||
tool.projectUrl !== "" &&
|
|
||||||
tool.projectUrl.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines tool category for styling/logic
|
|
||||||
*/
|
|
||||||
export function getToolCategory(tool: Tool): 'concept' | 'method' | 'hosted' | 'oss' | 'proprietary' {
|
|
||||||
if (tool.type === 'concept') return 'concept';
|
|
||||||
if (tool.type === 'method') return 'method';
|
|
||||||
if (isToolHosted(tool)) return 'hosted';
|
|
||||||
if (tool.license && tool.license !== 'Proprietary') return 'oss';
|
|
||||||
return 'proprietary';
|
|
||||||
}
|
|
||||||
372
src/utils/toolSelector.ts
Normal file
372
src/utils/toolSelector.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
// src/utils/toolSelector.ts
|
||||||
|
import { aiService } from './aiService.js';
|
||||||
|
import { embeddingsService, type SimilarityResult } from './embeddings.js';
|
||||||
|
import { confidenceScoring } from './confidenceScoring.js';
|
||||||
|
import { JSONParser } from './jsonUtils.js';
|
||||||
|
import { getPrompt } from '../config/prompts.js';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export interface ToolSelectionConfig {
|
||||||
|
maxSelectedItems: number;
|
||||||
|
embeddingCandidates: number;
|
||||||
|
similarityThreshold: number;
|
||||||
|
embeddingSelectionLimit: number;
|
||||||
|
embeddingConceptsLimit: number;
|
||||||
|
embeddingsMinTools: number;
|
||||||
|
embeddingsMaxReductionRatio: number;
|
||||||
|
methodSelectionRatio: number;
|
||||||
|
softwareSelectionRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface SelectionContext {
|
||||||
|
userQuery: string;
|
||||||
|
mode: string;
|
||||||
|
embeddingsSimilarities: Map<string, number>;
|
||||||
|
seenToolNames: Set<string>;
|
||||||
|
selectedTools?: Array<{
|
||||||
|
tool: any;
|
||||||
|
phase: string;
|
||||||
|
priority: string;
|
||||||
|
justification?: string;
|
||||||
|
taskRelevance?: number;
|
||||||
|
limitations?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolSelectionResult {
|
||||||
|
selectedTools: any[];
|
||||||
|
selectedConcepts: any[];
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolSelector {
|
||||||
|
private config: ToolSelectionConfig;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = {
|
||||||
|
maxSelectedItems: this.getEnvInt('AI_MAX_SELECTED_ITEMS', 25),
|
||||||
|
embeddingCandidates: this.getEnvInt('AI_EMBEDDING_CANDIDATES', 50),
|
||||||
|
similarityThreshold: this.getEnvFloat('AI_SIMILARITY_THRESHOLD', 0.3),
|
||||||
|
embeddingSelectionLimit: this.getEnvInt('AI_EMBEDDING_SELECTION_LIMIT', 30),
|
||||||
|
embeddingConceptsLimit: this.getEnvInt('AI_EMBEDDING_CONCEPTS_LIMIT', 15),
|
||||||
|
embeddingsMinTools: this.getEnvInt('AI_EMBEDDINGS_MIN_TOOLS', 8),
|
||||||
|
embeddingsMaxReductionRatio: this.getEnvFloat('AI_EMBEDDINGS_MAX_REDUCTION_RATIO', 0.75),
|
||||||
|
methodSelectionRatio: this.getEnvFloat('AI_METHOD_SELECTION_RATIO', 0.4),
|
||||||
|
softwareSelectionRatio: this.getEnvFloat('AI_SOFTWARE_SELECTION_RATIO', 0.5),
|
||||||
|
};
|
||||||
|
console.log('[TOOL-SELECTOR] Initialized with config:', this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEnvInt(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseInt(value, 10) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEnvFloat(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseFloat(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntelligentCandidates(
|
||||||
|
userQuery: string,
|
||||||
|
toolsData: any,
|
||||||
|
mode: string,
|
||||||
|
context: SelectionContext
|
||||||
|
): Promise<{
|
||||||
|
tools: any[];
|
||||||
|
concepts: any[];
|
||||||
|
domains: any[];
|
||||||
|
phases: any[];
|
||||||
|
'domain-agnostic-software': any[];
|
||||||
|
}> {
|
||||||
|
console.log('[TOOL-SELECTOR] Getting intelligent candidates for query');
|
||||||
|
|
||||||
|
let candidateTools: any[] = [];
|
||||||
|
let candidateConcepts: any[] = [];
|
||||||
|
|
||||||
|
context.embeddingsSimilarities.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await embeddingsService.waitForInitialization();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TOOL-SELECTOR] Embeddings initialization failed:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] Using embeddings for candidate selection');
|
||||||
|
|
||||||
|
const embeddingsSearchStart = Date.now();
|
||||||
|
|
||||||
|
const similarItems = await embeddingsService.findSimilar(
|
||||||
|
userQuery,
|
||||||
|
this.config.embeddingCandidates,
|
||||||
|
this.config.similarityThreshold
|
||||||
|
) as SimilarityResult[];
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] Embeddings found', similarItems.length, 'similar items');
|
||||||
|
|
||||||
|
const { auditService } = await import('./auditService.js');
|
||||||
|
const { getDataVersion } = await import('./dataService.js');
|
||||||
|
|
||||||
|
const toolsDataHash = getDataVersion() || 'unknown';
|
||||||
|
|
||||||
|
auditService.addEmbeddingsSearch(
|
||||||
|
userQuery,
|
||||||
|
similarItems,
|
||||||
|
this.config.similarityThreshold,
|
||||||
|
embeddingsSearchStart,
|
||||||
|
{
|
||||||
|
toolsDataHash: toolsDataHash,
|
||||||
|
selectionPhase: 'initial-candidate-selection',
|
||||||
|
candidateLimit: this.config.embeddingCandidates,
|
||||||
|
mode: mode,
|
||||||
|
reasoning: `Initiale semantische Suche für ${mode}-Modus - Reduzierung der ${toolsData.tools.length} verfügbaren Tools auf ${similarItems.length} relevante Kandidaten`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
similarItems.forEach(item => {
|
||||||
|
context.embeddingsSimilarities.set(item.name, item.similarity);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolsMap = new Map(toolsData.tools.map((tool: any) => [tool.name, tool]));
|
||||||
|
const conceptsMap = new Map(toolsData.concepts.map((concept: any) => [concept.name, concept]));
|
||||||
|
|
||||||
|
const similarTools = similarItems
|
||||||
|
.filter((item: any) => item.type === 'tool')
|
||||||
|
.map((item: any) => toolsMap.get(item.name))
|
||||||
|
.filter((tool: any): tool is NonNullable<any> => tool !== undefined && tool !== null);
|
||||||
|
|
||||||
|
const similarConcepts = similarItems
|
||||||
|
.filter((item: any) => item.type === 'concept')
|
||||||
|
.map((item: any) => conceptsMap.get(item.name))
|
||||||
|
.filter((concept: any): concept is NonNullable<any> => concept !== undefined && concept !== null);
|
||||||
|
|
||||||
|
const totalAvailableTools = toolsData.tools.length;
|
||||||
|
const reductionRatio = similarTools.length / totalAvailableTools;
|
||||||
|
|
||||||
|
if (similarTools.length >= this.config.embeddingsMinTools && reductionRatio <= this.config.embeddingsMaxReductionRatio) {
|
||||||
|
candidateTools = similarTools;
|
||||||
|
candidateConcepts = similarConcepts;
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] Using embeddings filtering:', totalAvailableTools, '→', similarTools.length, 'tools');
|
||||||
|
} else {
|
||||||
|
console.log('[TOOL-SELECTOR] Embeddings filtering insufficient, using full dataset');
|
||||||
|
candidateTools = toolsData.tools;
|
||||||
|
candidateConcepts = toolsData.concepts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await this.performAISelection(
|
||||||
|
userQuery,
|
||||||
|
candidateTools,
|
||||||
|
candidateConcepts,
|
||||||
|
mode,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools: selection.selectedTools,
|
||||||
|
concepts: selection.selectedConcepts,
|
||||||
|
domains: toolsData.domains,
|
||||||
|
phases: toolsData.phases,
|
||||||
|
'domain-agnostic-software': toolsData['domain-agnostic-software']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performAISelection(
|
||||||
|
userQuery: string,
|
||||||
|
candidateTools: any[],
|
||||||
|
candidateConcepts: any[],
|
||||||
|
mode: string,
|
||||||
|
context: SelectionContext
|
||||||
|
): Promise<ToolSelectionResult> {
|
||||||
|
console.log('[TOOL-SELECTOR] Performing AI selection');
|
||||||
|
|
||||||
|
const candidateMethods = candidateTools.filter((t: any) => t && t.type === 'method');
|
||||||
|
const candidateSoftware = candidateTools.filter((t: any) => t && t.type === 'software');
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] Candidates:',
|
||||||
|
candidateMethods.length, 'methods,',
|
||||||
|
candidateSoftware.length, 'software,',
|
||||||
|
candidateConcepts.length, 'concepts'
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodsWithFullData = candidateMethods.map(this.createToolData);
|
||||||
|
const softwareWithFullData = candidateSoftware.map(this.createToolData);
|
||||||
|
const conceptsWithFullData = candidateConcepts.map(this.createConceptData);
|
||||||
|
|
||||||
|
const maxTools = Math.min(this.config.embeddingSelectionLimit, candidateTools.length);
|
||||||
|
const maxConcepts = Math.min(this.config.embeddingConceptsLimit, candidateConcepts.length);
|
||||||
|
|
||||||
|
const methodRatio = Math.max(0, Math.min(1, this.config.methodSelectionRatio));
|
||||||
|
const softwareRatio = Math.max(0, Math.min(1, this.config.softwareSelectionRatio));
|
||||||
|
|
||||||
|
let methodLimit = Math.round(maxTools * methodRatio);
|
||||||
|
let softwareLimit = Math.round(maxTools * softwareRatio);
|
||||||
|
|
||||||
|
if (methodLimit + softwareLimit > maxTools) {
|
||||||
|
const scale = maxTools / (methodLimit + softwareLimit);
|
||||||
|
methodLimit = Math.floor(methodLimit * scale);
|
||||||
|
softwareLimit = Math.floor(softwareLimit * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodsPrimary = methodsWithFullData.slice(0, methodLimit);
|
||||||
|
const softwarePrimary = softwareWithFullData.slice(0, softwareLimit);
|
||||||
|
|
||||||
|
const toolsToSend: any[] = [...methodsPrimary, ...softwarePrimary];
|
||||||
|
|
||||||
|
let mIdx = methodsPrimary.length;
|
||||||
|
let sIdx = softwarePrimary.length;
|
||||||
|
|
||||||
|
while (toolsToSend.length < maxTools && (mIdx < methodsWithFullData.length || sIdx < softwareWithFullData.length)) {
|
||||||
|
const remM = methodsWithFullData.length - mIdx;
|
||||||
|
const remS = softwareWithFullData.length - sIdx;
|
||||||
|
|
||||||
|
if (remS >= remM && sIdx < softwareWithFullData.length) {
|
||||||
|
toolsToSend.push(softwareWithFullData[sIdx++]);
|
||||||
|
} else if (mIdx < methodsWithFullData.length) {
|
||||||
|
toolsToSend.push(methodsWithFullData[mIdx++]);
|
||||||
|
} else if (sIdx < softwareWithFullData.length) {
|
||||||
|
toolsToSend.push(softwareWithFullData[sIdx++]);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conceptsToSend = conceptsWithFullData.slice(0, maxConcepts);
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR-DEBUG] maxTools:', maxTools, 'maxConcepts:', maxConcepts);
|
||||||
|
console.log('[TOOL-SELECTOR] Sending to AI:',
|
||||||
|
toolsToSend.filter((t: any) => t.type === 'method').length, 'methods,',
|
||||||
|
toolsToSend.filter((t: any) => t.type === 'software').length, 'software,',
|
||||||
|
conceptsToSend.length, 'concepts'
|
||||||
|
);
|
||||||
|
|
||||||
|
const basePrompt = getPrompt('toolSelection', mode, userQuery, this.config.maxSelectedItems);
|
||||||
|
const prompt = getPrompt('toolSelectionWithData', basePrompt, toolsToSend, conceptsToSend);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await aiService.callAI(prompt);
|
||||||
|
const result = JSONParser.safeParseJSON(response.content, null);
|
||||||
|
|
||||||
|
if (!result || !Array.isArray(result.selectedTools) || !Array.isArray(result.selectedConcepts)) {
|
||||||
|
console.error('[TOOL-SELECTOR] AI selection returned invalid structure');
|
||||||
|
throw new Error('AI selection failed to return valid tool and concept selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSelected = result.selectedTools.length + result.selectedConcepts.length;
|
||||||
|
if (totalSelected === 0) {
|
||||||
|
throw new Error('AI selection returned empty selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsMap = new Map(candidateTools.map((tool: any) => [tool.name, tool]));
|
||||||
|
const conceptsMap = new Map(candidateConcepts.map((concept: any) => [concept.name, concept]));
|
||||||
|
|
||||||
|
const selectedTools = result.selectedTools
|
||||||
|
.map((name: string) => toolsMap.get(name))
|
||||||
|
.filter((tool: any): tool is NonNullable<any> => tool !== undefined && tool !== null);
|
||||||
|
|
||||||
|
const selectedConcepts = result.selectedConcepts
|
||||||
|
.map((name: string) => conceptsMap.get(name))
|
||||||
|
.filter((concept: any): concept is NonNullable<any> => concept !== undefined && concept !== null);
|
||||||
|
|
||||||
|
const selectedMethods = selectedTools.filter((t: any) => t && t.type === 'method');
|
||||||
|
const selectedSoftware = selectedTools.filter((t: any) => t && t.type === 'software');
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] AI selected:',
|
||||||
|
selectedMethods.length, 'methods,',
|
||||||
|
selectedSoftware.length, 'software,',
|
||||||
|
selectedConcepts.length, 'concepts'
|
||||||
|
);
|
||||||
|
|
||||||
|
const confidence = confidenceScoring.calculateSelectionConfidence(
|
||||||
|
result,
|
||||||
|
candidateTools.length + candidateConcepts.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return { selectedTools, selectedConcepts, confidence };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TOOL-SELECTOR] AI selection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectToolsForPhase(
|
||||||
|
userQuery: string,
|
||||||
|
phase: any,
|
||||||
|
availableTools: any[],
|
||||||
|
context: SelectionContext
|
||||||
|
): Promise<Array<{
|
||||||
|
toolName: string;
|
||||||
|
taskRelevance: number;
|
||||||
|
justification: string;
|
||||||
|
limitations: string[];
|
||||||
|
}>> {
|
||||||
|
console.log('[TOOL-SELECTOR] Selecting tools for phase:', phase.id);
|
||||||
|
|
||||||
|
if (availableTools.length === 0) {
|
||||||
|
console.log('[TOOL-SELECTOR] No tools available for phase:', phase.id);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = getPrompt('phaseToolSelection', userQuery, phase, availableTools);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await aiService.callMicroTaskAI(prompt);
|
||||||
|
const selections = JSONParser.safeParseJSON(response.content, []);
|
||||||
|
|
||||||
|
if (Array.isArray(selections)) {
|
||||||
|
const validSelections = selections.filter((sel: any) => {
|
||||||
|
const matchingTool = availableTools.find((tool: any) => tool && tool.name === sel.toolName);
|
||||||
|
if (!matchingTool) {
|
||||||
|
console.warn('[TOOL-SELECTOR] Invalid tool selection for phase:', phase.id, sel.toolName);
|
||||||
|
}
|
||||||
|
return !!matchingTool;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[TOOL-SELECTOR] Valid selections for phase:', phase.id, validSelections.length);
|
||||||
|
return validSelections;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TOOL-SELECTOR] Phase tool selection failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createToolData = (tool: any) => ({
|
||||||
|
name: tool.name,
|
||||||
|
type: tool.type,
|
||||||
|
description: tool.description,
|
||||||
|
domains: tool.domains,
|
||||||
|
phases: tool.phases,
|
||||||
|
platforms: tool.platforms || [],
|
||||||
|
tags: tool.tags || [],
|
||||||
|
skillLevel: tool.skillLevel,
|
||||||
|
license: tool.license,
|
||||||
|
accessType: tool.accessType,
|
||||||
|
projectUrl: tool.projectUrl,
|
||||||
|
knowledgebase: tool.knowledgebase,
|
||||||
|
related_concepts: tool.related_concepts || [],
|
||||||
|
related_software: tool.related_software || []
|
||||||
|
});
|
||||||
|
|
||||||
|
private createConceptData = (concept: any) => ({
|
||||||
|
name: concept.name,
|
||||||
|
type: 'concept',
|
||||||
|
description: concept.description,
|
||||||
|
domains: concept.domains,
|
||||||
|
phases: concept.phases,
|
||||||
|
tags: concept.tags || [],
|
||||||
|
skillLevel: concept.skillLevel,
|
||||||
|
related_concepts: concept.related_concepts || [],
|
||||||
|
related_software: concept.related_software || []
|
||||||
|
});
|
||||||
|
|
||||||
|
getConfig(): ToolSelectionConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toolSelector = new ToolSelector();
|
||||||
2082
tools-yaml-editor.html
Normal file
2082
tools-yaml-editor.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user