diff --git a/backend/app.py b/backend/app.py
index d6c6d373..69c3ab80 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,7 +1,9 @@
import builtins
import io
+import json
import os
import sys
+import uuid
from typing import Any, Dict, Optional, Union
from flask import Flask, Response, jsonify, request, send_file, session
@@ -93,6 +95,10 @@ def emit_log_realtime(message: str, level: str = "info") -> None:
from tiny_scientist.reviewer import Reviewer # noqa: E402
from tiny_scientist.scientist import TinyScientist # noqa: E402
from tiny_scientist.thinker import Thinker # noqa: E402
+from tiny_scientist.utils.agent_sdk import ( # noqa: E402
+ resolve_agent_sdk,
+ validate_agent_sdk_model_combo,
+)
from tiny_scientist.writer import Writer # noqa: E402
@@ -141,6 +147,89 @@ def patch_module_print() -> None:
reviewer: Optional[Reviewer] = None
global_cost_tracker: Optional[BudgetChecker] = None
+global_idea_storage: Dict[str, Any] = {}
+
+# Hierarchical ID counters
+# root_counter: next number for root-level ideas (1, 2, 3, ...)
+# child_counters: {parent_id: next_child_number}
+# modify_counters: {parent_id: next_X_number}
+_root_counter: int = 0
+_child_counters: Dict[str, int] = {}
+_modify_counters: Dict[str, int] = {}
+
+
+def _next_root_id() -> str:
+ global _root_counter
+ _root_counter += 1
+ return str(_root_counter)
+
+
+def _next_child_id(parent_id: str) -> str:
+ if parent_id not in _child_counters:
+ _child_counters[parent_id] = 0
+ _child_counters[parent_id] += 1
+ return f"{parent_id}-{_child_counters[parent_id]}"
+
+
+def _next_modify_id(parent_id: str) -> str:
+ if parent_id not in _modify_counters:
+ _modify_counters[parent_id] = 0
+ _modify_counters[parent_id] += 1
+ return f"{parent_id}-X{_modify_counters[parent_id]}"
+
+
+def _merge_id(id_a: str, id_b: str) -> str:
+ return f"{id_a}-Y-{id_b}-Y"
+
+
+def _reset_id_counters() -> None:
+ global _root_counter, _child_counters, _modify_counters
+ _root_counter = 0
+ _child_counters = {}
+ _modify_counters = {}
+
+
+def _store_or_update_idea(idea: Dict[str, Any]) -> None:
+ iid = idea.get("id")
+ if not iid:
+ return
+ existing = global_idea_storage.get(iid, {})
+ merged = {**existing, **idea}
+ global_idea_storage[iid] = merged
+
+
+def _list_stored_ideas() -> list[Dict[str, Any]]:
+ return list(global_idea_storage.values())
+
+
+def _bulk_update_scores(scored_payloads: list[Dict[str, Any]]) -> None:
+ for s in scored_payloads:
+ iid = s.get("id")
+ if not iid or iid not in global_idea_storage:
+ continue
+
+ update_dict: Dict[str, Any] = {}
+
+ incoming_scores = s.get("scores")
+ if incoming_scores and isinstance(incoming_scores, dict):
+ existing_scores = global_idea_storage[iid].get("scores")
+ if not isinstance(existing_scores, dict):
+ existing_scores = {}
+ update_dict["scores"] = {**existing_scores, **incoming_scores}
+
+ # Persist the most recent dimension scores/reasons as convenience fields
+ # (the canonical per-dimension values remain stored in `scores`).
+ for idx in (1, 2, 3):
+ score_key_in = f"dimension{idx}Score"
+ score_key_out = f"Dimension{idx}Score"
+ reason_key_out = f"Dimension{idx}Reason"
+ if s.get(score_key_in) is not None:
+ update_dict[score_key_out] = s.get(score_key_in)
+ if s.get(reason_key_out) is not None:
+ update_dict[reason_key_out] = s.get(reason_key_out, "")
+
+ global_idea_storage[iid].update(update_dict)
+
def format_name_for_display(name: Optional[str]) -> str:
"""Formats a name"""
@@ -149,6 +238,26 @@ def format_name_for_display(name: Optional[str]) -> str:
return " ".join(word.capitalize() for word in name.split("_"))
+def _auto_agent_sdk_for_model(model: str) -> str:
+ normalized = (model or "").strip().lower()
+ if normalized.startswith("claude-"):
+ return "claude"
+ return "openai"
+
+
+def _validate_idea_response_payload(response: Dict[str, Any]) -> bool:
+ ideas = response.get("ideas")
+ if not isinstance(ideas, list) or len(ideas) == 0:
+ return False
+ required = {"id", "title", "content", "originalData"}
+ for idea in ideas:
+ if not isinstance(idea, dict):
+ return False
+ if any(key not in idea for key in required):
+ return False
+ return True
+
+
# Initialize the Thinker
@app.route("/api/configure", methods=["POST"])
def configure() -> Union[Response, tuple[Response, int]]:
@@ -158,12 +267,22 @@ def configure() -> Union[Response, tuple[Response, int]]:
return jsonify({"error": "No JSON data provided"}), 400
model = data.get("model")
api_key = data.get("api_key")
+ requested_agent_sdk = data.get("agent_sdk")
budget = data.get("budget")
budget_preference = data.get("budget_preference")
if not model or not api_key:
return jsonify({"error": "Model and API key are required"}), 400
+ try:
+ if requested_agent_sdk:
+ resolved_agent_sdk = resolve_agent_sdk(agent_sdk=requested_agent_sdk)
+ else:
+ resolved_agent_sdk = _auto_agent_sdk_for_model(model)
+ validate_agent_sdk_model_combo(resolved_agent_sdk, model)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+
try:
(
resolved_budget,
@@ -175,11 +294,30 @@ def configure() -> Union[Response, tuple[Response, int]]:
# Map models to their environment variables
env_var_map = {
+ "gpt-5.2": "OPENAI_API_KEY",
+ "gpt-5.2-pro": "OPENAI_API_KEY",
+ "gpt-5.2-codex": "OPENAI_API_KEY",
+ "gpt-5": "OPENAI_API_KEY",
+ "gpt-5-pro": "OPENAI_API_KEY",
+ "gpt-5-mini": "OPENAI_API_KEY",
+ "gpt-5-nano": "OPENAI_API_KEY",
+ "gpt-4.1": "OPENAI_API_KEY",
+ "gpt-4.1-mini": "OPENAI_API_KEY",
+ "gpt-4.1-nano": "OPENAI_API_KEY",
+ "o3": "OPENAI_API_KEY",
+ "o4-mini-deep-research": "OPENAI_API_KEY",
+ "claude-opus-4-6": "ANTHROPIC_API_KEY",
+ "claude-opus-4-5": "ANTHROPIC_API_KEY",
+ "claude-sonnet-4-5": "ANTHROPIC_API_KEY",
+ "claude-haiku-4-5": "ANTHROPIC_API_KEY",
+ "claude-opus-4": "ANTHROPIC_API_KEY",
+ "claude-sonnet-4": "ANTHROPIC_API_KEY",
"deepseek-chat": "DEEPSEEK_API_KEY",
"deepseek-reasoner": "DEEPSEEK_API_KEY",
- "gpt-4o": "OPENAI_API_KEY",
- "gpt-o1": "OPENAI_API_KEY",
- "claude-3-5-sonnet-20241022": "ANTHROPIC_API_KEY",
+ "gemini-2.5-pro": "GOOGLE_API_KEY",
+ "gemini-2.5-flash": "GOOGLE_API_KEY",
+ "gemini-2.5-flash-lite": "GOOGLE_API_KEY",
+ "gemini-2.0-flash": "GOOGLE_API_KEY",
}
# Set the appropriate environment variable
@@ -193,6 +331,7 @@ def configure() -> Union[Response, tuple[Response, int]]:
session["configured"] = True
session["budget"] = resolved_budget
session["budget_preference"] = resolved_preference
+ session["agent_sdk"] = resolved_agent_sdk
# Initialize all components with same parameters as TinyScientist
global thinker, coder, writer, reviewer, global_cost_tracker
@@ -214,13 +353,15 @@ def configure() -> Union[Response, tuple[Response, int]]:
thinker = Thinker(
model=model,
tools=[],
- iter_num=0,
+ iter_num=1,
output_dir="./",
- search_papers=False,
+ search_papers=True,
generate_exp_plan=True,
+ temperature=1.0,
cost_tracker=BudgetChecker(
budget=allocation.get("thinker"), parent=global_cost_tracker
),
+ agent_sdk=resolved_agent_sdk,
)
coder = Coder(
model=model,
@@ -230,12 +371,14 @@ def configure() -> Union[Response, tuple[Response, int]]:
cost_tracker=BudgetChecker(
budget=allocation.get("coder"), parent=global_cost_tracker
),
+ agent_sdk=resolved_agent_sdk,
)
return jsonify(
{
"status": "configured",
"model": model,
+ "agent_sdk": resolved_agent_sdk,
"budget": resolved_budget,
"budget_preference": resolved_preference,
}
@@ -245,35 +388,45 @@ def configure() -> Union[Response, tuple[Response, int]]:
@app.route("/api/generate-initial", methods=["POST"])
def generate_initial() -> Union[Response, tuple[Response, int]]:
"""Generate initial ideas from an intent (handleAnalysisIntentSubmit)"""
- emit_buffered_logs() # Emit any buffered logs from module initialization
- data = request.json
- if data is None:
- return jsonify({"error": "No JSON data provided"}), 400
- if thinker is None:
- return jsonify({"error": "Thinker not configured"}), 400
- intent = data.get("intent")
- num_ideas = data.get("num_ideas", 3)
+ try:
+ emit_buffered_logs()
+ data = request.json
+ if data is None:
+ return jsonify({"error": "No JSON data provided"}), 400
+ if thinker is None:
+ return jsonify({"error": "Thinker not configured"}), 400
+ intent = data.get("intent")
- # Generate ideas
- ideas = thinker.run(intent=intent, num_ideas=num_ideas)
+ idea = thinker.run(intent=intent, num_ideas=1)
- # Return in the format expected by TreePlot
- response = {
- "ideas": [
- {
- "title": format_name_for_display(
- idea.get("Name") if isinstance(idea, dict) else None
- ),
- "content": (
- format_idea_content(idea) if isinstance(idea, dict) else str(idea)
- ),
- "originalData": idea, # Preserve complete thinker JSON for coder/writer
- }
- for idea in ideas
- ]
- }
+ if not idea or not isinstance(idea, dict):
+ return jsonify({"error": "Failed to generate idea"}), 500
- return jsonify(response)
+ # Ensure experiment plan is generated for experimental ideas (thinker.run may skip it on failure)
+ if idea.get("is_experimental", True) and not idea.get("Experiment"):
+ enriched_json = thinker.generate_experiment_plan(json.dumps(idea))
+ idea = json.loads(enriched_json)
+
+ print(f"[generate-initial] Returning idea keys: {list(idea.keys())}")
+ print(f"[generate-initial] Has Experiment: {'Experiment' in idea}")
+
+ new_id = _next_root_id()
+ response = {
+ "ideas": [
+ {
+ "id": new_id,
+ "title": format_name_for_display(idea.get("Name")),
+ "content": format_idea_content(idea),
+ "originalData": idea,
+ }
+ ]
+ }
+ if not _validate_idea_response_payload(response):
+ return jsonify({"error": "Invalid backend idea payload"}), 500
+ return jsonify(response)
+ except Exception as exc:
+ print(f"[ERROR] generate-initial failed: {exc}")
+ return jsonify({"error": f"generate-initial failed: {str(exc)}"}), 500
@app.route("/api/set-system-prompt", methods=["POST"])
@@ -298,37 +451,9 @@ def set_system_prompt() -> Union[Response, tuple[Response, int]]:
return jsonify({"status": "success", "message": "System prompt updated"})
-@app.route("/api/set-criteria", methods=["POST"])
-def set_criteria() -> Union[Response, tuple[Response, int]]:
- """Set evaluation criteria for a specific dimension"""
- global thinker
-
- if not thinker:
- return jsonify({"error": "Thinker not configured"}), 400
-
- data = request.json
- if data is None:
- return jsonify({"error": "No JSON data provided"}), 400
- dimension = data.get("dimension") # 'novelty', 'feasibility', or 'impact'
- criteria = data.get("criteria")
-
- if dimension not in ["novelty", "feasibility", "impact"]:
- return jsonify({"error": "Invalid dimension"}), 400
-
- # If empty string or None, reset to default
- if not criteria:
- thinker.set_criteria(dimension, None) # This will reset to default
- else:
- thinker.set_criteria(dimension, criteria)
-
- return jsonify(
- {"status": "success", "message": f"{dimension.capitalize()} criteria updated"}
- )
-
-
@app.route("/api/get-prompts", methods=["GET"])
def get_prompts() -> Union[Response, tuple[Response, int]]:
- """Get current prompts and criteria"""
+ """Get current system prompt (dynamic dimensions are configured client-side)."""
global thinker
if thinker is None:
@@ -337,16 +462,8 @@ def get_prompts() -> Union[Response, tuple[Response, int]]:
return jsonify(
{
"system_prompt": thinker.get_system_prompt(),
- "criteria": {
- "novelty": thinker.get_criteria("novelty"),
- "feasibility": thinker.get_criteria("feasibility"),
- "impact": thinker.get_criteria("impact"),
- },
"defaults": {
"system_prompt": thinker.default_system_prompt,
- "novelty": thinker.default_novelty_criteria,
- "feasibility": thinker.default_feasibility_criteria,
- "impact": thinker.default_impact_criteria,
},
}
)
@@ -355,35 +472,110 @@ def get_prompts() -> Union[Response, tuple[Response, int]]:
@app.route("/api/generate-children", methods=["POST"])
def generate_children() -> Union[Response, tuple[Response, int]]:
"""Generate child ideas (generateChildNodes)"""
+ try:
+ data = request.json
+ if data is None:
+ return jsonify({"error": "No JSON data provided"}), 400
+ if thinker is None:
+ return jsonify({"error": "Thinker not configured"}), 400
+ parent_content = data.get("parent_content")
+ parent_id = data.get("parent_id", "root")
+ context = data.get("context", "")
+
+ combined_intent = f"{parent_content}\nAdditional Context: {context}"
+ idea = thinker.run(intent=combined_intent, num_ideas=1)
+
+ if not idea or not isinstance(idea, dict):
+ return jsonify({"error": "Failed to generate idea"}), 500
+
+ # Ensure experiment plan is generated for experimental ideas (thinker.run may skip it on failure)
+ if idea.get("is_experimental", True) and not idea.get("Experiment"):
+ enriched_json = thinker.generate_experiment_plan(json.dumps(idea))
+ idea = json.loads(enriched_json)
+
+ child_id = _next_child_id(parent_id)
+
+ response = {
+ "ideas": [
+ {
+ "id": child_id,
+ "title": format_name_for_display(idea.get("Name")),
+ "content": format_idea_content(idea),
+ "originalData": idea,
+ }
+ ]
+ }
+ if not _validate_idea_response_payload(response):
+ return jsonify({"error": "Invalid backend idea payload"}), 500
+ return jsonify(response)
+ except Exception as exc:
+ print(f"[ERROR] generate-children failed: {exc}")
+ return jsonify({"error": f"generate-children failed: {str(exc)}"}), 500
+
+
+@app.route("/api/suggest-dimensions", methods=["POST"])
+def suggest_dimensions() -> Union[Response, tuple[Response, int]]:
+ """Suggest dimension pairs for evaluating ideas based on research intent."""
data = request.json
if data is None:
return jsonify({"error": "No JSON data provided"}), 400
if thinker is None:
return jsonify({"error": "Thinker not configured"}), 400
- parent_content = data.get("parent_content")
- context = data.get("context", "")
- # Combine parent content and context as the intent
- combined_intent = f"{parent_content}\nAdditional Context: {context}"
- ideas = thinker.run(intent=combined_intent, num_ideas=3)
+ intent = data.get("intent")
+ if not intent:
+ return jsonify({"error": "Intent is required"}), 400
- # Return in the format expected by TreePlot
- response = {
- "ideas": [
+ dimension_pairs = thinker.suggest_dimensions(intent=intent)
+ return jsonify({"dimension_pairs": dimension_pairs})
+
+
+@app.route("/api/evaluate-dimension", methods=["POST"])
+def evaluate_single_dimension() -> Union[Response, tuple[Response, int]]:
+ """Evaluate ideas on a single dimension pair only."""
+ data = request.json
+ if data is None:
+ return jsonify({"error": "No JSON data provided"}), 400
+ if thinker is None:
+ return jsonify({"error": "Thinker not configured"}), 400
+
+ ideas = data.get("ideas", [])
+ intent = data.get("intent")
+ dimension_pair = data.get("dimension_pair")
+ dimension_index = data.get("dimension_index", 0)
+
+ print("[DEBUG] Single dimension evaluation request:")
+ print(f" - Ideas count: {len(ideas)}")
+ print(f" - Dimension pair: {dimension_pair}")
+ print(f" - Dimension index: {dimension_index}")
+
+ if not dimension_pair:
+ return jsonify({"error": "dimension_pair is required"}), 400
+
+ try:
+ scores = thinker.rank_single_dimension(
+ ideas=ideas,
+ intent=intent,
+ dimension_pair=dimension_pair,
+ dimension_index=dimension_index,
+ )
+ print(f"[DEBUG] Single dimension evaluation returned {len(scores)} scores")
+ return jsonify(
{
- "title": format_name_for_display(
- idea.get("Name") if isinstance(idea, dict) else None
- ),
- "content": (
- format_idea_content(idea) if isinstance(idea, dict) else str(idea)
- ),
- "originalData": idea, # Preserve complete thinker JSON for coder/writer
+ "scores": scores,
+ "dimension_pair": dimension_pair,
+ "dimension_index": dimension_index,
}
- for idea in ideas
- ]
- }
+ )
+ except Exception as e:
+ print(f"[ERROR] Single dimension evaluation failed: {str(e)}")
+ import traceback
- return jsonify(response)
+ traceback.print_exc()
+ return (
+ jsonify({"error": f"Error during single dimension evaluation: {str(e)}"}),
+ 500,
+ )
@app.route("/api/modify", methods=["POST"])
@@ -397,28 +589,108 @@ def modify_idea() -> Union[Response, tuple[Response, int]]:
original_idea = data.get("original_idea")
modifications = data.get("modifications")
behind_idea = data.get("behind_idea")
+ original_id = data.get("original_id", "")
+ dimension_pairs = data.get("dimension_pairs") or []
+
+ if not isinstance(modifications, list) or len(modifications) == 0:
+ return jsonify({"error": "modifications must be a non-empty list"}), 400
+
+ if not isinstance(dimension_pairs, list) or len(dimension_pairs) < 3:
+ return jsonify({"error": "dimension_pairs (length >= 3) is required"}), 400
# Use original data directly (no conversion needed)
thinker_original = original_idea
thinker_behind = behind_idea
- # Convert modifications to Thinker format
- thinker_mods = []
- for mod in modifications:
- thinker_mods.append(
- {"metric": mod.get("metric"), "direction": mod.get("direction")}
- )
+ thinker_mods = modifications
# Modify the idea
modified_idea = thinker.modify_idea(
original_idea=thinker_original,
modifications=thinker_mods,
behind_idea=thinker_behind,
+ dimension_pairs=dimension_pairs,
)
+
+ # Apply the requested score adjustments onto the modified idea's dynamic score map.
+ # This keeps the UI consistent even though modify_idea is a content rewrite step.
+ base_scores: Dict[str, Any] = {}
+ if isinstance(thinker_original, dict) and isinstance(
+ thinker_original.get("scores"), dict
+ ):
+ base_scores.update(thinker_original.get("scores") or {})
+ if isinstance(modified_idea, dict) and isinstance(
+ modified_idea.get("scores"), dict
+ ):
+ base_scores.update(modified_idea.get("scores") or {})
+
+ for mod in modifications:
+ if not isinstance(mod, dict):
+ continue
+ metric = mod.get("metric")
+ new_score = mod.get("newScore")
+ if isinstance(metric, str) and metric.strip() and new_score is not None:
+ base_scores[metric] = new_score
+
+ if isinstance(modified_idea, dict):
+ modified_idea["scores"] = base_scores
+
+ def _pair_key(pair: Dict[str, Any]) -> str:
+ return f"{str(pair.get('dimensionA', '')).strip()}-{str(pair.get('dimensionB', '')).strip()}"
+
+ for idx, pair in enumerate(dimension_pairs[:3]):
+ key = _pair_key(pair)
+ score_val = base_scores.get(key)
+ modified_idea[f"Dimension{idx + 1}Score"] = score_val
+
+ # Generate experiment plan for modified idea (only if experimental)
+ if modified_idea.get("is_experimental", True) and not modified_idea.get(
+ "Experiment"
+ ):
+ enriched_json = thinker.generate_experiment_plan(json.dumps(modified_idea))
+ modified_idea = json.loads(enriched_json)
+
+ # Generate hierarchical ID for the modified idea
+ modified_id = _next_modify_id(original_id) if original_id else str(uuid.uuid4())
+
# Return in the format expected by TreePlot
response = {
+ "id": modified_id,
"title": format_name_for_display(modified_idea.get("Name")),
"content": format_idea_content(modified_idea),
"originalData": modified_idea, # Preserve complete thinker JSON for coder/writer
+ "scores": modified_idea.get("scores")
+ if isinstance(modified_idea, dict)
+ else {},
+ "dimension1Score": (
+ modified_idea.get("Dimension1Score")
+ if isinstance(modified_idea, dict)
+ else None
+ ),
+ "dimension2Score": (
+ modified_idea.get("Dimension2Score")
+ if isinstance(modified_idea, dict)
+ else None
+ ),
+ "dimension3Score": (
+ modified_idea.get("Dimension3Score")
+ if isinstance(modified_idea, dict)
+ else None
+ ),
+ "Dimension1Reason": (
+ modified_idea.get("Dimension1Reason", "")
+ if isinstance(modified_idea, dict)
+ else ""
+ ),
+ "Dimension2Reason": (
+ modified_idea.get("Dimension2Reason", "")
+ if isinstance(modified_idea, dict)
+ else ""
+ ),
+ "Dimension3Reason": (
+ modified_idea.get("Dimension3Reason", "")
+ if isinstance(modified_idea, dict)
+ else ""
+ ),
}
return jsonify(response)
@@ -433,14 +705,32 @@ def merge_ideas() -> Union[Response, tuple[Response, int]]:
return jsonify({"error": "Thinker not configured"}), 400
idea_a = data.get("idea_a")
idea_b = data.get("idea_b")
+ idea_a_id = data.get("idea_a_id", "")
+ idea_b_id = data.get("idea_b_id", "")
# Use original data directly (no conversion needed)
thinker_idea_a = idea_a
thinker_idea_b = idea_b
# Merge ideas
merged_idea = thinker.merge_ideas(idea_a=thinker_idea_a, idea_b=thinker_idea_b)
+ # Generate experiment plan for merged idea (only if experimental)
+ if (
+ isinstance(merged_idea, dict)
+ and merged_idea.get("is_experimental", True)
+ and not merged_idea.get("Experiment")
+ ):
+ enriched_json = thinker.generate_experiment_plan(json.dumps(merged_idea))
+ merged_idea = json.loads(enriched_json)
+
+ # Generate hierarchical merged ID
+ if idea_a_id and idea_b_id:
+ merged_id = _merge_id(idea_a_id, idea_b_id)
+ else:
+ merged_id = str(uuid.uuid4())
+
# Return in the format expected by TreePlot
response = {
+ "id": merged_id,
"title": format_name_for_display(
merged_idea.get("Name") if isinstance(merged_idea, dict) else None
),
@@ -457,43 +747,299 @@ def merge_ideas() -> Union[Response, tuple[Response, int]]:
@app.route("/api/evaluate", methods=["POST"])
def evaluate_ideas() -> Union[Response, tuple[Response, int]]:
- """Evaluate ideas (evaluateIdeas)"""
data = request.json
if data is None:
return jsonify({"error": "No JSON data provided"}), 400
if thinker is None:
return jsonify({"error": "Thinker not configured"}), 400
- ideas = data.get("ideas")
+
+ incoming_ideas = data.get("ideas", [])
intent = data.get("intent")
+ dimension_pairs = data.get("dimension_pairs", [])
+ user_score_corrections = data.get("userScoreCorrections", [])
+ mode = data.get("mode", "incremental")
+ explicit_target_ids = set(data.get("targetIds", []) or [])
- # Use original data directly (no conversion needed)
- thinker_ideas = ideas
+ if not isinstance(dimension_pairs, list) or len(dimension_pairs) < 3:
+ return (
+ jsonify({"error": "dimension_pairs (length >= 3) is required"}),
+ 400,
+ )
- # Store original IDs in order - LLM doesn't preserve titles, use index mapping
- original_ids = [idea.get("id") for idea in ideas]
+ def _pair_keys(pair: Dict[str, Any]) -> tuple[str, str]:
+ a = str(pair.get("dimensionA", "")).strip()
+ b = str(pair.get("dimensionB", "")).strip()
+ return f"{a}-{b}", f"{b}-{a}"
+
+ def _has_score_for_pair(idea: Dict[str, Any], pair: Dict[str, Any]) -> bool:
+ scores = idea.get("scores")
+ if not isinstance(scores, dict):
+ return False
+ k1, k2 = _pair_keys(pair)
+ v1 = scores.get(k1)
+ v2 = scores.get(k2)
+ return v1 is not None or v2 is not None
+
+ def _has_scores_for_pairs(
+ idea: Dict[str, Any], pairs: list[Dict[str, Any]]
+ ) -> bool:
+ return all(_has_score_for_pair(idea, pair) for pair in pairs)
+
+ print("[DEBUG] Evaluation request received:")
+ print(f" - Mode: {mode}")
+ print(f" - Incoming ideas count: {len(incoming_ideas)}")
+ print(f" - Incoming idea IDs: {[i.get('id', 'NO_ID') for i in incoming_ideas]}")
+ print(f" - Intent: {intent[:50] if intent else 'None'}...")
+ print(f" - Target IDs: {list(explicit_target_ids)}")
+ print(f" - Dimension pairs: {len(dimension_pairs)}")
+
+ existing_ids = {i["id"] for i in _list_stored_ideas()}
+ new_ids = []
+ for inc in incoming_ideas:
+ inc_id = inc.get("id") or str(uuid.uuid4())
+ inc["id"] = inc_id
+ if inc_id not in existing_ids:
+ new_ids.append(inc_id)
+ _store_or_update_idea({**inc})
+
+ stored_list = _list_stored_ideas()
+
+ if mode == "full":
+ target_ids_local = {idea.get("id") for idea in stored_list}
+ else:
+ target_ids_local = set(new_ids) | explicit_target_ids
+ if not explicit_target_ids:
+ for idea in stored_list:
+ if not _has_scores_for_pairs(idea, dimension_pairs[:3]):
+ target_ids_local.add(idea.get("id"))
+
+ if mode == "full":
+ evaluation_input = stored_list
+ else:
+ evaluation_input = []
+ for idea in stored_list:
+ iid = idea.get("id")
+ has_scores = _has_scores_for_pairs(idea, dimension_pairs[:3])
+ idea_copy = idea.copy()
+ if iid in target_ids_local:
+ idea_copy.pop("AlreadyScored", None)
+ evaluation_input.append(idea_copy)
+ else:
+ if has_scores:
+ idea_copy["AlreadyScored"] = True
+ evaluation_input.append(idea_copy)
+
+ print("[DEBUG] About to call thinker.rank with:")
+ print(f" - Evaluation input count: {len(evaluation_input)}")
+ print(f" - Evaluation input IDs: {[i.get('id') for i in evaluation_input]}")
+ print(f" - Intent length: {len(intent) if intent else 0}")
+ print(f" - Partial mode: {mode != 'full'}")
- # Rank ideas
- scored_ideas = thinker.rank(ideas=thinker_ideas, intent=intent)
+ try:
+ scored_ideas = thinker.rank(
+ ideas=evaluation_input,
+ intent=intent,
+ dimension_pairs=dimension_pairs if dimension_pairs else None,
+ user_score_corrections=user_score_corrections,
+ partial=(mode != "full"),
+ )
+ print(
+ f"[DEBUG] thinker.rank returned {len(scored_ideas) if scored_ideas else 0} scored ideas"
+ )
+ if scored_ideas:
+ print(
+ f"[DEBUG] First scored idea: {scored_ideas[0].get('Title', 'NO_TITLE')}"
+ )
+ except Exception as e:
+ print(f"[ERROR] thinker.rank failed: {str(e)}")
+ print(f"[ERROR] Exception type: {type(e).__name__}")
+ import traceback
- # Return in the format expected by TreePlot
- # Use index-based mapping since LLM changes titles but preserves order
- response = []
- for i, idea in enumerate(scored_ideas):
- # Use index to map back to original ID
- original_id = original_ids[i] if i < len(original_ids) else f"idea_{i}"
+ print(f"[ERROR] Traceback: {traceback.format_exc()}")
+ scored_ideas = []
+
+ title_maps = [{}, {}, {}]
+ for idea in stored_list:
+ iid = idea.get("id")
+ if not iid:
+ continue
+
+ for key in [idea.get("Title"), idea.get("Name"), idea.get("title")]:
+ if key and key.strip():
+ title_maps[0][key.strip()] = iid
+
+ for raw_name in [idea.get("Name"), idea.get("Title")]:
+ if raw_name and raw_name.strip():
+ formatted = format_name_for_display(raw_name)
+ title_maps[1][formatted] = iid
+
+ for key in [idea.get("Title"), idea.get("Name")]:
+ if key and key.strip():
+ variations = [
+ key.strip().lower(),
+ key.strip().replace(" ", "_").lower(),
+ key.strip().replace("_", " ").lower(),
+ ]
+ for variation in variations:
+ title_maps[2][variation] = iid
+
+ print("[DEBUG] Title maps built:")
+ print(f" - Map 0 (original): {list(title_maps[0].keys())}")
+ print(f" - Map 1 (formatted): {list(title_maps[1].keys())}")
+ print(f" - Map 2 (variations): {list(title_maps[2].keys())}")
+
+ def find_idea_id(scored_idea):
+ scored_titles = [
+ scored_idea.get("Title", "").strip(),
+ scored_idea.get("Name", "").strip(),
+ scored_idea.get("title", "").strip(),
+ ]
- response.append(
- {
- "id": original_id, # Use index-based mapping
- "noveltyScore": idea.get("NoveltyScore"),
- "noveltyReason": idea.get("NoveltyReason", ""),
- "feasibilityScore": idea.get("FeasibilityScore"),
- "feasibilityReason": idea.get("FeasibilityReason", ""),
- "impactScore": idea.get("ImpactScore"),
- "impactReason": idea.get("ImpactReason", ""),
- }
- )
- return jsonify(response)
+ print(f"[DEBUG] Finding ID for scored idea with titles: {scored_titles}")
+
+ for title in scored_titles:
+ if not title:
+ continue
+
+ for i, title_map in enumerate(title_maps):
+ if title in title_map:
+ print(
+ f"[DEBUG] Found exact match in map {i}: '{title}' -> {title_map[title]}"
+ )
+ return title_map[title]
+
+ title_lower = title.lower()
+ for i, title_map in enumerate(title_maps):
+ for stored_title, stored_id in title_map.items():
+ if stored_title.lower() == title_lower:
+ print(
+ f"[DEBUG] Found case-insensitive match in map {i}: '{title}' -> {stored_id}"
+ )
+ return stored_id
+
+ print(f"[DEBUG] No match found for titles: {scored_titles}")
+ return None
+
+ payloads = []
+ unmatched_ideas = []
+ matched_ids = set()
+
+ pair_count = len(dimension_pairs)
+
+ for i, scored in enumerate(scored_ideas):
+ iid = find_idea_id(scored)
+
+ if not iid and mode == "incremental" and i < len(new_ids):
+ potential_id = new_ids[i]
+ if potential_id in target_ids_local and potential_id not in matched_ids:
+ iid = potential_id
+ print(f"[DEBUG] Fallback ID match: scored[{i}] -> {iid}")
+
+ if not iid:
+ unmatched_ideas.append(
+ {
+ "title": scored.get("Title", scored.get("Name", "Unknown")),
+ "scored_keys": list(scored.keys()),
+ "index": i,
+ }
+ )
+ continue
+
+ if mode == "incremental" and iid not in target_ids_local:
+ continue
+
+ matched_ids.add(iid)
+ if iid and ("-CUSTOM-" in iid or iid.startswith("C-")):
+ print(f"[DEBUG] Custom idea {iid} reasoning fields:")
+ print(f" Dimension1Reason: {scored.get('Dimension1Reason', '')}")
+ print(f" Dimension2Reason: {scored.get('Dimension2Reason', '')}")
+ if pair_count >= 3:
+ print(f" Dimension3Reason: {scored.get('Dimension3Reason', '')}")
+ print(f" All scored keys: {list(scored.keys())}")
+
+ payload = {
+ "id": iid,
+ "scores": scored.get("scores", {})
+ if isinstance(scored.get("scores"), dict)
+ else {},
+ }
+ payload["dimension1Score"] = scored.get("Dimension1Score")
+ payload["dimension2Score"] = scored.get("Dimension2Score")
+ payload["Dimension1Reason"] = scored.get("Dimension1Reason", "")
+ payload["Dimension2Reason"] = scored.get("Dimension2Reason", "")
+ if pair_count >= 3:
+ payload["dimension3Score"] = scored.get("Dimension3Score")
+ payload["Dimension3Reason"] = scored.get("Dimension3Reason", "")
+ payloads.append(payload)
+
+ print("[DEBUG] Evaluation matching results:")
+ print(f" - Mode: {mode}")
+ print(f" - Stored ideas: {len(stored_list)}")
+ print(f" - Scored ideas from LLM: {len(scored_ideas)}")
+ print(f" - Target IDs: {list(target_ids_local)}")
+ print(f" - New IDs: {new_ids}")
+ print(f" - Successful matches: {len(payloads)}")
+ print(f" - Failed matches: {len(unmatched_ideas)}")
+
+ if unmatched_ideas:
+ print(f"[WARNING] Could not match {len(unmatched_ideas)} ideas:")
+ for um in unmatched_ideas[:3]:
+ print(
+ f" - [{um.get('index', '?')}] Title: '{um['title']}', Keys: {um['scored_keys']}"
+ )
+ print(f"[DEBUG] Available stored titles: {list(title_maps[0].keys())}")
+
+ if payloads:
+ print("[DEBUG] Matched payloads:")
+ for p in payloads[:2]:
+ print(
+ f" - ID: {p['id']}, Scores: D1={p.get('dimension1Score')}, D2={p.get('dimension2Score')}, D3={p.get('dimension3Score')}"
+ )
+
+ _bulk_update_scores(payloads)
+
+ updated_all = _list_stored_ideas()
+
+ client_return = []
+ for i in updated_all:
+ item = {
+ "id": i.get("id"),
+ "title": format_name_for_display(i.get("Name") or i.get("Title")),
+ "content": format_idea_content(i),
+ "originalData": i,
+ }
+
+ if i.get("scores"):
+ item["scores"] = i.get("scores")
+ item["dimension1Score"] = i.get("Dimension1Score")
+ item["dimension2Score"] = i.get("Dimension2Score")
+ item["dimension3Score"] = i.get("Dimension3Score")
+ item["Dimension1Reason"] = i.get("Dimension1Reason", "")
+ item["Dimension2Reason"] = i.get("Dimension2Reason", "")
+ item["Dimension3Reason"] = i.get("Dimension3Reason", "")
+
+ client_return.append(item)
+
+ custom_ideas_in_return = [
+ item
+ for item in client_return
+ if item["id"] and ("-CUSTOM-" in item["id"] or item["id"].startswith("C-"))
+ ]
+ if custom_ideas_in_return:
+ print("[DEBUG] Custom ideas in client return:")
+ for item in custom_ideas_in_return:
+ print(f" - ID: {item['id']}")
+ print(
+ f" D1Reason: {item.get('Dimension1Reason', 'MISSING')[:50]}..., D2Reason: {item.get('Dimension2Reason', 'MISSING')[:50]}..., D3Reason: {item.get('Dimension3Reason', 'MISSING')[:50]}..."
+ )
+
+ meta = {
+ "mode": mode,
+ "scoredCount": len(payloads),
+ "totalIdeas": len(updated_all),
+ "targets": list(target_ids_local),
+ }
+ return jsonify({"ideas": client_return, "meta": meta})
def format_idea_content(idea: Union[Dict[str, Any], str]) -> str:
@@ -505,16 +1051,16 @@ def format_idea_content(idea: Union[Dict[str, Any], str]) -> str:
if isinstance(idea, str):
return idea
- description = idea.get("Description", "")
+ problem = idea.get("Problem", "") or idea.get("Description", "")
importance = idea.get("Importance", "")
difficulty = idea.get("Difficulty", "")
novelty = idea.get("NoveltyComparison", "")
content_sections = [
- f"**Description:**\n{description}",
- f"**Impact:**\n{importance}",
- f"**Feasibility:**\n{difficulty}",
- f"**Novelty:**\n{novelty}",
+ f"**Problem:**\n{problem}",
+ f"**Importance:**\n{importance}",
+ f"**Difficulty:**\n{difficulty}",
+ f"**Novelty Comparison:**\n{novelty}",
]
return "\n\n".join(content_sections)
@@ -549,7 +1095,6 @@ def generate_code() -> Union[Response, tuple[Response, int]]:
print(f"Using pre-configured Coder with model: {coder.model}")
print(f"Idea keys: {list(idea.keys())}")
- print(f"Idea has Experiment field: {'Experiment' in idea}")
# Call coder.run() exactly like TinyScientist does
print("Starting coder.run()...")
@@ -623,17 +1168,14 @@ def generate_paper() -> Union[Response, tuple[Response, int]]:
idea_data = data.get("idea")
experiment_dir = data.get("experiment_dir", None)
- s2_api_key = data.get("s2_api_key", None)
-
- if not s2_api_key:
- return jsonify({"error": "Semantic Scholar API key is required"}), 400
+ s2_api_key = data.get("s2_api_key", None) or os.environ.get("S2_API_KEY")
if not idea_data:
print("ERROR: No idea provided in request")
return jsonify({"error": "No idea provided"}), 400
try:
- writer_model = session.get("model", "deepseek-chat") # Get model from session
+ writer_model = session["model"]
papers_dir = os.path.join(project_root, "generated", "papers")
try:
@@ -651,6 +1193,7 @@ def generate_paper() -> Union[Response, tuple[Response, int]]:
cost_tracker=BudgetChecker(
budget=session_allocation.get("writer"), parent=global_cost_tracker
),
+ agent_sdk=session.get("agent_sdk"),
)
print(f"Writer initialized for this request with model: {writer.model}")
@@ -769,7 +1312,7 @@ def review_paper() -> Union[Response, tuple[Response, int]]:
return jsonify({"error": "No JSON data provided"}), 400
pdf_path = data.get("pdf_path")
- s2_api_key = data.get("s2_api_key")
+ s2_api_key = data.get("s2_api_key") or os.environ.get("S2_API_KEY")
if not pdf_path:
return jsonify({"error": "No PDF path provided"}), 400
@@ -803,7 +1346,7 @@ def review_paper() -> Union[Response, tuple[Response, int]]:
# Check if file exists
if not os.path.exists(absolute_pdf_path):
return jsonify({"error": "PDF file not found"}), 404
- reviewer_model = session.get("model", "deepseek-chat") # Get model from session
+ reviewer_model = session["model"]
print("🔍 Starting paper review...")
try:
_, _, session_allocation = TinyScientist.resolve_budget_settings(
@@ -817,12 +1360,13 @@ def review_paper() -> Union[Response, tuple[Response, int]]:
tools=[],
num_reviews=1,
num_reflections=1,
- temperature=0.75,
+ temperature=1.0,
s2_api_key=s2_api_key,
cost_tracker=BudgetChecker(
budget=session_allocation.get("reviewer"),
parent=global_cost_tracker,
),
+ agent_sdk=session.get("agent_sdk"),
)
# Call reviewer.review() to get a single review
review_result = reviewer.review(absolute_pdf_path)
@@ -850,6 +1394,16 @@ def review_paper() -> Union[Response, tuple[Response, int]]:
return jsonify({"error": str(e), "success": False}), 500
+@app.route("/api/clear-session", methods=["POST"])
+def clear_session() -> Union[Response, tuple[Response, int]]:
+ """Clear session state and reset ID counters for a fresh start."""
+ global global_idea_storage
+ global_idea_storage = {}
+ _reset_id_counters()
+ session.clear()
+ return jsonify({"status": "cleared"})
+
+
if __name__ == "__main__":
# Configure Flask for long-running requests
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
diff --git a/config.template.toml b/config.template.toml
index cbfda224..5c1c7808 100644
--- a/config.template.toml
+++ b/config.template.toml
@@ -9,7 +9,7 @@
#name = "AI Scientist"
# LLM Model
-model = "gpt-4o"
+model = "gpt-5-mini"
# Temperature for controlling randomness in responses
temperature = 0.75
diff --git a/examples/code.py b/examples/code.py
index 092ff54d..235a4157 100644
--- a/examples/code.py
+++ b/examples/code.py
@@ -3,7 +3,6 @@
import os
from typing import Any, Dict
-import _bootstrap
from tiny_scientist.budget_checker import BudgetChecker
# Import the Coder class - assuming it's in a module called "coder"
diff --git a/examples/demo.py b/examples/demo.py
index 49555f3b..87810d22 100644
--- a/examples/demo.py
+++ b/examples/demo.py
@@ -1,7 +1,6 @@
import json
import os
-import _bootstrap
from tiny_scientist import TinyScientist
scientist = TinyScientist(
diff --git a/examples/draw.py b/examples/draw.py
index 044275f3..59d1ca84 100644
--- a/examples/draw.py
+++ b/examples/draw.py
@@ -3,7 +3,6 @@
import json
import os
-import _bootstrap
from tiny_scientist.tool_impls import DrawerTool
@@ -25,8 +24,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--model",
type=str,
- default="claude-3-5-sonnet-20241022",
- help="LLM model to use (default: claude-3-5-sonnet-20241022)",
+ default="gpt-5-mini",
+ help="LLM model to use (default: gpt-5-mini)",
)
parser.add_argument(
"--output",
diff --git a/examples/hf_auto_eval.py b/examples/hf_auto_eval.py
index b236b711..5f01ba36 100644
--- a/examples/hf_auto_eval.py
+++ b/examples/hf_auto_eval.py
@@ -1,6 +1,5 @@
import argparse
-import _bootstrap
from tiny_scientist import TinyScientist
diff --git a/examples/latex_compile.py b/examples/latex_compile.py
index ed2cc2dc..c7dbbe89 100644
--- a/examples/latex_compile.py
+++ b/examples/latex_compile.py
@@ -1,10 +1,9 @@
-import _bootstrap
from tiny_scientist.utils.llm import create_client
from tiny_scientist.utils.output_formatter import ACLOutputFormatter
from tiny_scientist.utils.water_marker import WaterMarker
-client = create_client(model="gpt-4o")
-formatter = ACLOutputFormatter(model="gpt-4o", client=client)
+client = create_client(model="gpt-5-mini")
+formatter = ACLOutputFormatter(model="gpt-5-mini", client=client)
watermarker = WaterMarker()
formatter._compile_latex(
cwd="../latex", output_pdf_path="../latex/output.pdf", timeout=1000
diff --git a/examples/review.py b/examples/review.py
index e006c091..80fc43d5 100644
--- a/examples/review.py
+++ b/examples/review.py
@@ -4,7 +4,6 @@
import os.path as osp
from typing import Any, List
-import _bootstrap
from tiny_scientist.reviewer import Reviewer
from tiny_scientist.utils.llm import AVAILABLE_LLMS
diff --git a/examples/think.py b/examples/think.py
index 3ef30080..21af211d 100644
--- a/examples/think.py
+++ b/examples/think.py
@@ -4,7 +4,6 @@
import os
from typing import Any, Dict, cast
-import _bootstrap
from tiny_scientist.thinker import Thinker
from tiny_scientist.utils.input_formatter import InputFormatter
from tiny_scientist.utils.llm import AVAILABLE_LLMS
diff --git a/examples/write.py b/examples/write.py
index 2f920079..166a96e3 100644
--- a/examples/write.py
+++ b/examples/write.py
@@ -3,7 +3,6 @@
import json
import os
-import _bootstrap
from tiny_scientist.writer import Writer
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index bbdba2a3..14977484 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
+ "@react-three/drei": "^9.122.0",
+ "@react-three/fiber": "^8.18.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -24,6 +26,7 @@
"recharts": "^2.13.3",
"remark-gfm": "^4.0.0",
"socket.io-client": "^4.8.1",
+ "three": "^0.181.2",
"web-vitals": "^2.1.4"
},
"devDependencies": {
@@ -2241,6 +2244,12 @@
"postcss-selector-parser": "^6.0.10"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -2829,6 +2838,12 @@
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="
},
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.17",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
+ "license": "Apache-2.0"
+ },
"node_modules/@monaco-editor/loader": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
@@ -2852,6 +2867,18 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
+ "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -2968,6 +2995,195 @@
}
}
},
+ "node_modules/@react-spring/animated": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
+ "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/core": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
+ "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-spring/donate"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/rafz": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
+ "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/shared": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
+ "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/rafz": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/three": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz",
+ "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/core": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": ">=6.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "three": ">=0.126"
+ }
+ },
+ "node_modules/@react-spring/types": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
+ "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
+ "license": "MIT"
+ },
+ "node_modules/@react-three/drei": {
+ "version": "9.122.0",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz",
+ "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@react-spring/three": "~9.7.5",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^2.9.0",
+ "cross-env": "^7.0.3",
+ "detect-gpu": "^5.0.56",
+ "glsl-noise": "^0.0.0",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
+ "react-composer": "^5.0.3",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.7.8",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.0",
+ "tunnel-rat": "^0.1.2",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^8",
+ "react": "^18",
+ "react-dom": "^18",
+ "three": ">=0.137"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
+ "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/react-reconciler": "^0.26.7",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^1.0.6",
+ "react-reconciler": "^0.27.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.21.0",
+ "suspend-react": "^0.1.3",
+ "zustand": "^3.7.1"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=18 <19",
+ "react-dom": ">=18 <19",
+ "react-native": ">=0.64",
+ "three": ">=0.133"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber/node_modules/scheduler": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
+ "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/@react-three/fiber/node_modules/zustand": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
+ "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -3614,6 +3830,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -3753,6 +3975,12 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/draco3d": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
+ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "8.56.12",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
@@ -4114,6 +4342,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -4161,6 +4395,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.26.7",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz",
+ "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -4219,6 +4462,12 @@
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
"node_modules/@types/testing-library__jest-dom": {
"version": "5.14.9",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz",
@@ -4227,6 +4476,21 @@
"@types/jest": "*"
}
},
+ "node_modules/@types/three": {
+ "version": "0.182.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
+ "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~0.22.0"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4238,6 +4502,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
@@ -4482,6 +4752,24 @@
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -4613,6 +4901,12 @@
"@xtuc/long": "4.2.2"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -5411,7 +5705,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5447,6 +5740,15 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -5611,7 +5913,6 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5709,6 +6010,15 @@
"node": ">= 6"
}
},
+ "node_modules/camera-controls": {
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz",
+ "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.126.1"
+ }
+ },
"node_modules/caniuse-api": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@@ -5721,9 +6031,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001680",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz",
- "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==",
+ "version": "1.0.30001770",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
+ "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
"funding": [
{
"type": "opencollective",
@@ -5737,7 +6047,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
- ]
+ ],
+ "license": "CC-BY-4.0"
},
"node_modules/case-sensitive-paths-webpack-plugin": {
"version": "2.4.0",
@@ -6266,6 +6577,24 @@
"node": ">=10"
}
},
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7287,6 +7616,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-gpu": {
+ "version": "5.0.70",
+ "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
+ "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "webgl-constants": "^1.1.1"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -7508,6 +7846,12 @@
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
"integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA=="
},
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
@@ -8730,6 +9074,12 @@
"node": ">= 8"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -9370,6 +9720,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/glsl-noise": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
+ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
+ "license": "MIT"
+ },
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -9537,6 +9893,12 @@
"he": "bin/he"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.15",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
+ "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
+ "license": "Apache-2.0"
+ },
"node_modules/hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -9843,7 +10205,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -10344,6 +10705,12 @@
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
@@ -10634,6 +11001,27 @@
"node": ">= 0.4"
}
},
+ "node_modules/its-fine": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
+ "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": ">=18.0"
+ }
+ },
+ "node_modules/its-fine/node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -11972,6 +12360,16 @@
"lz-string": "bin/bin.js"
}
},
+ "node_modules/maath": {
+ "version": "0.10.8",
+ "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -12347,6 +12745,21 @@
"node": ">= 8"
}
},
+ "node_modules/meshline": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
+ "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
+ "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
+ "license": "MIT"
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -15113,6 +15526,12 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -15178,6 +15597,16 @@
"asap": "~2.0.6"
}
},
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -15380,6 +15809,18 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
+ "node_modules/react-composer": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz",
+ "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.6.0"
+ },
+ "peerDependencies": {
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@@ -15528,6 +15969,31 @@
"react": ">=18"
}
},
+ "node_modules/react-reconciler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz",
+ "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.21.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/react-reconciler/node_modules/scheduler": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
+ "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -15637,6 +16103,21 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -16884,6 +17365,32 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/stats-gl": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/stats.js": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
+ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -17321,6 +17828,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
"node_modules/svg-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
@@ -17679,6 +18195,45 @@
"node": ">=0.8"
}
},
+ "node_modules/three": {
+ "version": "0.181.2",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
+ "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
+ "license": "MIT"
+ },
+ "node_modules/three-mesh-bvh": {
+ "version": "0.7.8",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz",
+ "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==",
+ "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">= 0.151.0"
+ }
+ },
+ "node_modules/three-stdlib": {
+ "version": "2.36.1",
+ "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
+ "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/draco3d": "^1.4.0",
+ "@types/offscreencanvas": "^2019.6.4",
+ "@types/webxr": "^0.5.2",
+ "draco3d": "^1.4.1",
+ "fflate": "^0.6.9",
+ "potpack": "^1.0.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.128.0"
+ }
+ },
+ "node_modules/three-stdlib/node_modules/fflate": {
+ "version": "0.6.10",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
+ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
+ "license": "MIT"
+ },
"node_modules/throat": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz",
@@ -17761,6 +18316,36 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/troika-three-text": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-three-utils": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-worker-utils": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
+ "license": "MIT"
+ },
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -17835,6 +18420,43 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
+ "node_modules/tunnel-rat/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -18213,6 +18835,15 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -18237,6 +18868,15 @@
"resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA=="
},
+ "node_modules/utility-types": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
+ "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -18398,6 +19038,17 @@
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg=="
},
+ "node_modules/webgl-constants": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+ "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
+ },
+ "node_modules/webgl-sdf-generator": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
+ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
+ "license": "MIT"
+ },
"node_modules/webidl-conversions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
@@ -19240,6 +19891,35 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index bd5d8fa2..868117e6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -5,6 +5,8 @@
"proxy": "http://localhost:5000",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
+ "@react-three/drei": "^9.122.0",
+ "@react-three/fiber": "^8.18.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -20,11 +22,12 @@
"recharts": "^2.13.3",
"remark-gfm": "^4.0.0",
"socket.io-client": "^4.8.1",
+ "three": "^0.181.2",
"web-vitals": "^2.1.4"
},
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
+ "start": "GENERATE_SOURCEMAP=false react-scripts start",
+ "build": "GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"shadcn-ui": "shadcn-ui"
diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js
index 1f03afee..cb8484bc 100644
--- a/frontend/src/App.test.js
+++ b/frontend/src/App.test.js
@@ -1,8 +1,14 @@
-import { render, screen } from '@testing-library/react';
import App from './App';
+import { render, screen } from '@testing-library/react';
+
+jest.mock('./components/TreePlotVisualization', () => {
+ const React = require('react');
+ return function MockedTreePlotVisualization() {
+ return React.createElement('div', { 'data-testid': 'tree-plot-visualization' });
+ };
+});
-test('renders learn react link', () => {
+test('renders the main visualization container', () => {
render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+ expect(screen.getByTestId('tree-plot-visualization')).toBeInTheDocument();
});
diff --git a/frontend/src/components/DimensionEditDropdown.css b/frontend/src/components/DimensionEditDropdown.css
new file mode 100644
index 00000000..e8728293
--- /dev/null
+++ b/frontend/src/components/DimensionEditDropdown.css
@@ -0,0 +1,179 @@
+.dimension-edit-dropdown {
+ position: absolute;
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 1000;
+ width: 380px; /* Increased width */
+ display: flex;
+ flex-direction: column;
+ animation: fadeIn 0.15s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-5px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.edit-dropdown-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid #f3f4f6;
+ background-color: #f9fafb;
+ border-radius: 8px 8px 0 0;
+}
+
+.edit-dropdown-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #374151;
+}
+
+.edit-close-btn {
+ background: none;
+ border: none;
+ font-size: 20px;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+}
+
+.edit-close-btn:hover {
+ background-color: #e5e7eb;
+ color: #4b5563;
+}
+
+.edit-dropdown-body {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.edit-field-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.edit-field-group label {
+ font-size: 12px;
+ font-weight: 600;
+ color: #6b7280;
+ margin-left: 2px;
+}
+
+.edit-dimension-input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ box-sizing: border-box;
+}
+
+.edit-dimension-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
+}
+
+.edit-dimension-desc {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 12px;
+ color: #4b5563;
+ background-color: white;
+ resize: vertical;
+ box-sizing: border-box;
+ font-family: inherit;
+}
+
+.edit-dimension-desc:focus {
+ outline: none;
+ border-color: #3b82f6;
+ background-color: white;
+}
+
+.edit-vs-separator-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 4px 0;
+}
+
+.edit-vs-separator {
+ color: #9ca3af;
+ font-size: 12px;
+ font-weight: 600;
+ background-color: #f3f4f6;
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+/* Colors for inputs */
+.dimension-a {
+ /* border-left: 3px solid #3b82f6; */
+}
+
+.dimension-b {
+ /* border-left: 3px solid #f59e0b; */
+}
+
+.edit-dropdown-footer {
+ padding: 12px 16px;
+ border-top: 1px solid #f3f4f6;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ background-color: #f9fafb;
+ border-radius: 0 0 8px 8px;
+}
+
+.edit-btn-cancel {
+ padding: 8px 12px;
+ background: white;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ color: #374151;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.edit-btn-cancel:hover {
+ background: #f3f4f6;
+}
+
+.edit-btn-confirm {
+ padding: 8px 12px;
+ background: #3b82f6;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: white;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.edit-btn-confirm:hover {
+ background: #2563eb;
+}
+
+.edit-btn-confirm:disabled {
+ background: #93c5fd;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/components/DimensionEditDropdown.jsx b/frontend/src/components/DimensionEditDropdown.jsx
new file mode 100644
index 00000000..60ddbd20
--- /dev/null
+++ b/frontend/src/components/DimensionEditDropdown.jsx
@@ -0,0 +1,155 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './DimensionEditDropdown.css';
+
+const DimensionEditDropdown = ({
+ isOpen,
+ anchorPosition, // { top, left } 定位
+ currentPair, // { dimensionA, dimensionB, descriptionA, descriptionB }
+ pairIndex, // 0/1/2 - 维度索引
+ onClose,
+ onConfirm, // (newPair, pairIndex) => void
+ isLoading = false,
+}) => {
+ const [editedA, setEditedA] = useState('');
+ const [editedB, setEditedB] = useState('');
+ const [editedDescA, setEditedDescA] = useState('');
+ const [editedDescB, setEditedDescB] = useState('');
+ const dropdownRef = useRef(null);
+
+ // 当currentPair变化时,更新输入框
+ useEffect(() => {
+ if (currentPair) {
+ setEditedA(currentPair.dimensionA || '');
+ setEditedB(currentPair.dimensionB || '');
+ setEditedDescA(currentPair.descriptionA || '');
+ setEditedDescB(currentPair.descriptionB || '');
+ }
+ }, [currentPair]);
+
+ // 点击外部关闭
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
+ onClose();
+ }
+ };
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen, onClose]);
+
+ // ESC键关闭
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+ if (isOpen) {
+ document.addEventListener('keydown', handleKeyDown);
+ }
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ const axisLabel = pairIndex === 0 ? 'X-axis' : pairIndex === 1 ? 'Y-axis' : 'Z-axis';
+ const hasChanges = editedA !== (currentPair?.dimensionA || '') ||
+ editedB !== (currentPair?.dimensionB || '') ||
+ editedDescA !== (currentPair?.descriptionA || '') ||
+ editedDescB !== (currentPair?.descriptionB || '');
+
+ const isValid = editedA.trim() && editedB.trim();
+
+ const handleConfirm = () => {
+ if (!isValid || isLoading) return;
+ onConfirm({
+ ...currentPair,
+ dimensionA: editedA.trim(),
+ dimensionB: editedB.trim(),
+ descriptionA: editedDescA.trim(),
+ descriptionB: editedDescB.trim(),
+ }, pairIndex);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Dimension {pairIndex + 1} ({axisLabel})
+
+
+
+
+ {/* Input fields */}
+
+
+
+ setEditedA(e.target.value)}
+ placeholder="Dimension A Name"
+ autoFocus
+ />
+
+
+
+ vs
+
+
+
+
+ setEditedB(e.target.value)}
+ placeholder="Dimension B Name"
+ />
+
+
+
+ {/* Footer buttons */}
+
+
+
+
+
+ );
+};
+
+export default DimensionEditDropdown;
diff --git a/frontend/src/components/DimensionSelector.css b/frontend/src/components/DimensionSelector.css
new file mode 100644
index 00000000..5961fb18
--- /dev/null
+++ b/frontend/src/components/DimensionSelector.css
@@ -0,0 +1,331 @@
+.dimension-selector {
+ width: 100%;
+ padding: 24px;
+ background: #f9fafb;
+ border-radius: 12px;
+ margin: 20px 0;
+}
+
+.dimension-selector-header {
+ margin-bottom: 24px;
+}
+
+.dimension-selector-header h3 {
+ font-size: 20px;
+ font-weight: 600;
+ color: #111827;
+ margin: 0 0 8px 0;
+}
+
+.dimension-selector-subtitle {
+ font-size: 14px;
+ color: #6b7280;
+ margin: 0;
+}
+
+.dimension-selector-loading {
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.spinner {
+ border: 3px solid #f3f4f6;
+ border-top: 3px solid #3b82f6;
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 16px;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.dimension-pairs-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.dimension-pair-card {
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.dimension-pair-card:hover {
+ border-color: #3b82f6;
+ box-shadow: 0 4px 6px rgba(59, 130, 246, 0.1);
+}
+
+.dimension-pair-card.selected {
+ border-color: #3b82f6;
+ background: #eff6ff;
+ box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
+}
+
+.dimension-pair-spectrum {
+ margin-bottom: 12px;
+}
+
+.dimension-end {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.dimension-label {
+ font-weight: 600;
+ color: #111827;
+ font-size: 14px;
+}
+
+.dimension-score {
+ font-size: 12px;
+ color: #6b7280;
+ background: #f3f4f6;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+
+.spectrum-line {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-top: 2px solid #e5e7eb;
+ border-bottom: 2px solid #e5e7eb;
+ margin: 8px 0;
+}
+
+.spectrum-arrow {
+ font-size: 18px;
+ color: #9ca3af;
+}
+
+.spectrum-center {
+ font-size: 12px;
+ color: #6b7280;
+ font-weight: 500;
+}
+
+.dimension-description {
+ font-size: 12px;
+ color: #6b7280;
+ margin: 4px 0;
+ line-height: 1.4;
+}
+
+.dimension-explanation {
+ font-size: 13px;
+ color: #374151;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid #e5e7eb;
+ font-style: italic;
+}
+
+.custom-dimension-button {
+ width: 100%;
+ padding: 12px 16px;
+ background: white;
+ border: 2px dashed #d1d5db;
+ border-radius: 8px;
+ color: #6b7280;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-bottom: 20px;
+}
+
+.custom-dimension-button:hover {
+ border-color: #3b82f6;
+ color: #3b82f6;
+ background: #f9fafb;
+}
+
+.custom-dimension-input {
+ background: white;
+ border: 2px solid #3b82f6;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.custom-dimension-input h4 {
+ margin: 0 0 16px 0;
+ font-size: 16px;
+ color: #111827;
+}
+
+.custom-input-row {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.custom-input-group {
+ flex: 1;
+}
+
+.custom-input-group label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ color: #374151;
+ margin-bottom: 8px;
+}
+
+.custom-input-group input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ margin-bottom: 8px;
+}
+
+.custom-input-group textarea {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 13px;
+ min-height: 60px;
+ resize: vertical;
+}
+
+.custom-input-divider {
+ font-size: 24px;
+ color: #9ca3af;
+ font-weight: bold;
+}
+
+.custom-input-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+.custom-input-actions button {
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.custom-input-actions button:first-child {
+ background: #3b82f6;
+ color: white;
+ border: none;
+}
+
+.custom-input-actions button:first-child:hover {
+ background: #2563eb;
+}
+
+.custom-input-actions button:last-child {
+ background: white;
+ color: #6b7280;
+ border: 1px solid #d1d5db;
+}
+
+.custom-input-actions button:last-child:hover {
+ background: #f9fafb;
+}
+
+.selected-pairs-summary {
+ background: white;
+ border: 2px solid #3b82f6;
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 20px;
+}
+
+.selected-pairs-summary h4 {
+ margin: 0 0 12px 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #111827;
+}
+
+.selected-pairs-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.selected-pair-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: #eff6ff;
+ border-radius: 6px;
+}
+
+.pair-number {
+ font-size: 13px;
+ font-weight: 600;
+ color: #3b82f6;
+}
+
+.pair-text {
+ flex: 1;
+ font-size: 14px;
+ color: #374151;
+}
+
+.remove-pair-button {
+ background: none;
+ border: none;
+ color: #9ca3af;
+ font-size: 20px;
+ cursor: pointer;
+ padding: 0 8px;
+ transition: color 0.2s ease;
+}
+
+.remove-pair-button:hover {
+ color: #ef4444;
+}
+
+.confirm-dimensions-button {
+ width: 100%;
+ padding: 14px 24px;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.confirm-dimensions-button:hover:not(:disabled) {
+ background: #2563eb;
+ box-shadow: 0 4px 6px rgba(37, 99, 235, 0.3);
+}
+
+.confirm-dimensions-button:disabled {
+ background: #d1d5db;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
diff --git a/frontend/src/components/DimensionSelector.jsx b/frontend/src/components/DimensionSelector.jsx
new file mode 100644
index 00000000..911740f0
--- /dev/null
+++ b/frontend/src/components/DimensionSelector.jsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import './DimensionSelector.css';
+
+const DimensionSelector = ({ intent, onDimensionsSelected }) => {
+ const [suggestedPairs, setSuggestedPairs] = useState([]);
+ const [selectedPairs, setSelectedPairs] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [showCustomInput, setShowCustomInput] = useState(false);
+ const [customPair, setCustomPair] = useState({
+ dimensionA: '',
+ dimensionB: '',
+ descriptionA: '',
+ descriptionB: '',
+ });
+
+ const fetchDimensionSuggestions = useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/suggest-dimensions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ intent }),
+ });
+ if (!response.ok) throw new Error('Failed to fetch dimension suggestions');
+ const data = await response.json();
+ setSuggestedPairs(data.dimension_pairs || []);
+ } catch (error) {
+ console.error('Error fetching dimension suggestions:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [intent]);
+
+ useEffect(() => {
+ if (intent) {
+ fetchDimensionSuggestions();
+ }
+ }, [intent, fetchDimensionSuggestions]);
+
+ const togglePairSelection = (pair) => {
+ if (selectedPairs.find(p => p.dimensionA === pair.dimensionA && p.dimensionB === pair.dimensionB)) {
+ setSelectedPairs(selectedPairs.filter(p => !(p.dimensionA === pair.dimensionA && p.dimensionB === pair.dimensionB)));
+ } else {
+ if (selectedPairs.length < 3) {
+ setSelectedPairs([...selectedPairs, pair]);
+ } else {
+ // Replace the oldest selected pair to keep recency
+ setSelectedPairs([...selectedPairs.slice(1), pair]);
+ }
+ }
+ };
+
+ const handleCustomPairSubmit = () => {
+ if (customPair.dimensionA && customPair.dimensionB) {
+ if (selectedPairs.length < 3) {
+ setSelectedPairs([...selectedPairs, customPair]);
+ } else {
+ setSelectedPairs([...selectedPairs.slice(1), customPair]);
+ }
+ setCustomPair({
+ dimensionA: '',
+ dimensionB: '',
+ descriptionA: '',
+ descriptionB: '',
+ });
+ setShowCustomInput(false);
+ }
+ };
+
+ const handleConfirm = () => {
+ if (selectedPairs.length === 3) {
+ onDimensionsSelected(selectedPairs);
+ }
+ };
+
+ const isPairSelected = (pair) => {
+ return selectedPairs.some(p => p.dimensionA === pair.dimensionA && p.dimensionB === pair.dimensionB);
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Suggesting evaluation dimensions...
+
+
+ );
+ }
+
+ return (
+
+
+
Select Evaluation Dimensions
+
+ Choose 3 dimension pairs to evaluate your ideas ({selectedPairs.length}/3 selected)
+
+
+
+
+ {suggestedPairs.map((pair, index) => (
+
togglePairSelection(pair)}
+ >
+
+
+ {pair.dimensionA}
+ 0
+
+
+
+ {pair.dimensionB}
+ 100
+
+
+
{pair.descriptionA}
+
{pair.descriptionB}
+ {pair.explanation && (
+
{pair.explanation}
+ )}
+
+ ))}
+
+
+ {!showCustomInput && (
+
+ )}
+
+ {showCustomInput && (
+
+
Custom Dimension Pair
+
+
+
+ setCustomPair({ ...customPair, dimensionA: e.target.value })}
+ />
+
+
↔
+
+
+ setCustomPair({ ...customPair, dimensionB: e.target.value })}
+ />
+
+
+
+
+
+
+
+ )}
+
+ {selectedPairs.length > 0 && (
+
+
Selected Dimensions:
+
+ {selectedPairs.map((pair, index) => (
+
+ Dimension {index + 1}:
+
+ {pair.dimensionA} ←→ {pair.dimensionB}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+};
+
+export default DimensionSelector;
diff --git a/frontend/src/components/DimensionSelectorModal.css b/frontend/src/components/DimensionSelectorModal.css
new file mode 100644
index 00000000..c8cae198
--- /dev/null
+++ b/frontend/src/components/DimensionSelectorModal.css
@@ -0,0 +1,350 @@
+/* 弹窗遮罩层 */
+.dimension-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2000;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+/* 弹窗主体 */
+.dimension-modal-content {
+ background: white;
+ border-radius: 12px;
+ width: 90%;
+ max-width: 900px;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(50px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+/* 弹窗头部 */
+.dimension-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 24px 30px;
+ border-bottom: 2px solid #e5e7eb;
+}
+
+.dimension-modal-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #1f2937;
+ font-weight: 600;
+}
+
+.dimension-modal-close {
+ background: none;
+ border: none;
+ font-size: 2rem;
+ color: #6b7280;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ transition: all 0.2s;
+}
+
+.dimension-modal-close:hover {
+ background-color: #f3f4f6;
+ color: #1f2937;
+}
+
+/* 加载状态 */
+.dimension-modal-loading {
+ padding: 60px;
+ text-align: center;
+}
+
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid #e5e7eb;
+ border-top-color: #3b82f6;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin: 0 auto 20px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.dimension-modal-loading p {
+ color: #6b7280;
+ font-size: 0.95rem;
+}
+
+/* 错误状态 */
+.dimension-modal-error {
+ padding: 40px;
+ text-align: center;
+}
+
+.dimension-modal-error p {
+ color: #dc2626;
+ margin-bottom: 16px;
+}
+
+.dimension-modal-error button {
+ padding: 8px 16px;
+ background-color: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+/* AI 建议的维度区域 */
+.dimension-modal-suggestions {
+ padding: 24px 30px;
+ background-color: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.dimension-modal-suggestions h3 {
+ margin: 0 0 20px 0;
+ font-size: 1.1rem;
+ color: #374151;
+ font-weight: 600;
+}
+
+.dimension-suggestions-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+
+.dimension-suggestion-card {
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 16px;
+ transition: all 0.2s;
+}
+
+.dimension-suggestion-card:hover {
+ border-color: #3b82f6;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
+}
+
+.dimension-pair-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.dimension-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #1f2937;
+ flex: 1;
+ text-align: center;
+}
+
+.dimension-arrow {
+ font-size: 1rem;
+ color: #9ca3af;
+}
+
+.dimension-explanation {
+ font-size: 0.8rem;
+ color: #6b7280;
+ line-height: 1.4;
+ margin: 0 0 12px 0;
+ min-height: 40px;
+}
+
+.dimension-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.btn-use-as-pair1,
+.btn-use-as-pair2 {
+ flex: 1;
+ padding: 6px 10px;
+ font-size: 0.75rem;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.btn-use-as-pair1 {
+ background-color: #dbeafe;
+ color: #1e40af;
+}
+
+.btn-use-as-pair1:hover {
+ background-color: #3b82f6;
+ color: white;
+}
+
+.btn-use-as-pair2 {
+ background-color: #fef3c7;
+ color: #92400e;
+}
+
+.btn-use-as-pair2:hover {
+ background-color: #f59e0b;
+ color: white;
+}
+
+/* 自定义维度区域 */
+.dimension-modal-custom {
+ padding: 24px 30px;
+}
+
+.dimension-modal-custom h3 {
+ margin: 0 0 20px 0;
+ font-size: 1.1rem;
+ color: #374151;
+ font-weight: 600;
+}
+
+.dimension-custom-pair {
+ margin-bottom: 20px;
+ padding: 16px;
+ background-color: #f9fafb;
+ border-radius: 8px;
+ border: 2px solid #e5e7eb;
+}
+
+.dimension-pair-label {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #4b5563;
+ margin-bottom: 12px;
+}
+
+.dimension-input-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.dimension-input {
+ flex: 1;
+ padding: 10px 14px;
+ border: 2px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ transition: all 0.2s;
+}
+
+.dimension-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.dimension-input::placeholder {
+ color: #9ca3af;
+}
+
+.dimension-separator {
+ font-size: 1.2rem;
+ color: #6b7280;
+ font-weight: 600;
+}
+
+/* 底部按钮 */
+.dimension-modal-footer {
+ padding: 20px 30px;
+ border-top: 2px solid #e5e7eb;
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.btn-cancel,
+.btn-confirm {
+ padding: 10px 24px;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-cancel {
+ background-color: #f3f4f6;
+ color: #4b5563;
+}
+
+.btn-cancel:hover {
+ background-color: #e5e7eb;
+}
+
+.btn-confirm {
+ background-color: #3b82f6;
+ color: white;
+}
+
+.btn-confirm:hover {
+ background-color: #2563eb;
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .dimension-modal-content {
+ width: 95%;
+ max-height: 95vh;
+ }
+
+ .dimension-suggestions-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .dimension-input-row {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .dimension-separator {
+ transform: rotate(90deg);
+ }
+}
diff --git a/frontend/src/components/DimensionSelectorModal.jsx b/frontend/src/components/DimensionSelectorModal.jsx
new file mode 100644
index 00000000..5328b5d1
--- /dev/null
+++ b/frontend/src/components/DimensionSelectorModal.jsx
@@ -0,0 +1,214 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import './DimensionSelectorModal.css';
+
+/**
+ * 弹窗式维度选择器
+ * 用户输入 Intent 后弹出,显示 3 个 AI 建议的维度对
+ * 用户可以选择预设或自定义 2 对维度
+ */
+const DimensionSelectorModal = ({ isOpen, onClose, onConfirm, intent }) => {
+ const [suggestedPairs, setSuggestedPairs] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 用户自定义的2对维度 (4个输入框)
+ const [customDimensions, setCustomDimensions] = useState({
+ pair1: { dimensionA: '', dimensionB: '' },
+ pair2: { dimensionA: '', dimensionB: '' }
+ });
+
+ const fetchSuggestedDimensions = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await fetch('/api/suggest-dimensions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ intent })
+ });
+
+ if (!response.ok) throw new Error('Failed to fetch dimension suggestions');
+
+ const data = await response.json();
+ setSuggestedPairs(data.dimension_pairs || []);
+ } catch (err) {
+ console.error('Error fetching dimensions:', err);
+ setError('Failed to load dimension suggestions. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [intent]);
+
+ // 获取 AI 建议的维度对
+ useEffect(() => {
+ if (isOpen && intent) {
+ fetchSuggestedDimensions();
+ }
+ }, [isOpen, intent, fetchSuggestedDimensions]);
+
+ // 点击预设选项,填入对应的输入框
+ const handleSelectPreset = (pair, targetPairIndex) => {
+ setCustomDimensions(prev => ({
+ ...prev,
+ [`pair${targetPairIndex}`]: {
+ dimensionA: pair.dimensionA,
+ dimensionB: pair.dimensionB
+ }
+ }));
+ };
+
+ // 输入框变化
+ const handleInputChange = (pairIndex, dimension, value) => {
+ setCustomDimensions(prev => ({
+ ...prev,
+ [`pair${pairIndex}`]: {
+ ...prev[`pair${pairIndex}`],
+ [dimension]: value
+ }
+ }));
+ };
+
+ // 确认选择
+ const handleConfirm = () => {
+ const pair1 = customDimensions.pair1;
+ const pair2 = customDimensions.pair2;
+
+ // 验证必须填写2对维度
+ if (!pair1.dimensionA || !pair1.dimensionB || !pair2.dimensionA || !pair2.dimensionB) {
+ alert('Please fill in both dimension pairs (all 4 fields)');
+ return;
+ }
+
+ // 返回选中的2对维度
+ const selectedPairs = [
+ { dimensionA: pair1.dimensionA, dimensionB: pair1.dimensionB },
+ { dimensionA: pair2.dimensionA, dimensionB: pair2.dimensionB }
+ ];
+
+ onConfirm(selectedPairs);
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Select Evaluation Dimensions
+
+
+
+ {isLoading && (
+
+
+
Generating dimension suggestions...
+
+ )}
+
+ {error && (
+
+ )}
+
+ {!isLoading && !error && (
+ <>
+ {/* AI 建议的 3 对维度 */}
+
+
AI Suggested Dimension Pairs (click to use)
+
+ {suggestedPairs.map((pair, index) => (
+
+
+ {pair.dimensionA}
+ ←→
+ {pair.dimensionB}
+
+
{pair.explanation}
+
+
+
+
+
+ ))}
+
+
+
+ {/* 用户自定义的 2 对维度 (4个输入框) */}
+
+
Your Selected Dimensions
+
+ {/* 第一对维度 */}
+
+
+ {/* 第二对维度 */}
+
+
+
+ {/* 确认按钮 */}
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default DimensionSelectorModal;
diff --git a/frontend/src/components/DimensionSelectorPanel.css b/frontend/src/components/DimensionSelectorPanel.css
new file mode 100644
index 00000000..d4134fab
--- /dev/null
+++ b/frontend/src/components/DimensionSelectorPanel.css
@@ -0,0 +1,507 @@
+/* 下拉展开面板容器 - 覆盖在原界面上方 */
+.dimension-selector-panel {
+ position: absolute;
+ top: 100%;
+ /* 紧贴在 Intent 输入框下方 */
+ left: 0;
+ right: 0;
+ margin-top: 8px;
+ background: white;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
+ z-index: 1000;
+ max-height: 80vh;
+ overflow-y: auto;
+ animation: slideDown 0.3s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 面板头部 */
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid #e5e7eb;
+ position: sticky;
+ top: 0;
+ background: white;
+ z-index: 1;
+}
+
+.panel-header h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #111827;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ font-size: 28px;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.close-btn:hover {
+ background: #f3f4f6;
+ color: #111827;
+}
+
+/* 加载状态 */
+.panel-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ color: #6b7280;
+}
+
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid #e5e7eb;
+ border-top-color: #3b82f6;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin-bottom: 16px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.panel-loading p {
+ margin: 0;
+ font-size: 14px;
+}
+
+/* 错误状态 */
+.panel-error {
+ padding: 40px 20px;
+ text-align: center;
+ color: #dc2626;
+}
+
+.panel-error p {
+ margin: 0 0 16px 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #dc2626;
+}
+
+.panel-error button {
+ padding: 8px 16px;
+ background: #dc2626;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background 0.2s;
+}
+
+.panel-error button:hover {
+ background: #b91c1c;
+}
+
+/* 左右两列布局容器 */
+.panel-two-columns {
+ display: flex;
+ gap: 24px;
+ padding: 20px;
+ align-items: stretch;
+}
+
+/* AI 建议区域 - 左列 */
+.suggestions-section {
+ flex: 3;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.suggestions-section h4 {
+ margin: 0 0 16px 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: #374151;
+ text-align: center;
+}
+
+/* 建议列表 - 垂直排列 */
+.suggestions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ flex: 1;
+}
+
+/* 保留旧的grid样式以备不时之需 */
+.suggestions-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+
+.suggestion-card {
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 16px;
+ transition: all 0.2s;
+ cursor: pointer;
+ position: relative;
+ flex: 1;
+ /* Distribute available vertical space equally */
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.suggestion-card:hover {
+ background: #f3f4f6;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ transform: translateY(-2px);
+ z-index: 1;
+}
+
+.suggestion-card.selected {
+ border-color: #3b82f6;
+ background: #eff6ff;
+ box-shadow: 0 6px 18px rgba(59, 130, 246, 0.15);
+}
+
+.suggestion-content {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ gap: 8px;
+ flex: 1;
+}
+
+.dimension-side {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ /* Allow text wrapping */
+}
+
+.side-a {
+ text-align: right;
+ align-items: flex-end;
+}
+
+.side-b {
+ text-align: left;
+ align-items: flex-start;
+}
+
+.dimension-label-a {
+ color: #3b82f6;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.dimension-label-b {
+ color: #f59e0b;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.description-label-a {
+ color: #60a5fa;
+ /* Lighter blue */
+ font-size: 14px;
+ margin: 0;
+ line-height: 1.3;
+ opacity: 0.8;
+
+}
+
+.description-label-b {
+ color: #f59e0b;
+ /* Same amber/orange but opacity or lighter shade if needed, keeping readable */
+ font-size: 14px;
+ margin: 0;
+ line-height: 1.3;
+ opacity: 0.8;
+}
+
+.vs-separator-vertical {
+ color: #9ca3af;
+ font-size: 12px;
+ font-weight: 600;
+ display: flex;
+ align-items: flex-start;
+ /* Align with top (titles) */
+ padding-top: 2px;
+}
+
+.selected-badge {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: #dbeafe;
+ color: #1e3a8a;
+ border: 1px solid #bfdbfe;
+ border-radius: 999px;
+ padding: 4px 8px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.selection-hint {
+ margin-top: 8px;
+ font-size: 12px;
+ color: #4b5563;
+}
+
+/* 自定义输入区域 - 右列 */
+.custom-section {
+ flex: 4;
+ /* Increased from 2 to accommodate larger inputs */
+ display: flex;
+ flex-direction: column;
+}
+
+.custom-section h4 {
+ margin: 0 0 16px 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: #374151;
+}
+
+/* 输入框容器 - 带背景的盒子 */
+.custom-inputs-box {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ flex: 1;
+ justify-content: space-between;
+ padding: 20px;
+ background: #f9fafb;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+}
+
+/* 垂直排列的输入框 - 保留旧样式 */
+.custom-inputs-vertical {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ flex: 1;
+ justify-content: space-between;
+}
+
+/* 保留旧的grid样式以备不时之需 */
+.custom-inputs-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 20px;
+}
+
+.custom-inputs-grid.three-cols {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.dimension-pair-inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: white;
+ padding: 12px;
+ border-radius: 6px;
+ border: 1px solid #e5e7eb;
+}
+
+.pair-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 4px;
+}
+
+/* New layout for dimension pair inputs */
+.pair-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.dimension-field-group {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.dimension-input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 2px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: all 0.2s;
+ box-sizing: border-box;
+}
+
+.description-input {
+ width: 100%;
+ padding: 6px 10px;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ font-size: 12px;
+ color: #4b5563;
+ transition: all 0.2s;
+ box-sizing: border-box;
+ background-color: white;
+ resize: vertical;
+ font-family: inherit;
+}
+
+.description-input:focus,
+.dimension-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ background-color: white;
+}
+
+.dimension-input:focus {
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
+}
+
+.dimension-input::placeholder {
+ color: #9ca3af;
+}
+
+.description-input::placeholder {
+ color: #9ca3af;
+ font-style: italic;
+}
+
+.input-separator {
+ padding-top: 10px;
+ text-align: center;
+ color: #9ca3af;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+/* 底部按钮区 */
+.panel-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 16px 20px;
+ border-top: 1px solid #e5e7eb;
+ background: white;
+ position: sticky;
+ bottom: 0;
+}
+
+.btn-cancel,
+.btn-confirm {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-cancel {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.btn-cancel:hover {
+ background: #e5e7eb;
+}
+
+.btn-confirm {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-confirm:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
+}
+
+/* 响应式设计 */
+@media (max-width: 1024px) {
+ .panel-two-columns {
+ flex-direction: column;
+ }
+
+ .custom-section {
+ flex: none;
+ width: 100%;
+ }
+
+ .suggestions-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .suggestions-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .custom-inputs-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .dimension-selector-panel {
+ max-height: 70vh;
+ }
+}
+
+/* 滚动条美化 */
+.dimension-selector-panel::-webkit-scrollbar {
+ width: 8px;
+}
+
+.dimension-selector-panel::-webkit-scrollbar-track {
+ background: #f3f4f6;
+}
+
+.dimension-selector-panel::-webkit-scrollbar-thumb {
+ background: #9ca3af;
+ border-radius: 4px;
+}
+
+.dimension-selector-panel::-webkit-scrollbar-thumb:hover {
+ background: #6b7280;
+}
diff --git a/frontend/src/components/DimensionSelectorPanel.jsx b/frontend/src/components/DimensionSelectorPanel.jsx
new file mode 100644
index 00000000..d6a6e1b0
--- /dev/null
+++ b/frontend/src/components/DimensionSelectorPanel.jsx
@@ -0,0 +1,360 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import './DimensionSelectorPanel.css';
+
+/**
+ * 下拉展开面板式维度选择器
+ * 在 Intent 输入框下方展开,覆盖在界面上
+ * 显示 3 个 AI 建议的维度对,用户可选择或自定义 3 对维度
+ */
+const DimensionSelectorPanel = ({ isOpen, onClose, onConfirm, intent }) => {
+ const [suggestedPairs, setSuggestedPairs] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 用户自定义的3对维度
+ const [customDimensions, setCustomDimensions] = useState({
+ pair1: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' },
+ pair2: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' },
+ pair3: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' }
+ });
+
+ const fetchSuggestedDimensions = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await fetch('/api/suggest-dimensions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include', // 重要: 包含 session cookie
+ body: JSON.stringify({ intent })
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to fetch dimension suggestions');
+ }
+
+ const data = await response.json();
+ setSuggestedPairs(data.dimension_pairs || []);
+ } catch (err) {
+ console.error('Error fetching dimensions:', err);
+ setError(err.message || 'Failed to load dimension suggestions. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [intent]);
+
+ // 获取 AI 建议的维度对
+ useEffect(() => {
+ if (isOpen && intent) {
+ fetchSuggestedDimensions();
+ }
+ }, [isOpen, intent, fetchSuggestedDimensions]);
+
+ const findPairSlotForSuggestion = (pair, dimensions) => {
+ return Object.entries(dimensions).find(([, value]) =>
+ value.dimensionA === pair.dimensionA && value.dimensionB === pair.dimensionB
+ )?.[0] || null;
+ };
+
+ const findFirstEmptyPairSlot = (dimensions) => {
+ return ['pair1', 'pair2', 'pair3'].find(key => {
+ const pair = dimensions[key];
+ return !pair.dimensionA && !pair.dimensionB;
+ }) || null;
+ };
+
+ // 点击建议卡片,按顺序填入空的 pair 槽位;再次点击则清空该槽位
+ const handleToggleSuggestion = (pair) => {
+ setCustomDimensions(prev => {
+ const existingSlot = findPairSlotForSuggestion(pair, prev);
+ if (existingSlot) {
+ return {
+ ...prev,
+ [existingSlot]: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' }
+ };
+ }
+
+ const emptySlot = findFirstEmptyPairSlot(prev);
+ if (!emptySlot) return prev;
+
+ return {
+ ...prev,
+ [emptySlot]: {
+ dimensionA: pair.dimensionA,
+ dimensionB: pair.dimensionB,
+ descriptionA: pair.descriptionA || '',
+ descriptionB: pair.descriptionB || ''
+ }
+ };
+ });
+ };
+
+ // 更新自定义输入框
+ const handleInputChange = (pairIndex, dimension, value) => {
+ setCustomDimensions(prev => ({
+ ...prev,
+ [`pair${pairIndex}`]: {
+ ...prev[`pair${pairIndex}`],
+ [dimension]: value
+ }
+ }));
+ };
+
+ // 确认选择
+ const handleConfirm = () => {
+ const { pair1, pair2, pair3 } = customDimensions;
+
+ // 验证所有输入框都已填写
+ if (
+ !pair1.dimensionA || !pair1.dimensionB ||
+ !pair2.dimensionA || !pair2.dimensionB ||
+ !pair3.dimensionA || !pair3.dimensionB
+ ) {
+ alert('Please fill in all dimension fields or select from suggestions above.');
+ return;
+ }
+
+ // 返回3对维度
+ const selectedPairs = [
+ {
+ dimensionA: pair1.dimensionA,
+ dimensionB: pair1.dimensionB,
+ descriptionA: pair1.descriptionA,
+ descriptionB: pair1.descriptionB
+ },
+ {
+ dimensionA: pair2.dimensionA,
+ dimensionB: pair2.dimensionB,
+ descriptionA: pair2.descriptionA,
+ descriptionB: pair2.descriptionB
+ },
+ {
+ dimensionA: pair3.dimensionA,
+ dimensionB: pair3.dimensionB,
+ descriptionA: pair3.descriptionA,
+ descriptionB: pair3.descriptionB
+ }
+ ];
+
+ onConfirm(selectedPairs);
+
+ // 重置状态
+ setCustomDimensions({
+ pair1: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' },
+ pair2: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' },
+ pair3: { dimensionA: '', dimensionB: '', descriptionA: '', descriptionB: '' }
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Select Evaluation Dimensions
+
+
+
+ {isLoading && (
+
+
+
Generating dimension suggestions...
+
+ )}
+
+ {error && (
+
+ )}
+
+ {!isLoading && !error && suggestedPairs.length > 0 && (
+ <>
+ {/* 左右两列布局 */}
+
+ {/* 左列:AI 建议的预设选项 */}
+
+
AI Suggested Dimensions (click to select)
+
+ {suggestedPairs.map((pair, index) => {
+ const selectedSlot = findPairSlotForSuggestion(pair, customDimensions);
+ const isSelected = !!selectedSlot;
+
+ return (
+
handleToggleSuggestion(pair)}
+ >
+ {isSelected && (
+
Pair {selectedSlot.replace('pair', '')}
+ )}
+
+
+
+
{pair.dimensionA}
+ {pair.descriptionA && (
+
{pair.descriptionA}
+ )}
+
+
+
vs
+
+
+
{pair.dimensionB}
+ {pair.descriptionB && (
+
{pair.descriptionB}
+ )}
+
+
+
+ );
+ })}
+
+
+
+ {/* 右列:用户选择的3个槽位 */}
+
+
Your Selected Dimensions
+
+ {/* Pair 1 */}
+
+
Pair 1 (X-axis)
+
+
+ handleInputChange(1, 'dimensionA', e.target.value)}
+ />
+
+
vs
+
+ handleInputChange(1, 'dimensionB', e.target.value)}
+ />
+
+
+
+
+ {/* Pair 2 */}
+
+
Pair 2 (Y-axis)
+
+
+ handleInputChange(2, 'dimensionA', e.target.value)}
+ />
+
+
vs
+
+ handleInputChange(2, 'dimensionB', e.target.value)}
+ />
+
+
+
+
+ {/* Pair 3 */}
+
+
Pair 3 (Z-axis)
+
+
+ handleInputChange(3, 'dimensionA', e.target.value)}
+ />
+
+
vs
+
+ handleInputChange(3, 'dimensionB', e.target.value)}
+ />
+
+
+
+
+
+
+
+ {/* 底部操作按钮 */}
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+export default DimensionSelectorPanel;
diff --git a/frontend/src/components/Evaluation3D.jsx b/frontend/src/components/Evaluation3D.jsx
new file mode 100644
index 00000000..201b02b0
--- /dev/null
+++ b/frontend/src/components/Evaluation3D.jsx
@@ -0,0 +1,1011 @@
+import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
+import { Canvas, useFrame, useThree } from '@react-three/fiber';
+import { TrackballControls, Text, Line, OrthographicCamera, Billboard } from '@react-three/drei';
+import * as THREE from 'three';
+
+// --- Constants ---
+const CUBE_SIZE = 100;
+const HALF_SIZE = CUBE_SIZE / 2;
+const NODE_RANGE = 90;
+const LABEL_COLOR = '#374151';
+const GRID_COLOR = '#e5e7eb';
+const CROSSHAIR_COLOR = '#9ca3af';
+
+// Keep node coloring consistent with Tree View
+const getNodeColor = (node, colorMap) => {
+ if (!node) return '#FF6B6B';
+ if (node.isMergedResult) return '#B22222';
+ if (node.isNewlyGenerated || node.isModified) return '#FFD700';
+ return (colorMap && colorMap[node.type]) || '#FF6B6B';
+};
+
+// --- Helpers ---
+const scoreToPos = (score) => {
+ // Score range: -50 to 50 → Position range: -NODE_RANGE/2 to NODE_RANGE/2
+ const clamped = Math.max(-50, Math.min(50, score));
+ return (clamped / 50) * (NODE_RANGE / 2);
+};
+const posToScore = (pos) => Math.max(-50, Math.min(50, (pos / (NODE_RANGE / 2)) * 50));
+const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
+const hasVector2 = (pos) => !!pos && isFiniteNumber(pos.x) && isFiniteNumber(pos.y);
+const hasVector3 = (pos) => !!pos && isFiniteNumber(pos.x) && isFiniteNumber(pos.y) && isFiniteNumber(pos.z);
+
+// --- Components ---
+
+const CameraController = ({ targetFaceIndex, targetUp, isSnapping, setSnapped, setIsSnapping, controlsRef }) => {
+ const { camera } = useThree();
+
+ // 6 orthogonal view positions
+ const faceConfigs = useMemo(() => [
+ { pos: [0, 0, 1] }, // Front
+ { pos: [1, 0, 0] }, // Right
+ { pos: [0, 0, -1] }, // Back
+ { pos: [-1, 0, 0] }, // Left
+ { pos: [0, 1, 0] }, // Top
+ { pos: [0, -1, 0] }, // Bottom
+ ], []);
+
+ useFrame(() => {
+ if (isSnapping && targetFaceIndex !== null && targetUp) {
+ const R = 200;
+ const config = faceConfigs[targetFaceIndex];
+ const targetPos = new THREE.Vector3(...config.pos).multiplyScalar(R);
+
+ // Lerp position and up vector
+ camera.position.lerp(targetPos, 0.15);
+ camera.up.lerp(targetUp, 0.15);
+
+ // Sync OrbitControls internal state
+ if (controlsRef?.current) {
+ controlsRef.current.update();
+ }
+
+ const dist = camera.position.distanceTo(targetPos);
+
+ if (dist < 0.5) {
+ camera.position.copy(targetPos);
+ camera.up.copy(targetUp);
+ if (controlsRef?.current) {
+ controlsRef.current.update();
+ }
+ setSnapped(true);
+ setIsSnapping(false);
+ }
+ }
+ });
+ return null;
+};
+
+const WireframeBox = () => {
+ return (
+
+
+
+
+ );
+};
+
+const ProjectedAxis = ({ type }) => {
+ const points = useMemo(() => {
+ if (type === 'x') return [[-HALF_SIZE, 0, 0], [HALF_SIZE, 0, 0]];
+ if (type === 'y') return [[0, -HALF_SIZE, 0], [0, HALF_SIZE, 0]];
+ if (type === 'z') return [[0, 0, -HALF_SIZE], [0, 0, HALF_SIZE]];
+ return [[0, 0, 0], [0, 0, 0]];
+ }, [type]);
+
+ return (
+
+
+
+ );
+};
+
+const AxisLabel = ({ position, text, rotationZ, anchorX, anchorY, fontSize = 3.5 }) => {
+ const { camera } = useThree();
+ const ref = useRef();
+ const rotQuat = useMemo(() => new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), rotationZ), [rotationZ]);
+
+ useFrame(() => {
+ if (!ref.current) return;
+ ref.current.quaternion.copy(camera.quaternion);
+ if (rotationZ !== 0) {
+ ref.current.quaternion.multiply(rotQuat);
+ }
+ });
+
+ return (
+
+
+ {text}
+
+
+ );
+};
+
+const AxisLabels3D = ({ activeFace, dims, isSnapped }) => {
+ const faceNormals = [
+ { id: 0, v: new THREE.Vector3(0, 0, 1), perpAxis: 'z' }, // Front
+ { id: 1, v: new THREE.Vector3(1, 0, 0), perpAxis: 'x' }, // Right
+ { id: 2, v: new THREE.Vector3(0, 0, -1), perpAxis: 'z' }, // Back
+ { id: 3, v: new THREE.Vector3(-1, 0, 0), perpAxis: 'x' }, // Left
+ { id: 4, v: new THREE.Vector3(0, 1, 0), perpAxis: 'y' }, // Top
+ { id: 5, v: new THREE.Vector3(0, -1, 0), perpAxis: 'y' }, // Bottom
+ ];
+
+ const axisDefs = [
+ { key: 'x', vec: new THREE.Vector3(1, 0, 0), dimIndex: 0 },
+ { key: 'y', vec: new THREE.Vector3(0, 1, 0), dimIndex: 1 },
+ { key: 'z', vec: new THREE.Vector3(0, 0, 1), dimIndex: 2 }
+ ];
+
+ const faceNormal = faceNormals[activeFace].v || faceNormals[0].v;
+
+ return (
+ <>
+ {axisDefs.map(({ key, vec, dimIndex }) => {
+ const dim = dims[dimIndex];
+ if (!dim) return null;
+
+ const isPerpendicular = Math.abs(vec.clone().normalize().dot(faceNormal)) > 0.999;
+ if (isSnapped && isPerpendicular) return null;
+
+ const posA = vec.clone().multiplyScalar(-HALF_SIZE);
+ const posB = vec.clone().multiplyScalar(HALF_SIZE);
+
+ return (
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+const Node3D = ({ node, position, color, isSelected, isHovered, onPointerOver, onPointerOut, onClick, onDragEnd, axisMapping, isSnapped, allNodes, dims, setIsDraggingNode, setDragVisual3D, setDragVisualState, projectToFace, dragVisual3D, dragVisualState, activeCount, activeDimensionIndices, dragHoverTarget }) => {
+ const meshRef = useRef();
+ const [isDragging, setIsDragging] = useState(false);
+ const { camera, raycaster } = useThree();
+ const dragPlane = useMemo(() => new THREE.Plane(), []);
+ const intersection = useMemo(() => new THREE.Vector3(), []);
+ const dragOffset = useMemo(() => new THREE.Vector3(), []);
+ const dragStart = useRef(new THREE.Vector3());
+ const movedRef = useRef(false);
+
+ // Nodes stay at their true 3D position (no projection to face)
+ const targetPos = new THREE.Vector3(...position);
+
+ useFrame(() => {
+ if (meshRef.current && !isDragging) {
+ meshRef.current.position.lerp(targetPos, 0.15);
+ }
+ });
+
+ // Get normal vector for the perpendicular axis
+ const getPerpendicularNormal = (axis) => {
+ switch (axis) {
+ case 'x': return new THREE.Vector3(1, 0, 0);
+ case 'y': return new THREE.Vector3(0, 1, 0);
+ case 'z': return new THREE.Vector3(0, 0, 1);
+ default: return new THREE.Vector3(0, 0, 1);
+ }
+ };
+
+ const handlePointerDown = (e) => {
+ e.stopPropagation();
+ if (e.button !== 0) return;
+
+ // Prevent canvas mousedown from triggering rotation
+ e.nativeEvent?.stopImmediatePropagation();
+
+ setIsDragging(true);
+ if (setIsDraggingNode) setIsDraggingNode(true);
+ movedRef.current = false;
+
+ let normal;
+ if (activeCount === 1) {
+ normal = new THREE.Vector3(0, 0, 1); // Force drag plane to be XY for 1D (allowing X movement)
+ } else {
+ normal = isSnapped && axisMapping
+ ? getPerpendicularNormal(axisMapping.perpAxis)
+ : new THREE.Vector3().copy(camera.position).normalize();
+ }
+ dragPlane.setFromNormalAndCoplanarPoint(normal, meshRef.current.position);
+ if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
+ dragOffset.copy(intersection).sub(meshRef.current.position);
+ }
+ dragStart.current.copy(meshRef.current.position);
+ e.target.setPointerCapture(e.pointerId);
+ };
+
+ const handlePointerMove = (e) => {
+ if (!isDragging) return;
+ e.stopPropagation();
+ if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
+ const newPos = intersection.sub(dragOffset);
+ const lim = NODE_RANGE / 2;
+ newPos.x = Math.max(-lim, Math.min(lim, newPos.x));
+
+ // For 1D view, restrict dragging to X-axis only
+ if (activeCount === 1) {
+ newPos.y = 0; // Force Y to 0
+ newPos.z = 0; // Force Z to 0
+ } else {
+ newPos.y = Math.max(-lim, Math.min(lim, newPos.y));
+ newPos.z = Math.max(-lim, Math.min(lim, newPos.z));
+ }
+ meshRef.current.position.copy(newPos);
+ movedRef.current = true;
+
+ // live visual for modify/merge - lock perpendicular axis
+ const curr = newPos.clone();
+ if (isSnapped && axisMapping) {
+ if (axisMapping.perpAxis === 'x') curr.x = position[0];
+ if (axisMapping.perpAxis === 'y') curr.y = position[1];
+ if (axisMapping.perpAxis === 'z') curr.z = position[2];
+ }
+ let mergeTargetId = null;
+ let mergeTargetPos = null;
+ let best = Infinity;
+ const thresh = 8;
+ allNodes.forEach(other => {
+ if (other.id === node.id || other.isGhost) return;
+ const px = scoreToPos(other.scores?.[`${dims[0]?.dimensionA}-${dims[0]?.dimensionB}`] ?? 0);
+ const py = scoreToPos(other.scores?.[`${dims[1]?.dimensionA}-${dims[1]?.dimensionB}`] ?? 0);
+ const pz = scoreToPos(other.scores?.[`${dims[2]?.dimensionA}-${dims[2]?.dimensionB}`] ?? 0);
+ const facePos = projectToFace(new THREE.Vector3(px, py, pz));
+ const dragFace = projectToFace(curr);
+ const d = Math.hypot(dragFace.a - facePos.a, dragFace.b - facePos.b);
+ if (d < thresh && d < best) {
+ best = d;
+ mergeTargetId = other.id;
+ mergeTargetPos = new THREE.Vector3(px, py, pz);
+ }
+ });
+ if (setDragVisual3D) {
+ if (mergeTargetId) {
+ setDragVisual3D({ type: 'merge', sourceId: node.id, current: curr.clone(), targetId: mergeTargetId, targetPos: mergeTargetPos });
+ } else {
+ setDragVisual3D({ type: 'modify', sourceId: node.id, start: dragStart.current.clone(), current: curr.clone() });
+ }
+ }
+ if (setDragVisualState) {
+ if (mergeTargetId && mergeTargetPos) {
+ setDragVisualState({
+ type: 'merge',
+ sourceNodeId: node.id,
+ targetNodeId: mergeTargetId,
+ ghostPosition: { x: dragStart.current.x, y: dragStart.current.y, z: dragStart.current.z },
+ targetPosition: { x: mergeTargetPos.x, y: mergeTargetPos.y, z: mergeTargetPos.z }
+ });
+ } else {
+ setDragVisualState({
+ type: 'modify',
+ sourceNodeId: node.id,
+ ghostPosition: { x: dragStart.current.x, y: dragStart.current.y, z: dragStart.current.z },
+ newPosition: { x: curr.x, y: curr.y, z: curr.z }
+ });
+ }
+ }
+ }
+ };
+
+ const handlePointerUp = (e) => {
+ if (!isDragging) return;
+ e.stopPropagation();
+ setIsDragging(false);
+ if (setIsDraggingNode) setIsDraggingNode(false);
+ e.target.releasePointerCapture(e.pointerId);
+ if (!movedRef.current) {
+ if (onClick) onClick(node);
+ return;
+ }
+ const curr = meshRef.current.position.clone();
+
+ // Lock perpendicular axis when snapped
+ if (isSnapped && activeCount > 1 && axisMapping) { // Apply for 2D/3D only
+ if (axisMapping.perpAxis === 'x') curr.x = position[0];
+ if (axisMapping.perpAxis === 'y') curr.y = position[1];
+ if (axisMapping.perpAxis === 'z') curr.z = position[2];
+ } else if (activeCount === 1) { // For 1D, ensure Y and Z are 0
+ curr.y = 0;
+ curr.z = 0;
+ }
+
+ // Build new score map
+ const scoreFromAxis = (val) => posToScore(val);
+ const scoresMap = {};
+ if (activeCount === 1) {
+ const activeDimIndex = activeDimensionIndices[0];
+ if (dims[activeDimIndex]) {
+ scoresMap[`${dims[activeDimIndex].dimensionA}-${dims[activeDimIndex].dimensionB}`] = scoreFromAxis(curr.x);
+ }
+ } else { // 2D or 3D
+ if (dims[0]) scoresMap[`${dims[0].dimensionA}-${dims[0].dimensionB}`] = scoreFromAxis(curr.x);
+ if (dims[1]) scoresMap[`${dims[1].dimensionA}-${dims[1].dimensionB}`] = scoreFromAxis(curr.y);
+ if (dims[2]) scoresMap[`${dims[2].dimensionA}-${dims[2].dimensionB}`] = scoreFromAxis(curr.z);
+ }
+
+ // Merge detection in 2D plane (use passed-in projectToFace)
+ const dragged2D = projectToFace(curr);
+ let mergeTargetId = null;
+ let mergeTargetPos = null;
+ let bestDist = Infinity;
+ const threshold = 8; // scene units
+
+ allNodes.forEach(other => {
+ if (other.id === node.id || other.isGhost) return;
+ const targetVec = new THREE.Vector3(
+ scoreToPos(other.scores?.[`${dims[0]?.dimensionA}-${dims[0]?.dimensionB}`] ?? 0),
+ scoreToPos(other.scores?.[`${dims[1]?.dimensionA}-${dims[1]?.dimensionB}`] ?? 0),
+ scoreToPos(other.scores?.[`${dims[2]?.dimensionA}-${dims[2]?.dimensionB}`] ?? 0)
+ );
+ const pos = projectToFace(targetVec);
+ const dx = dragged2D.a - pos.a;
+ const dy = dragged2D.b - pos.b;
+ const d = Math.hypot(dx, dy);
+ if (d < threshold && d < bestDist) {
+ bestDist = d;
+ mergeTargetId = other.id;
+ mergeTargetPos = targetVec;
+ }
+ });
+
+ if (setDragVisual3D) {
+ if (mergeTargetId && mergeTargetPos) {
+ setDragVisual3D({ type: 'merge', sourceId: node.id, current: curr.clone(), targetId: mergeTargetId, targetPos: mergeTargetPos.clone(), hold: true });
+ } else {
+ setDragVisual3D({ type: 'modify', sourceId: node.id, start: dragStart.current.clone(), current: curr.clone(), hold: true });
+ }
+ }
+ if (onDragEnd) onDragEnd(node.id, { scoresMap, mergeTargetId, clientX: e.clientX, clientY: e.clientY }, e);
+ };
+
+ return (
+
+ { e.stopPropagation(); onPointerOver(node) }}
+ onPointerOut={(e) => { e.stopPropagation(); onPointerOut() }}
+ scale={
+ (dragVisualState?.type === 'merge' && dragVisualState.targetNodeId === node.id) ||
+ (dragVisual3D?.type === 'merge' && dragVisual3D.targetId === node.id) ||
+ (dragHoverTarget === node.id)
+ ? 1.35
+ : ((isHovered || isDragging) && !isSelected ? (activeCount === 1 ? 1.5 : 1.2) : (activeCount === 1 ? 1.2 : 1))
+ }
+ >
+
+
+ {isSelected && (
+
+
+
+
+ )}
+
+
+
+ {node.title}
+
+
+
+ );
+};
+
+const SceneContent = ({
+ nodes,
+ dimensions,
+ onNodeDragEnd,
+ selectedNode,
+ onNodeClick,
+ hoveredNode,
+ onNodeHover,
+ targetFaceIndex,
+ setTargetFaceIndex,
+ pendingChange,
+ pendingMerge,
+ colorMap,
+ dragVisualState,
+ setDragVisualState,
+ mergeAnimationState,
+ activeDimensionIndices,
+ onDropExternal,
+ dragHoverTarget,
+ onDragHover
+}) => {
+ const { camera, gl, scene, raycaster } = useThree();
+ const controlsRef = useRef();
+ const [isSnapped, setSnapped] = useState(true);
+ const [isSnapping, setIsSnapping] = useState(false);
+ const [isDraggingNode, setIsDraggingNode] = useState(false);
+ const [dragVisual3D, setDragVisual3D] = useState(null);
+ const [targetUp, setTargetUp] = useState(null);
+ const [axisMapping, setAxisMapping] = useState({
+ perpAxis: 'z',
+ horizontalAxis: 'x',
+ verticalAxis: 'y'
+ });
+
+ // Handle external drops (HTML5 DnD)
+ useEffect(() => {
+ const canvas = gl.domElement;
+ // Prevent global DOM tracker from double-counting canvas-level events; track nodes semantically instead.
+ canvas.setAttribute('data-tracking-name', 'evaluation_3d_canvas');
+ canvas.setAttribute('data-uatrack-suppress-click', 'true');
+ canvas.setAttribute('data-uatrack-suppress-hover', 'true');
+ canvas.setAttribute('data-uatrack-suppress-drag', 'true');
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!onDragHover) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const mouse = new THREE.Vector2(
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
+ -((e.clientY - rect.top) / rect.height) * 2 + 1
+ );
+
+ raycaster.setFromCamera(mouse, camera);
+
+ const intersects = raycaster.intersectObjects(scene.children, true);
+ const hit = intersects.find(intersect =>
+ intersect.object.userData && intersect.object.userData.nodeId
+ );
+
+ if (hit) {
+ onDragHover(hit.object.userData.nodeId);
+ } else {
+ onDragHover(null);
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Clear hover state on drop
+ if (onDragHover) onDragHover(null);
+
+ if (!onDropExternal) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const mouse = new THREE.Vector2(
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
+ -((e.clientY - rect.top) / rect.height) * 2 + 1
+ );
+
+ raycaster.setFromCamera(mouse, camera);
+
+ // Filter for objects that are likely our nodes
+ const intersects = raycaster.intersectObjects(scene.children, true);
+
+ // Find the first intersected object that has nodeId in userData
+ const hit = intersects.find(intersect =>
+ intersect.object.userData && intersect.object.userData.nodeId
+ );
+
+ if (hit) {
+ const nodeId = hit.object.userData.nodeId;
+ const targetNode = nodes.find(n => n.id === nodeId);
+ if (targetNode) {
+ onDropExternal(e, targetNode);
+ return;
+ }
+ }
+
+ // If no valid node hit, drop on empty space
+ onDropExternal(e, null);
+ };
+
+ canvas.addEventListener('dragover', handleDragOver);
+ canvas.addEventListener('drop', handleDrop);
+ // Also handle dragleave to clear hover
+ const handleDragLeave = () => {
+ if (onDragHover) onDragHover(null);
+ };
+ canvas.addEventListener('dragleave', handleDragLeave);
+
+ return () => {
+ canvas.removeEventListener('dragover', handleDragOver);
+ canvas.removeEventListener('drop', handleDrop);
+ canvas.removeEventListener('dragleave', handleDragLeave);
+ };
+ }, [gl, camera, scene, raycaster, nodes, onDropExternal, onDragHover]);
+
+ // Compute axis mapping based on camera orientation
+ const computeAxisMapping = useCallback((cam) => {
+ const direction = new THREE.Vector3();
+ cam.getWorldDirection(direction);
+
+ const up = cam.up.clone().normalize();
+ const right = new THREE.Vector3().crossVectors(direction, up).normalize();
+
+ const axes = ['x', 'y', 'z'];
+ const vecs = {
+ x: new THREE.Vector3(1, 0, 0),
+ y: new THREE.Vector3(0, 1, 0),
+ z: new THREE.Vector3(0, 0, 1)
+ };
+
+ // 1. perpAxis: axis most aligned with view direction
+ let perpAxis = 'z', maxDirDot = 0;
+ axes.forEach(axis => {
+ const dot = Math.abs(direction.dot(vecs[axis]));
+ if (dot > maxDirDot) {
+ maxDirDot = dot;
+ perpAxis = axis;
+ }
+ });
+
+ // 2. horizontalAxis: remaining axis most aligned with screen right
+ const remaining = axes.filter(a => a !== perpAxis);
+ let horizontalAxis = remaining[0], maxRightDot = 0;
+ remaining.forEach(axis => {
+ const dot = Math.abs(right.dot(vecs[axis]));
+ if (dot > maxRightDot) {
+ maxRightDot = dot;
+ horizontalAxis = axis;
+ }
+ });
+
+ // 3. verticalAxis: the remaining one
+ const verticalAxis = remaining.find(a => a !== horizontalAxis);
+
+ return { perpAxis, horizontalAxis, verticalAxis };
+ }, []);
+
+ // Compute target up vector based on current camera orientation
+ const computeTargetUp = useCallback((cam, perpAxis) => {
+ // Get actual screen Y direction from camera matrix (not camera.up)
+ const screenUp = new THREE.Vector3();
+ cam.matrixWorld.extractBasis(new THREE.Vector3(), screenUp, new THREE.Vector3());
+ screenUp.normalize();
+
+ // Two axes on screen (non-perp)
+ const axes = ['x', 'y', 'z'].filter(a => a !== perpAxis);
+ const vecs = {
+ x: new THREE.Vector3(1, 0, 0),
+ y: new THREE.Vector3(0, 1, 0),
+ z: new THREE.Vector3(0, 0, 1)
+ };
+
+ // Find which axis is closer to screen vertical direction
+ const dot0 = Math.abs(screenUp.dot(vecs[axes[0]]));
+ const dot1 = Math.abs(screenUp.dot(vecs[axes[1]]));
+ const verticalAxis = dot0 > dot1 ? axes[0] : axes[1];
+
+ // Determine sign based on dot product (preserve visual direction)
+ const dot = screenUp.dot(vecs[verticalAxis]);
+ return dot > 0 ? vecs[verticalAxis].clone() : vecs[verticalAxis].clone().negate();
+ }, []);
+
+ const handleEnd = () => {
+ const camDir = camera.position.clone().normalize();
+ const directions = [
+ { id: 0, v: new THREE.Vector3(0, 0, 1), perpAxis: 'z' }, // Front
+ { id: 1, v: new THREE.Vector3(1, 0, 0), perpAxis: 'x' }, // Right
+ { id: 2, v: new THREE.Vector3(0, 0, -1), perpAxis: 'z' }, // Back
+ { id: 3, v: new THREE.Vector3(-1, 0, 0), perpAxis: 'x' }, // Left
+ { id: 4, v: new THREE.Vector3(0, 1, 0), perpAxis: 'y' }, // Top
+ { id: 5, v: new THREE.Vector3(0, -1, 0), perpAxis: 'y' }, // Bottom
+ ];
+
+ let best = 0, maxDot = -Infinity;
+ directions.forEach(d => {
+ const dot = camDir.dot(d.v);
+ if (dot > maxDot) {
+ maxDot = dot;
+ best = d.id;
+ }
+ });
+
+ // Compute axis mapping based on current camera orientation
+ const mapping = computeAxisMapping(camera);
+ setAxisMapping(mapping);
+
+ // Only snap if very close to an orthogonal face (threshold: 0.95)
+ const SNAP_THRESHOLD = 0.95;
+ const shouldSnap = maxDot > SNAP_THRESHOLD;
+
+ if (shouldSnap) {
+ // Compute target up vector based on current screen orientation
+ const up = computeTargetUp(camera, directions[best].perpAxis);
+ setTargetUp(up);
+
+ setTargetFaceIndex(best);
+ setIsSnapping(true);
+ setSnapped(false);
+ }
+
+ };
+
+ const handleStart = () => {
+ setSnapped(false);
+ setIsSnapping(false);
+ };
+
+ useEffect(() => {
+ const canvas = document.querySelector('canvas');
+ if (!canvas) return;
+
+ const handleWheel = (e) => {
+ e.preventDefault();
+ camera.zoom = Math.max(1, Math.min(10, camera.zoom - e.deltaY * 0.005));
+ camera.updateProjectionMatrix();
+ };
+
+ canvas.addEventListener('wheel', handleWheel, { passive: false });
+
+ return () => {
+ canvas.removeEventListener('wheel', handleWheel);
+ };
+ }, [camera]);
+
+ // Project 3D position to 2D plane based on axis mapping
+ const projectToPlane = useCallback((vec) => {
+ const getVal = (axis) => axis === 'x' ? vec.x : axis === 'y' ? vec.y : vec.z;
+ return {
+ a: getVal(axisMapping.horizontalAxis),
+ b: getVal(axisMapping.verticalAxis)
+ };
+ }, [axisMapping]);
+
+ // Filter out root node once at the top level
+ const validNodes = useMemo(() => nodes.filter(node => node.type !== 'root'), [nodes]);
+
+ // Clear internal dragVisual3D when parent clears dragVisualState (on cancel/error)
+ useEffect(() => {
+ if (!dragVisualState && dragVisual3D) {
+ setDragVisual3D(null);
+ }
+ }, [dragVisualState, dragVisual3D, setDragVisual3D]);
+
+ // Camera Transition Logic based on Active Dimensions
+ const activeCount = activeDimensionIndices ? activeDimensionIndices.length : 3;
+
+ const mergeTargetPosition = useMemo(() => {
+ if (!dragVisualState || dragVisualState.type !== 'merge') return null;
+ if (hasVector3(dragVisualState.targetPosition)) return dragVisualState.targetPosition;
+ if (!dragVisualState.targetNodeId) return null;
+ const targetNode = nodes.find(n => n.id === dragVisualState.targetNodeId);
+ if (!targetNode) return null;
+
+ const getScore = (dimObj) => {
+ if (!dimObj) return 0;
+ const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
+ if (targetNode.scores && targetNode.scores[key] !== undefined) return targetNode.scores[key];
+ return 0;
+ };
+
+ let x, y, z;
+ if (activeCount === 1) {
+ const activeIdx = activeDimensionIndices ? activeDimensionIndices[0] : 0;
+ const activeDim = dimensions[activeIdx];
+ x = getScore(activeDim);
+ y = 0;
+ z = 0;
+ } else {
+ x = getScore(dimensions[0]);
+ y = getScore(dimensions[1]);
+ z = getScore(dimensions[2]);
+ }
+
+ return { x: scoreToPos(x), y: scoreToPos(y), z: scoreToPos(z) };
+ }, [dragVisualState, nodes, dimensions, activeCount, activeDimensionIndices]);
+ const mergeGhostPosition = useMemo(() => {
+ if (!dragVisualState || dragVisualState.type !== 'merge') return null;
+ if (hasVector3(dragVisualState.ghostPosition)) return dragVisualState.ghostPosition;
+ if (!hasVector2(dragVisualState.ghostPosition)) return null;
+ if (!gl?.domElement) return null;
+
+ const rect = gl.domElement.getBoundingClientRect();
+ if (!rect.width || !rect.height) return null;
+
+ const mouse = new THREE.Vector2(
+ (dragVisualState.ghostPosition.x / rect.width) * 2 - 1,
+ -((dragVisualState.ghostPosition.y / rect.height) * 2 - 1)
+ );
+
+ raycaster.setFromCamera(mouse, camera);
+ const viewDir = new THREE.Vector3();
+ camera.getWorldDirection(viewDir);
+ const planePoint = mergeTargetPosition
+ ? new THREE.Vector3(mergeTargetPosition.x, mergeTargetPosition.y, mergeTargetPosition.z)
+ : new THREE.Vector3(0, 0, 0);
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(viewDir, planePoint);
+ const hit = new THREE.Vector3();
+ const intersected = raycaster.ray.intersectPlane(plane, hit);
+ if (!intersected) return null;
+ return { x: hit.x, y: hit.y, z: hit.z };
+ }, [dragVisualState, gl, camera, raycaster, mergeTargetPosition]);
+
+ useEffect(() => {
+ if (activeCount < 3) {
+ // Determine target face and up vector to align axes with screen
+ // Requirement: First dimension (Horizontal) -> Left to Right
+ // Second dimension (Vertical) -> Bottom to Top
+ let face = 0; // Default Front
+ let up = new THREE.Vector3(0, 1, 0);
+
+ if (activeCount === 2) {
+ const indices = activeDimensionIndices || [0, 1];
+ const [a, b] = [...indices].sort((i, j) => i - j);
+
+ if (a === 0 && b === 1) {
+ // XY: Front View (Looking at Z-)
+ // X (Horizontal): Left(-X) -> Right(+X)
+ // Y (Vertical): Bottom(-Y) -> Top(+Y)
+ face = 0;
+ up.set(0, 1, 0);
+ } else if (a === 0 && b === 2) {
+ // XZ: Bottom View (Looking at Y+)
+ // X (Horizontal): Left(-X) -> Right(+X)
+ // Z (Vertical): Bottom(-Z is Down? No, we want A(-Z) at Bottom).
+ // If Up is Z+, Screen Top is Z+, Screen Bottom is Z-.
+ // So A(-Z) is Bottom. B(+Z) is Top. Correct.
+ face = 5;
+ up.set(0, 0, 1);
+ } else if (a === 1 && b === 2) {
+ // YZ: Right View (Looking at X-)
+ // Y (Horizontal): Left(-Y) -> Right(+Y)
+ // Z (Vertical): Bottom(-Z) -> Top(+Z) (With Up=Z+)
+ face = 1;
+ up.set(0, 0, 1);
+ }
+ } else {
+ // 1D: Always Front (mapped to X)
+ face = 0;
+ up.set(0, 1, 0);
+ }
+
+ setTargetFaceIndex(face);
+ setTargetUp(up);
+ setIsSnapping(true);
+ setSnapped(false);
+ }
+ }, [activeCount, activeDimensionIndices, setTargetFaceIndex, setTargetUp, setIsSnapping, setSnapped]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {activeCount === 3 && }
+
+ {/* Render ProjectedAxis based on activeCount */}
+ {activeCount === 1 && }
+ {activeCount === 2 && (
+ <>
+ {activeDimensionIndices.includes(0) && }
+ {activeDimensionIndices.includes(1) && }
+ {activeDimensionIndices.includes(2) && }
+ >
+ )}
+ {activeCount === 3 && (
+ <>
+
+
+
+ >
+ )}
+
+ {activeCount === 1 ? (
+ // For 1D, pass only the single active dimension (mapped to index 0 for correct X-axis labeling)
+
+ ) : (
+ // For 2D/3D, pass all dims (nulls will be filtered inside AxisLabels3D component logic itself)
+
+ )}
+
+ {dragVisualState && dragVisualState.type === 'modify' && dragVisualState.ghostPosition && dragVisualState.newPosition && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {dragVisualState && dragVisualState.type === 'merge' && mergeGhostPosition && mergeTargetPosition && dragVisualState.targetNodeId && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {dimensions.filter(d => d !== null).length === 0 && (Select Dimensions)}
+
+ {validNodes.map(node => {
+ if (node.isGhost) return null;
+
+ // Check if all required dimension scores are available
+ // In 1D/2D mode, we only care about active dimensions
+ const hasAllScores = dimensions.every((dimObj, idx) => {
+ if (!dimObj) return true; // Inactive dimension
+ // For 1D, we only care about the single active dimension
+ if (activeCount === 1 && idx !== activeDimensionIndices[0]) return true;
+ // For 2D, we only care about the two active dimensions
+ if (activeCount === 2 && !activeDimensionIndices.includes(idx)) return true;
+
+ const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
+ return node.scores && node.scores[key] !== undefined;
+ });
+
+ // Don't render node if scoring is incomplete
+ if (!hasAllScores) return null;
+
+ const getScore = (dimObj) => {
+ if (!dimObj) return 0; // Changed from 50 to 0 (center in -50 to 50 range)
+ const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
+ if (node.scores && node.scores[key] !== undefined) return node.scores[key];
+ return 0; // Changed from 50 to 0
+ };
+
+ let x, y, z;
+ if (activeCount === 1) {
+ // 1D View: Remap the single active dimension to X axis
+ // Find the active dimension
+ const activeIdx = activeDimensionIndices ? activeDimensionIndices[0] : 0;
+ const activeDim = dimensions[activeIdx];
+ x = getScore(activeDim);
+ y = 0; // Center (0 is the center in -50 to 50 range)
+ z = 0; // Center
+ } else {
+ // 2D/3D View: Standard mapping
+ x = getScore(dimensions[0]);
+ y = getScore(dimensions[1]);
+ z = getScore(dimensions[2]);
+ }
+
+ const overridePos = dragVisual3D && dragVisual3D.sourceId === node.id && dragVisual3D.current;
+ const pos = overridePos
+ ? [overridePos.x, overridePos.y, overridePos.z]
+ : [scoreToPos(x), scoreToPos(y), scoreToPos(z)];
+
+ const color = getNodeColor(node, colorMap);
+
+ return (
+ onNodeHover(null)}
+ onClick={onNodeClick} onDragEnd={onNodeDragEnd}
+ axisMapping={axisMapping} isSnapped={isSnapped} allNodes={validNodes} dims={dimensions} setIsDraggingNode={setIsDraggingNode}
+ setDragVisual3D={setDragVisual3D} setDragVisualState={setDragVisualState} projectToFace={projectToPlane} dragVisual3D={dragVisual3D} dragVisualState={dragVisualState}
+ activeCount={activeCount} // Pass activeCount to Node3D
+ activeDimensionIndices={activeDimensionIndices} // Pass activeDimensionIndices to Node3D
+ dragHoverTarget={dragHoverTarget}
+ />
+ );
+ })}
+ >
+ );
+};
+
+const Evaluation3D = (props) => {
+ const [targetFaceIndex, setTargetFaceIndex] = useState(0);
+ const colorMap = useMemo(() => ({
+ root: '#4C84FF',
+ simple: '#45B649',
+ complex: '#FF6B6B',
+ }), []);
+ const activeDims = useMemo(() => {
+ if (!props.selectedDimensionPairs) return [null, null, null];
+ const indices = props.activeDimensionIndices || [0, 1, 2];
+ return [0, 1, 2].map(i => {
+ if (indices.includes(i)) return props.selectedDimensionPairs[i];
+ return null;
+ });
+ }, [props.selectedDimensionPairs, props.activeDimensionIndices]);
+
+ // Determine if we should show the operation status
+ const showOperationStatus = props.operationStatus &&
+ (props.operationStatus.toLowerCase().includes('merging') ||
+ props.operationStatus.toLowerCase().includes('evaluating') ||
+ props.operationStatus.toLowerCase().includes('modifying') ||
+ props.isGenerating);
+
+ return (
+
+ {/* Operation Status Banner */}
+ {showOperationStatus && (
+
+ {props.operationStatus || 'Processing...'}
+
+ )}
+
+
+ Drag Cube to Rotate • Drag Nodes to Edit
+
+
+ );
+};
+
+export default Evaluation3D;
diff --git a/frontend/src/components/FactorBlock.jsx b/frontend/src/components/FactorBlock.jsx
deleted file mode 100644
index 23427f73..00000000
--- a/frontend/src/components/FactorBlock.jsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-
-/**
- * 单个因子(Novelty / Feasibility / Impact)分数与理由
- */
-const FactorBlock = ({
- label,
- color,
- score,
- reason,
- beforeVal,
- afterVal,
- showAfter,
-}) => {
- const hasBefore = beforeVal !== null && beforeVal !== undefined;
- const round = (v) => Math.round(parseFloat(v) || 0);
-
- /* 箭头 */
- const Arrow = () => {
- if (!hasBefore) return null;
- const b = round(beforeVal);
- const a = round(afterVal);
- if (a > b) return ▲;
- if (a < b) return ▼;
- return ─;
- };
-
- return (
-
-
-
-
- {label}
-
- {hasBefore ? (
- <>
- {round(beforeVal)}
-
- {round(afterVal)}
- >
- ) : (
-
Score: {round(score)}
- )}
-
-
-
- {reason || '(No reason provided)'}
-
-
- );
-};
-
-export default FactorBlock;
diff --git a/frontend/src/components/IdeaCard.css b/frontend/src/components/IdeaCard.css
new file mode 100644
index 00000000..5c2f6f34
--- /dev/null
+++ b/frontend/src/components/IdeaCard.css
@@ -0,0 +1,17 @@
+.problem-highlight {
+ background-color: #fef08a;
+ color: #92400e;
+ border-radius: 4px;
+ padding: 0 4px;
+ cursor: pointer;
+}
+
+.problem-highlight:hover {
+ box-shadow: 0 0 0 2px #fef08a inset;
+}
+
+.problem-content ::selection,
+.dimension-reasoning ::selection {
+ background: #fef08a;
+ color: #92400e;
+}
diff --git a/frontend/src/components/IdeaCard.jsx b/frontend/src/components/IdeaCard.jsx
index bc9bf24b..e67474e6 100644
--- a/frontend/src/components/IdeaCard.jsx
+++ b/frontend/src/components/IdeaCard.jsx
@@ -1,372 +1,1038 @@
-import React, { useState, useRef, useEffect } from 'react';
-import ReactMarkdown from 'react-markdown';
-import remarkGfm from 'remark-gfm';
-
-// Helper function to parse the specific markdown table format
-const parseMarkdownTable = (markdown) => {
- if (!markdown || typeof markdown !== 'string') return [];
- const lines = markdown.trim().split('\n');
- if (lines.length < 3) return []; // Header, separator, at least one data row
-
- // Assumes fixed headers: | Component | Specification | Justification / Rationale | Status |
- const dataLines = lines.slice(2); // Skip header and separator
-
- return dataLines.map(line => {
- const cells = line.split('|').map(cell => cell.trim());
- // cells[0] is empty because of the leading '|'
- if (cells.length < 5) return null; // Handle malformed rows
- return {
- Component: cells[1] || '',
- Specification: cells[2] || '',
- 'Justification / Rationale': cells[3] || '',
- Status: cells[4] || ''
- };
- }).filter(Boolean); // Filter out any null rows
-};
-
-// Helper function to serialize data back to a markdown table string
-const serializeToMarkdown = (data) => {
- const header = '| Component | Specification | Justification / Rationale | Status |';
- const separator = '|---|---|---|---|';
- const rows = data.map(row =>
- `| ${row.Component} | ${row.Specification.replace(/\n/g, '
')} | ${row['Justification / Rationale'].replace(/\n/g, '
')} | ${row.Status} |`
- );
- return [header, separator, ...rows].join('\n');
-};
-
-/**
- * A new component for interactively editing the experiment table.
- */
-const EditableTable = ({ content, onSave, onCancel }) => {
- const [tableData, setTableData] = useState(() => parseMarkdownTable(content));
- const originalData = useRef(parseMarkdownTable(content)); // To store for reset
-
- const handleCellChange = (index, field, value) => {
- const newData = [...tableData];
- newData[index][field] = value;
- setTableData(newData);
- };
-
- const handleSave = () => {
- const newMarkdown = serializeToMarkdown(tableData);
- onSave(newMarkdown);
- };
+import React, { useState } from 'react';
+import { extractContentSections } from '../utils/contentParser';
+import './IdeaCard.css';
- const handleReset = () => {
- setTableData(originalData.current);
- };
+const escapeRegExp = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const ExperimentTable = ({ src }) => {
+ if (!src || typeof src !== 'string') return null;
+ const lines = src.trim().split('\n').filter(l => l.trim());
+ if (lines.length < 2) return {src};
+ const parseRow = (line) =>
+ line.split('|').map(c => c.trim()).filter((_, i, arr) => i !== 0 && i !== arr.length - 1);
+ const headers = parseRow(lines[0]);
+ const dataRows = lines.slice(2).map(parseRow);
return (
-
-
-
-
-
- {['Component', 'Specification', 'Justification / Rationale', 'Status'].map(header => (
- | {header} |
+
+
+
+
+ {headers.map((h, i) => (
+ | {h} |
+ ))}
+
+
+
+ {dataRows.map((row, ri) => (
+
+ {row.map((cell, ci) => (
+ | {cell} |
))}
-
-
- {tableData.map((row, rowIndex) => (
-
- | {row.Component} |
-
- |
-
- |
- {row.Status} |
-
- ))}
-
-
-
-
-
-
-
-
+ ))}
+
+
);
};
+const renderWithHighlights = (text, highlights, onClickHighlight) => {
+ if (!text || !highlights || highlights.length === 0) return text;
+ const sanitized = highlights
+ .map(h => (h || '').trim())
+ .filter(Boolean);
-/**
- * A reusable modal component, now with an optional onEdit handler.
- */
-const Modal = ({ isOpen, onClose, children, title, onEdit }) => {
- if (!isOpen) {
- return null;
- }
+ if (sanitized.length === 0) return text;
- return (
-
-
e.stopPropagation()}
- style={{
- backgroundColor: '#fff',
- borderRadius: '8px',
- padding: '24px',
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
- border: '1px solid #e5e7eb',
- width: '80%',
- maxWidth: '900px',
- maxHeight: '80vh',
- display: 'flex',
- flexDirection: 'column',
+ const regex = new RegExp(`(${sanitized.map(h => escapeRegExp(h)).join('|')})`, 'gi');
+ return text.split(regex).map((part, idx) => {
+ if (!part) return null;
+ const isHit = sanitized.some(h => h.toLowerCase() === part.toLowerCase());
+ if (!isHit) return part;
+ const payload = part.trim();
+ return (
+
{
+ e.stopPropagation();
+ if (payload && onClickHighlight) {
+ onClickHighlight(payload);
+ }
}}
>
-
-
-
- {title || 'Details'}
-
- {/* Edit button only appears if onEdit function is provided */}
- {onEdit && (
-
- )}
-
-
-
-
- {children}
-
-
-
- );
-};
-
-
-/**
- * A styled component to render Markdown content.
- */
-const MarkdownRenderer = ({ content }) => {
- const tableStyles = `
- .markdown-table-container table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.9rem; }
- .markdown-table-container th, .markdown-table-container td { border: 1px solid #dfe2e5; padding: 8px 12px; text-align: left; }
- .markdown-table-container th { background-color: #f6f8fa; font-weight: 600; }
- .markdown-table-container tr:nth-child(even) { background-color: #f9fafb; }
- `;
- const safeContent = typeof content === 'string' ? content : '';
- return (
-
-
-
- {safeContent || "No content available."}
-
-
- );
+ {part}
+
+ );
+ });
};
-
/**
- * The main card component with editable experiment plans.
+ * 显示单条假设,可在 Before / After 之间切换
+ * 现在支持动态维度系统:
+ * - 如果有 selectedDimensionPairs,则显示动态维度分数
*/
const IdeaCard = ({
node,
showAfter,
setShowAfter,
- onEditCriteria,
- activeSection,
- setActiveSection,
- onUpdateTable, // Receive the update handler
+ onModifyScore, // Function to handle score modifications
+ showModifyButton = false, // Whether to show modify buttons (for tree view)
+ pendingChanges = null, // Pending changes from parent component
+ currentView = 'evaluation', // Current view context (exploration/evaluation)
+ selectedDimensionPairs = null, // 新增:选中的维度对,用于显示动态分数
+ activeDimensions = null, // 当前显示的维度面,用于高亮
+ onShowFragmentMenu = null, // 新增:显示 Fragment 菜单的回调
+ activeDimensionIndices = [0, 1, 2], // Active dimension indices
+ onToggleDimensionIndex = null, // Callback to toggle dimension index
+ onCreateFragmentFromHighlight = null, // 新增:点击重点直接生成 fragment
+ onSwapDimension = null, // 交换维度方向
+ onEditDimension = null, // 编辑维度名称 (pairIndex, anchorRect) => void
}) => {
- const [showComparisonModal, setShowComparisonModal] = useState(false);
- const [showExperimentModal, setShowExperimentModal] = useState(false);
- const [isEditingExperiment, setIsEditingExperiment] = useState(false);
+ const [expandedSections, setExpandedSections] = useState({ experiment: false });
- const buttonRefs = useRef({});
+ const toggleSection = (sectionName) => {
+ setExpandedSections(prev => ({
+ ...prev,
+ [sectionName]: !prev[sectionName]
+ }));
+ };
+ // Handle root node differently
if (node.type === 'root') {
return (
-
-
Research Intent
-
+
+
+ {'Research Intent'}
+
+
+
+ {node.content}
+
+
+ );
+ }
+
+ // Handle fragment node
+ if (node.type === 'fragment') {
+ return (
+
+
+
+ {node.id.split('-S')[1]}
+
+
+
+ Fragment
+
+
+ from {node.parentId}
+
+
+
+
+
{node.content}
+
+
+ 💡 This fragment has no evaluation scores yet. Merge it with other ideas to evaluate.
+
);
}
+ // Regular idea node handling
const hasPrevious = !!node.previousState;
- const displayedNode = hasPrevious && !showAfter ? node.previousState : node;
-
- const { originalData } = displayedNode;
- const description = originalData?.Description || '';
- const importance = originalData?.Importance || '';
- const difficulty = originalData?.Difficulty || '';
- const noveltyComparison = originalData?.NoveltyComparison || '';
- const comparisonTable = originalData?.ComparisonTable || '';
- const experimentTable = originalData?.ExperimentTable || '';
-
- let activeContent = '';
- if (activeSection === 'Impact') activeContent = importance;
- else if (activeSection === 'Feasibility') activeContent = difficulty;
- else if (activeSection === 'Novelty') activeContent = noveltyComparison;
-
- const editIcon = (
-
- );
+ const hasNext = !hasPrevious && !!node.modifiedState;
+ const alternateNode = hasPrevious
+ ? node.previousState
+ : (hasNext ? node.modifiedState : null);
+ const isShowingAlternate = !!alternateNode && !showAfter;
+ const displayNode = isShowingAlternate ? alternateNode : node;
+ const content = displayNode?.content || '';
+
+ // TODO: Edit icon for future score editing UI
+ // const editIcon = (
);
+
+ // 解析内容中的各个章节
+ const sections = extractContentSections(content);
+
+ // 找到问题部分(作为主要内容显示)
+ const problemSection = sections.find(section => section.title === 'Problem');
+ const problemHighlights = node.problemHighlights || node.originalData?.problem_highlights || [];
+
+ // 处理文本选择事件
+ const handleMouseUp = (e) => {
+ if (!onShowFragmentMenu) {
+ console.log('[Fragment Debug] onShowFragmentMenu not provided');
+ return;
+ }
+
+ // 保存 currentTarget 引用(React 合成事件在异步回调中会被清空)
+ const cardElement = e.currentTarget;
+
+ // 延迟获取 selection,确保选择完成
+ setTimeout(() => {
+ const selection = window.getSelection();
+ const selectedText = selection.toString().trim();
+
+ console.log('[Fragment Debug] Text selected:', selectedText);
- const getScore = (sectionName) => {
- const scoreMap = { 'Impact': 'impactScore', 'Feasibility': 'feasibilityScore', 'Novelty': 'noveltyScore' };
- const scoreField = scoreMap[sectionName];
- if (!scoreField) return null;
- return hasPrevious && !showAfter ? node.previousState[scoreField] : node[scoreField];
+ if (selectedText && selectedText.length > 0 && selection.rangeCount > 0) {
+ // 检查选中的文本是否在当前 IdeaCard 内
+ const range = selection.getRangeAt(0);
+ if (cardElement && range.commonAncestorContainer &&
+ cardElement.contains(range.commonAncestorContainer)) {
+
+ // 检查选中的文本是否在Problem部分内
+ let currentNode = range.commonAncestorContainer;
+ let inProblemSection = false;
+
+ // 向上遍历DOM树,查找data-section="problem"的元素
+ console.log('[Fragment Debug] Starting node:', currentNode);
+ let debugPath = [];
+ while (currentNode && currentNode !== cardElement) {
+ if (currentNode.nodeType === 1) {
+ const sectionAttr = currentNode.getAttribute ? currentNode.getAttribute('data-section') : null;
+ debugPath.push(`${currentNode.nodeName}[data-section="${sectionAttr}"]`);
+ if (sectionAttr === 'problem') {
+ inProblemSection = true;
+ break;
+ }
+ }
+ currentNode = currentNode.parentNode;
+ }
+
+ console.log('[Fragment Debug] DOM path:', debugPath.join(' > '));
+ console.log('[Fragment Debug] In Problem section:', inProblemSection);
+
+ // 只有在Problem部分选中文字才显示Fragment菜单
+ if (inProblemSection) {
+ // 获取选择的屏幕位置
+ const rect = range.getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.bottom + 5; // 菜单显示在选中文本下方
+
+ console.log('[Fragment Debug] Showing fragment menu at', x, y);
+ onShowFragmentMenu(x, y, selectedText, node.id);
+ }
+ }
+ }
+ }, 10);
};
- const sectionColors = {
- "Impact": "#4040a1",
- "Feasibility": "#50394c",
- "Novelty": "#618685",
+ // 获取维度分数(如果使用新系统)
+ const getDimensionScores = (targetNode) => {
+ if (!selectedDimensionPairs || selectedDimensionPairs.length < 2) {
+ return null; // 不显示动态维度分数
+ }
+
+ if (!targetNode || !targetNode.scores) {
+ return null; // 节点还没有动态维度分数
+ }
+
+ return selectedDimensionPairs.map((pair, index) => {
+ const primaryKey = `${pair.dimensionA}-${pair.dimensionB}`;
+ const reverseKey = `${pair.dimensionB}-${pair.dimensionA}`;
+
+ const extractScoreValue = (entry) => {
+ if (entry === null || entry === undefined) return null;
+ if (typeof entry === 'number') return entry;
+ if (typeof entry === 'object' && entry.value !== undefined) return entry.value;
+ return null;
+ };
+
+ const orientScoreValue = (value, flipped) => {
+ if (!flipped || typeof value !== 'number') return value;
+ if (value >= -50 && value <= 50) return -value;
+ if (value >= 0 && value <= 100) return 100 - value;
+ return -value;
+ };
+
+ const resolveScore = () => {
+ const getScoreEntry = (k) => {
+ if (!k) return undefined;
+ if (targetNode.scores && targetNode.scores[k] !== undefined) return targetNode.scores[k];
+ if (targetNode.originalData && targetNode.originalData.scores && targetNode.originalData.scores[k] !== undefined) {
+ return targetNode.originalData.scores[k];
+ }
+ return undefined;
+ };
+
+ const primaryEntry = getScoreEntry(primaryKey);
+ const primaryValue = extractScoreValue(primaryEntry);
+ if (primaryValue !== null) {
+ return { value: primaryValue, reasonKey: primaryKey };
+ }
+
+ const reverseEntry = getScoreEntry(reverseKey);
+ const reverseValue = extractScoreValue(reverseEntry);
+ if (reverseValue !== null) {
+ return { value: orientScoreValue(reverseValue, true), reasonKey: reverseKey };
+ }
+
+ return { value: null, reasonKey: primaryKey };
+ };
+
+ const { value: rawScore, reasonKey } = resolveScore();
+
+ // Helper: extract a reason for this dimension pair from multiple possible locations
+ const extractReasonForPair = () => {
+ try {
+ // 0) PRIORITY: Check for Dimension1Reason / Dimension2Reason directly on node or originalData
+ const dimensionReasonKey = `Dimension${index + 1}Reason`;
+ if (targetNode[dimensionReasonKey]) return targetNode[dimensionReasonKey];
+ if (targetNode.originalData && targetNode.originalData[dimensionReasonKey]) {
+ return targetNode.originalData[dimensionReasonKey];
+ } // 1) originalData.scores may contain structured info
+ if (targetNode.originalData && targetNode.originalData.scores && targetNode.originalData.scores[reasonKey]) {
+ const s = targetNode.originalData.scores[reasonKey];
+ if (s && typeof s === 'object') {
+ if (s.reason) return s.reason;
+ if (s.reasons && Array.isArray(s.reasons)) return s.reasons.join('\n');
+ }
+ }
+
+ // 2) node.scores entry might be an object with reason
+ if (targetNode.scores && targetNode.scores[reasonKey] && typeof targetNode.scores[reasonKey] === 'object') {
+ const s = targetNode.scores[reasonKey];
+ if (s.reason) return s.reason;
+ if (s.reasons && Array.isArray(s.reasons)) return s.reasons.join('\n');
+ if (s.value !== undefined) return null; // structured but no reason
+ }
+
+ if (targetNode.originalData) {
+ const keys = Object.keys(targetNode.originalData);
+ for (const k of keys) {
+ if (k.toLowerCase().includes('reason')) {
+ const lower = k.toLowerCase();
+ const pairKey = reasonKey.toLowerCase();
+ // if the reason key references either dimension name or the full pair, prefer it
+ if (lower.includes(pairKey) || lower.includes(pair.dimensionA?.toLowerCase()) || lower.includes(pair.dimensionB?.toLowerCase())) {
+ const v = targetNode.originalData[k];
+ if (v) return v;
+ }
+ }
+ }
+ // fallback: any Reason field
+ for (const k of keys) {
+ if (k.toLowerCase().includes('reason')) {
+ const v = targetNode.originalData[k];
+ if (v) return v;
+ }
+ }
+ }
+ } catch (err) {
+ // ignore and return null
+ console.error('Error extracting reason:', err);
+ }
+ return null;
+ };
+
+ const reason = extractReasonForPair();
+
+ return {
+ label: `${pair.dimensionA} ←→ ${pair.dimensionB}`,
+ score: rawScore !== undefined ? rawScore : null,
+ reason,
+ pair
+ };
+ });
};
- const tabs = ['Impact', 'Feasibility', 'Novelty'];
+ // 获取动态维度分数
+ const dimensionScores = getDimensionScores(displayNode);
return (
- <>
-
- {hasPrevious && (
-
- )}
+
+ {/* Toggle 按钮 */}
+ {(hasPrevious || hasNext) && (
+
+ )}
-
- {displayedNode.title || 'Untitled'}
+ {/* 标题 + ID Badge */}
+
+
+ {displayNode?.title || node.title || 'Untitled'}
+ {node.id && node.id !== 'root' && (
+
+ #{node.id}
+
+ )}
+
-
- {description}
+ {/* 问题部分 - 直接显示在标题下方,添加下横线 */}
+ {problemSection && problemSection.content ? (
+
+ {renderWithHighlights(problemSection.content, problemHighlights, (text) => {
+ if (onCreateFragmentFromHighlight) {
+ onCreateFragmentFromHighlight(text, node.id);
+ }
+ })}
-
-
- {comparisonTable && (
-
- )}
- {experimentTable && (
-
- )}
+ ) : content && content.trim() && !sections.length ? (
+ // Fallback: Show full content if no structured sections
+
+ {renderWithHighlights(content, problemHighlights, (text) => {
+ if (onCreateFragmentFromHighlight) {
+ onCreateFragmentFromHighlight(text, node.id);
+ }
+ })}
+ ) : null}
+
+ {/* 动态维度分数显示(如果有)- 每个dimension一整行 */}
+ {dimensionScores && dimensionScores.length > 0 && (
+
+ {dimensionScores.map((dim, idx) => {
+ // Determine signed display (-50..+50)
+ let signed = null;
+ if (dim.score !== null && dim.score !== undefined) {
+ const v = Number(dim.score);
+ if (!Number.isNaN(v)) {
+ if (v >= -50 && v <= 50) {
+ signed = Math.round(v);
+ } else if (v >= 0 && v <= 100) {
+ signed = Math.round(v) - 50;
+ } else {
+ signed = Math.max(-50, Math.min(50, Math.round(v)));
+ }
+ }
+ }
+
+ // Color mapping per axis role
+ const xPosStyle = { background: '#dbeafe', color: '#1e3a8a' };
+ const xNegStyle = { background: '#fef3c7', color: '#92400e' };
+ const yPosStyle = { background: '#dcfce7', color: '#14532d' };
+ const yNegStyle = { background: '#fce7f3', color: '#831843' };
+ let scoreColor = '#374151';
+ let scoreBg = 'transparent';
+
+ const activeX = activeDimensions?.xDimension;
+ const activeY = activeDimensions?.yDimension;
+ const isX = activeX && (`${activeX.dimensionA}-${activeX.dimensionB}`) === `${dim.pair.dimensionA}-${dim.pair.dimensionB}`;
+ const isY = activeY && (`${activeY.dimensionA}-${activeY.dimensionB}`) === `${dim.pair.dimensionA}-${dim.pair.dimensionB}`;
+
+ if (signed !== null) {
+ if (isX) {
+ if (signed >= 0) {
+ scoreColor = xPosStyle.color;
+ scoreBg = xPosStyle.background;
+ } else {
+ scoreColor = xNegStyle.color;
+ scoreBg = xNegStyle.background;
+ }
+ } else if (isY) {
+ if (signed >= 0) {
+ scoreColor = yPosStyle.color;
+ scoreBg = yPosStyle.background;
+ } else {
+ scoreColor = yNegStyle.color;
+ scoreBg = yNegStyle.background;
+ }
+ } else {
+ if (signed >= 0) {
+ scoreColor = xPosStyle.color;
+ scoreBg = xPosStyle.background;
+ } else {
+ scoreColor = xNegStyle.color;
+ scoreBg = xNegStyle.background;
+ }
+ }
+ }
+
+ // Parse dimension label to get left and right parts
+ const labelParts = dim.label.split('←→').map(s => s.trim());
+ const leftDim = labelParts[0] || '';
+ const rightDim = labelParts[1] || '';
+ const isActiveDimension = activeDimensionIndices.includes(idx);
+ const canSwap = isActiveDimension && !!onSwapDimension;
-
- {tabs.map(tab => {
- const isActive = activeSection === tab;
- const score = getScore(tab);
- const color = sectionColors[tab];
- const sectionKey = tab.toLowerCase().replace(' ', '_');
- if (!buttonRefs.current[sectionKey]) buttonRefs.current[sectionKey] = React.createRef();
return (
-
setActiveSection(tab)} style={{ padding: '8px 12px', marginRight: '10px', cursor: 'pointer', position: 'relative', fontWeight: isActive ? 600 : 400, color: isActive ? color : '#6b7280', borderBottom: isActive ? `2px solid ${color}` : 'none', display: 'flex', alignItems: 'center' }}>
- {isActive &&
}
- {tab}
-
- {score !== null && (
-
- {Math.round(score)}
+
+ {/* Dimension label and score on same line */}
+
+ {/* Checkbox for toggling dimension */}
+
{
+ e.stopPropagation(); // Prevent card selection if any
+ if (onToggleDimensionIndex) onToggleDimensionIndex(idx);
+ }}
+ style={{ cursor: 'pointer', width: '16px', height: '16px', accentColor: '#2563EB' }}
+ />
+
+ {/* Left dimension - highlight if score is negative, clickable to edit */}
+
{
+ e.stopPropagation();
+ if (onEditDimension) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ onEditDimension(idx, { top: rect.bottom + 5, left: rect.left });
+ }
+ }}
+ style={{
+ fontWeight: 500,
+ padding: '2px 6px',
+ borderRadius: '4px',
+ backgroundColor: signed !== null && signed < 0 ? scoreBg : 'transparent',
+ color: signed !== null && signed < 0 ? scoreColor : '#374151',
+ cursor: onEditDimension ? 'pointer' : 'default',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px'
+ }}
+ title={onEditDimension ? 'Click to edit dimension' : undefined}
+ >
+ {leftDim}
+ {onEditDimension && (
+
+ )}
+
+ {/* Score in the middle - clickable to swap */}
+ {dim.score !== null ? (
+
{
+ e.stopPropagation();
+ if (canSwap) onSwapDimension(idx);
+ }}
+ style={{
+ padding: '2px 10px',
+ borderRadius: '6px',
+ fontSize: '0.9rem',
+ fontWeight: 700,
+ backgroundColor: scoreBg,
+ color: scoreColor,
+ cursor: canSwap ? 'pointer' : 'default',
+ opacity: canSwap ? 1 : 0.35,
+ border: canSwap ? '1px solid rgba(55,65,81,0.15)' : '1px solid transparent',
+ userSelect: 'none',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px'
+ }}
+ title={canSwap ? 'Click to swap dimension direction' : 'Enable this dimension to swap'}
+ >
+ {signed !== null ? (signed > 0 ? `+${signed}` : signed) : 'N/A'}
+ {canSwap && (
+
+ )}
+
+ ) : (
+
+ N/A
+
+ )}
+
+ {/* Right dimension - highlight if score is positive, clickable to edit */}
+
{
+ e.stopPropagation();
+ if (onEditDimension) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ onEditDimension(idx, { top: rect.bottom + 5, left: rect.left });
+ }
+ }}
+ style={{
+ fontWeight: 500,
+ padding: '2px 6px',
+ borderRadius: '4px',
+ backgroundColor: signed !== null && signed > 0 ? scoreBg : 'transparent',
+ color: signed !== null && signed > 0 ? scoreColor : '#374151',
+ cursor: onEditDimension ? 'pointer' : 'default',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px'
+ }}
+ title={onEditDimension ? 'Click to edit dimension' : undefined}
+ >
+ {rightDim}
+ {onEditDimension && (
+
+ )}
+
+
+
+ {/* Reason text below */}
+ {dim.reason && (
+
+
+ {renderWithHighlights(dim.reason, problemHighlights, (text) => {
+ if (onCreateFragmentFromHighlight) {
+ onCreateFragmentFromHighlight(text, node.id);
+ }
+ })}
+
+
)}
);
})}
+ )}
-
-
-
-
+ {/* Collapsible Detail Sections */}
+ {displayNode?.originalData && (
+
+ {/* Approach Section */}
+ {displayNode.originalData.Approach && (
+
+
+ {expandedSections.approach && (
+
+ {displayNode.originalData.Approach}
+
+ )}
+
+ )}
-
setShowComparisonModal(false)} title="Novelty Comparison">
-
-
+ {/* Experiment Section */}
+ {(displayNode.originalData.Experiment || displayNode.originalData.ExperimentTable) && (
+
+
+ {expandedSections.experiment && (
+
+ {displayNode.originalData.ExperimentTable ? (
+
+ ) : typeof displayNode.originalData.Experiment === 'object' ? (
+
+ {displayNode.originalData.Experiment.Model && (
+
+ Model: {displayNode.originalData.Experiment.Model}
+
+ )}
+ {displayNode.originalData.Experiment.Dataset && (
+
+ Dataset:{' '}
+ {typeof displayNode.originalData.Experiment.Dataset === 'object'
+ ? JSON.stringify(displayNode.originalData.Experiment.Dataset, null, 2)
+ : displayNode.originalData.Experiment.Dataset}
+
+ )}
+ {displayNode.originalData.Experiment.Metric && (
+
+ Metric: {displayNode.originalData.Experiment.Metric}
+
+ )}
+
+ ) : (
+
+ {displayNode.originalData.Experiment}
+
+ )}
+
+ )}
+
+ )}
-
{
- setShowExperimentModal(false);
- setIsEditingExperiment(false); // Ensure edit mode is off when closing
- }}
- title={isEditingExperiment ? "Edit Experiment Plan" : "Experiment Plan"}
- onEdit={!isEditingExperiment ? () => setIsEditingExperiment(true) : undefined}
- >
- {isEditingExperiment ? (
- {
- onUpdateTable(displayedNode.id, 'ExperimentTable', newMarkdown);
- setIsEditingExperiment(false);
- }}
- onCancel={() => setIsEditingExperiment(false)}
- />
- ) : (
-
- )}
-
- >
+ {/* Importance Section */}
+ {displayNode.originalData.Importance && (
+
+
+ {expandedSections.importance && (
+
+ {displayNode.originalData.Importance}
+
+ )}
+
+ )}
+
+ {/* Difficulty Section */}
+ {displayNode.originalData.Difficulty && (
+
+
+ {expandedSections.difficulty && (
+
+ {displayNode.originalData.Difficulty}
+
+ )}
+
+ )}
+
+ {/* NoveltyComparison Section */}
+ {displayNode.originalData.NoveltyComparison && (
+
+
+ {expandedSections.noveltyComparison && (
+
+ {displayNode.originalData.NoveltyComparison}
+
+ )}
+
+ )}
+
+
+ )}
+
+
);
};
diff --git a/frontend/src/components/IdeaFactorsAndScoresCard.jsx b/frontend/src/components/IdeaFactorsAndScoresCard.jsx
deleted file mode 100644
index 3b48442e..00000000
--- a/frontend/src/components/IdeaFactorsAndScoresCard.jsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React from 'react';
-import FactorBlock from './FactorBlock';
-
-/**
- * 组合 3 个因子块的卡片
- */
-const IdeaFactorsAndScoresCard = ({ node, isEvaluating, showAfter }) => {
- const hasPrev = !!node.previousState;
-
- if (isEvaluating) {
- return (
-
- Scores will be displayed once evaluation is complete.
-
- );
- }
-
- const pick = (field) =>
- showAfter || !hasPrev ? node[field] : node.previousState[field];
-
- return (
- <>
-
-
-
-
-
- >
- );
-};
-
-export default IdeaFactorsAndScoresCard;
diff --git a/frontend/src/components/LogDisplay.jsx b/frontend/src/components/LogDisplay.jsx
index 5d22c2a4..f7d17005 100644
--- a/frontend/src/components/LogDisplay.jsx
+++ b/frontend/src/components/LogDisplay.jsx
@@ -9,7 +9,7 @@ const LogDisplay = ({ isVisible, onToggle }) => {
useEffect(() => {
// Always maintain socket connection when component mounts
- socketRef.current = io('http://localhost:5000');
+ socketRef.current = io(window.location.origin);
socketRef.current.on('connect', () => {
setIsConnected(true);
diff --git a/frontend/src/components/TopNav.jsx b/frontend/src/components/TopNav.jsx
index 047deb06..ef504005 100644
--- a/frontend/src/components/TopNav.jsx
+++ b/frontend/src/components/TopNav.jsx
@@ -196,51 +196,51 @@ const TopNav = ({ currentView, setCurrentView, showCodeView = false }) => {
}}
>
- {/* Home View */}
-
setCurrentView('home_view')}
- >
- {overviewIcon}
- Home View
-
- {/* Exploration View */}
-
setCurrentView('exploration')}
- >
- {explorationIcon}
- Exploration View
-
-
- {/* Evaluation View */}
-
setCurrentView('evaluation')}
- >
- {evaluationIcon}
- Evaluation View
-
+ {/* Home View */}
+
setCurrentView('home_view')}
+ >
+ {overviewIcon}
+ Home View
+
+ {/* Tree View */}
+
setCurrentView('exploration')}
+ >
+ {explorationIcon}
+ Tree View
+
- {/* Code View - only show if code has been generated */}
- {showCodeView && (
+ {/* Cube View */}
setCurrentView('code_view')}
+ style={{ ...tabStyle, ...getActiveStyle('evaluation'), marginRight: 6 }}
+ onClick={() => setCurrentView('evaluation')}
>
- {codeIcon}
- Code View
+ {evaluationIcon}
+ Cube View
- )}
- {/* Paper View */}
-
setCurrentView('paper_view')}
- >
- {paperIcon}
- Paper View
-
+ {/* Code View - only show if code has been generated */}
+ {showCodeView && (
+
setCurrentView('code_view')}
+ >
+ {codeIcon}
+ Code View
+
+ )}
+
+ {/* Paper View */}
+
setCurrentView('paper_view')}
+ >
+ {paperIcon}
+ Paper View
+
{/* GitHub Logo */}
diff --git a/frontend/src/components/TreePlotVisualization.jsx b/frontend/src/components/TreePlotVisualization.jsx
index dafc82dc..b8a22101 100644
--- a/frontend/src/components/TreePlotVisualization.jsx
+++ b/frontend/src/components/TreePlotVisualization.jsx
@@ -1,12 +1,20 @@
// ============== 段落1:导入依赖 ==============
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as d3 from 'd3';
-import Editor from '@monaco-editor/react';
import TopNav from './TopNav';
import IdeaCard from './IdeaCard';
-import IdeaFactorsAndScoresCard from './IdeaFactorsAndScoresCard';
-import LogDisplay from './LogDisplay';
+import DimensionSelectorPanel from './DimensionSelectorPanel';
+import DimensionEditDropdown from './DimensionEditDropdown';
+import Evaluation3D from './Evaluation3D';
+import { buildNodeContent } from '../utils/contentParser';
+
+
+const COLOR_MAP = {
+ root: '#4C84FF',
+ simple: '#45B649',
+ complex: '#FF6B6B',
+};
// Helper components defined outside the main component to preserve state
@@ -26,7 +34,8 @@ const ContextAndGenerateCard = ({
setIsEditingSystemPrompt,
setModalAnchorEl,
editIcon,
- handleProceedWithSelectedIdea
+ handleProceedWithSelectedIdea,
+ getNodeTrackingInfo
}) => {
const newEditButtonRef = useRef(null);
const generateButtonRef = useRef(null);
@@ -34,7 +43,7 @@ const ContextAndGenerateCard = ({
const contextInputRef = useRef(null);
const customFormRef = useRef(null);
- // Removed user action tracking
+ // Track user interactions (disable hover to avoid conflicts with D3 node tracking)
return (
setUserInput(e.target.value)}
+ onChange={(e) => {
+ setUserInput(e.target.value);
+ }}
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
@@ -151,34 +162,6 @@ const ContextAndGenerateCard = ({
Add Custom Idea
-
) : (
@@ -198,7 +181,9 @@ const ContextAndGenerateCard = ({
id="custom-idea-title"
type="text"
value={customIdea.title}
- onChange={(e) => setCustomIdea(prev => ({ ...prev, title: e.target.value }))}
+ onChange={(e) => {
+ setCustomIdea(prev => ({ ...prev, title: e.target.value }));
+ }}
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
@@ -218,7 +203,9 @@ const ContextAndGenerateCard = ({