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 + /> +