feat: Migrate to React Flow and add Fixed + Dynamic category mode
Frontend: - Migrate MindmapDAG from D3.js to React Flow (@xyflow/react) - Add custom node components (QueryNode, CategoryHeaderNode, AttributeNode) - Add useDAGLayout hook for column-based layout - Add "AI" badge for LLM-suggested categories - Update CategorySelector with Fixed + Dynamic mode option - Improve dark/light theme support Backend: - Add FIXED_PLUS_DYNAMIC category mode - Filter duplicate category names in LLM suggestions - Update prompts to exclude fixed categories when suggesting new ones - Improve LLM service with better error handling and logging - Auto-remove /no_think prefix for non-Qwen models - Add smart JSON format detection for model compatibility - Improve JSON extraction with multiple parsing strategies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ class CategoryMode(str, Enum):
|
|||||||
"""類別模式"""
|
"""類別模式"""
|
||||||
FIXED_ONLY = "fixed_only"
|
FIXED_ONLY = "fixed_only"
|
||||||
FIXED_PLUS_CUSTOM = "fixed_plus_custom"
|
FIXED_PLUS_CUSTOM = "fixed_plus_custom"
|
||||||
|
FIXED_PLUS_DYNAMIC = "fixed_plus_dynamic" # Fixed + LLM suggested
|
||||||
CUSTOM_ONLY = "custom_only"
|
CUSTOM_ONLY = "custom_only"
|
||||||
DYNAMIC_AUTO = "dynamic_auto"
|
DYNAMIC_AUTO = "dynamic_auto"
|
||||||
|
|
||||||
@@ -98,3 +99,35 @@ class DynamicStep1Result(BaseModel):
|
|||||||
class DynamicCausalChain(BaseModel):
|
class DynamicCausalChain(BaseModel):
|
||||||
"""動態版本的因果鏈"""
|
"""動態版本的因果鏈"""
|
||||||
chain: Dict[str, str] # {類別名: 選中屬性}
|
chain: Dict[str, str] # {類別名: 選中屬性}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== DAG (Directed Acyclic Graph) schemas =====
|
||||||
|
|
||||||
|
class DAGNode(BaseModel):
|
||||||
|
"""DAG 節點 - 每個屬性只出現一次"""
|
||||||
|
id: str # 唯一 ID: "{category}_{index}"
|
||||||
|
name: str # 顯示名稱
|
||||||
|
category: str # 所屬類別
|
||||||
|
order: int # 欄位內位置
|
||||||
|
|
||||||
|
|
||||||
|
class DAGEdge(BaseModel):
|
||||||
|
"""DAG 邊 - 節點之間的連接"""
|
||||||
|
source_id: str
|
||||||
|
target_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeDAG(BaseModel):
|
||||||
|
"""完整 DAG 結構"""
|
||||||
|
query: str
|
||||||
|
categories: List[CategoryDefinition]
|
||||||
|
nodes: List[DAGNode]
|
||||||
|
edges: List[DAGEdge]
|
||||||
|
|
||||||
|
|
||||||
|
class DAGRelationship(BaseModel):
|
||||||
|
"""Step 2 輸出 - 單一關係"""
|
||||||
|
source_category: str
|
||||||
|
source: str # source attribute name
|
||||||
|
target_category: str
|
||||||
|
target: str # target attribute name
|
||||||
|
|||||||
@@ -120,17 +120,26 @@ def get_flat_attribute_prompt(query: str, categories: Optional[List[str]] = None
|
|||||||
|
|
||||||
# ===== Dynamic category system prompts =====
|
# ===== Dynamic category system prompts =====
|
||||||
|
|
||||||
def get_step0_category_analysis_prompt(query: str, suggested_count: int = 3) -> str:
|
def get_step0_category_analysis_prompt(
|
||||||
|
query: str,
|
||||||
|
suggested_count: int = 3,
|
||||||
|
exclude_categories: List[str] | None = None
|
||||||
|
) -> str:
|
||||||
"""Step 0: LLM 分析建議類別"""
|
"""Step 0: LLM 分析建議類別"""
|
||||||
|
exclude_text = ""
|
||||||
|
if exclude_categories:
|
||||||
|
exclude_text = f"\n【禁止使用的類別】{', '.join(exclude_categories)}(這些已經是固定類別,不要重複建議)\n"
|
||||||
|
|
||||||
return f"""/no_think
|
return f"""/no_think
|
||||||
分析「{query}」,建議 {suggested_count} 個最適合的屬性類別來描述它。
|
分析「{query}」,建議 {suggested_count} 個最適合的屬性類別來描述它。
|
||||||
|
|
||||||
【常見類別參考】材料、功能、用途、使用族群、特性、形狀、顏色、尺寸、品牌、價格區間
|
【常見類別參考】特性、形狀、顏色、尺寸、品牌、價格區間、重量、風格、場合、季節、技術規格
|
||||||
|
{exclude_text}
|
||||||
【重要】
|
【重要】
|
||||||
1. 選擇最能描述此物件本質的類別
|
1. 選擇最能描述此物件本質的類別
|
||||||
2. 類別之間應該有邏輯關係(如:材料→功能→用途)
|
2. 類別之間應該有邏輯關係
|
||||||
3. 不要選擇過於抽象或重複的類別
|
3. 不要選擇過於抽象或重複的類別
|
||||||
|
4. 必須建議與參考列表不同的、有創意的類別
|
||||||
|
|
||||||
只回傳 JSON:
|
只回傳 JSON:
|
||||||
{{
|
{{
|
||||||
@@ -213,3 +222,47 @@ def get_step2_dynamic_causal_chain_prompt(
|
|||||||
|
|
||||||
只回傳 JSON:
|
只回傳 JSON:
|
||||||
{json.dumps(json_template, ensure_ascii=False, indent=2)}"""
|
{json.dumps(json_template, ensure_ascii=False, indent=2)}"""
|
||||||
|
|
||||||
|
|
||||||
|
# ===== DAG relationship prompt =====
|
||||||
|
|
||||||
|
def get_step2_dag_relationships_prompt(
|
||||||
|
query: str,
|
||||||
|
categories: List, # List[CategoryDefinition]
|
||||||
|
attributes_by_category: Dict[str, List[str]],
|
||||||
|
) -> str:
|
||||||
|
"""生成相鄰類別之間的自然關係"""
|
||||||
|
sorted_cats = sorted(categories, key=lambda x: x.order if hasattr(x, 'order') else x.get('order', 0))
|
||||||
|
|
||||||
|
# Build attribute listing
|
||||||
|
attr_listing = "\n".join([
|
||||||
|
f"【{cat.name if hasattr(cat, 'name') else cat['name']}】{', '.join(attributes_by_category.get(cat.name if hasattr(cat, 'name') else cat['name'], []))}"
|
||||||
|
for cat in sorted_cats
|
||||||
|
])
|
||||||
|
|
||||||
|
# Build direction hints
|
||||||
|
direction_hints = " → ".join([cat.name if hasattr(cat, 'name') else cat['name'] for cat in sorted_cats])
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
分析「{query}」的屬性關係。
|
||||||
|
|
||||||
|
{attr_listing}
|
||||||
|
|
||||||
|
【關係方向】{direction_hints}
|
||||||
|
|
||||||
|
【規則】
|
||||||
|
1. 只建立相鄰類別之間的關係(例如:材料→功能,功能→用途)
|
||||||
|
2. 只輸出真正有因果或關聯關係的配對
|
||||||
|
3. 一個屬性可連接多個下游屬性,也可以不連接任何屬性
|
||||||
|
4. 不需要每個屬性都有連接
|
||||||
|
5. 關係應該合理且有意義
|
||||||
|
|
||||||
|
回傳 JSON:
|
||||||
|
{{
|
||||||
|
"relationships": [
|
||||||
|
{{"source_category": "類別A", "source": "屬性名", "target_category": "類別B", "target": "屬性名"}},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
只回傳 JSON。"""
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from ..models.schemas import (
|
|||||||
Step0Result,
|
Step0Result,
|
||||||
DynamicStep1Result,
|
DynamicStep1Result,
|
||||||
DynamicCausalChain,
|
DynamicCausalChain,
|
||||||
|
DAGNode,
|
||||||
|
DAGEdge,
|
||||||
|
AttributeDAG,
|
||||||
|
DAGRelationship,
|
||||||
)
|
)
|
||||||
from ..prompts.attribute_prompt import (
|
from ..prompts.attribute_prompt import (
|
||||||
get_step1_attributes_prompt,
|
get_step1_attributes_prompt,
|
||||||
@@ -23,6 +27,7 @@ from ..prompts.attribute_prompt import (
|
|||||||
get_step0_category_analysis_prompt,
|
get_step0_category_analysis_prompt,
|
||||||
get_step1_dynamic_attributes_prompt,
|
get_step1_dynamic_attributes_prompt,
|
||||||
get_step2_dynamic_causal_chain_prompt,
|
get_step2_dynamic_causal_chain_prompt,
|
||||||
|
get_step2_dag_relationships_prompt,
|
||||||
)
|
)
|
||||||
from ..services.llm_service import ollama_provider, extract_json_from_response
|
from ..services.llm_service import ollama_provider, extract_json_from_response
|
||||||
|
|
||||||
@@ -39,14 +44,21 @@ FIXED_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def execute_step0(request: StreamAnalyzeRequest) -> Step0Result | None:
|
async def execute_step0(
|
||||||
"""Execute Step 0 - LLM category analysis"""
|
request: StreamAnalyzeRequest,
|
||||||
if request.category_mode == CategoryMode.FIXED_ONLY:
|
exclude_categories: List[str] | None = None
|
||||||
return None
|
) -> Step0Result | None:
|
||||||
|
"""Execute Step 0 - LLM category analysis
|
||||||
|
|
||||||
|
Called only for modes that need LLM to suggest categories:
|
||||||
|
- FIXED_PLUS_DYNAMIC
|
||||||
|
- CUSTOM_ONLY
|
||||||
|
- DYNAMIC_AUTO
|
||||||
|
"""
|
||||||
prompt = get_step0_category_analysis_prompt(
|
prompt = get_step0_category_analysis_prompt(
|
||||||
request.query,
|
request.query,
|
||||||
request.suggested_category_count
|
request.suggested_category_count,
|
||||||
|
exclude_categories=exclude_categories
|
||||||
)
|
)
|
||||||
temperature = request.temperature if request.temperature is not None else 0.7
|
temperature = request.temperature if request.temperature is not None else 0.7
|
||||||
response = await ollama_provider.generate(
|
response = await ollama_provider.generate(
|
||||||
@@ -83,6 +95,34 @@ def resolve_final_categories(
|
|||||||
)
|
)
|
||||||
return categories
|
return categories
|
||||||
|
|
||||||
|
elif request.category_mode == CategoryMode.FIXED_PLUS_DYNAMIC:
|
||||||
|
# Fixed categories + LLM suggested categories
|
||||||
|
categories = [
|
||||||
|
CategoryDefinition(
|
||||||
|
name=cat.name,
|
||||||
|
description=cat.description,
|
||||||
|
is_fixed=True,
|
||||||
|
order=i
|
||||||
|
)
|
||||||
|
for i, cat in enumerate(FIXED_CATEGORIES)
|
||||||
|
]
|
||||||
|
if step0_result:
|
||||||
|
# Filter out LLM categories that duplicate fixed category names
|
||||||
|
fixed_names = {cat.name for cat in FIXED_CATEGORIES}
|
||||||
|
added_count = 0
|
||||||
|
for cat in step0_result.categories:
|
||||||
|
if cat.name not in fixed_names:
|
||||||
|
categories.append(
|
||||||
|
CategoryDefinition(
|
||||||
|
name=cat.name,
|
||||||
|
description=cat.description,
|
||||||
|
is_fixed=False,
|
||||||
|
order=len(FIXED_CATEGORIES) + added_count
|
||||||
|
)
|
||||||
|
)
|
||||||
|
added_count += 1
|
||||||
|
return categories
|
||||||
|
|
||||||
elif request.category_mode == CategoryMode.CUSTOM_ONLY:
|
elif request.category_mode == CategoryMode.CUSTOM_ONLY:
|
||||||
return step0_result.categories if step0_result else FIXED_CATEGORIES
|
return step0_result.categories if step0_result else FIXED_CATEGORIES
|
||||||
|
|
||||||
@@ -192,17 +232,73 @@ def assemble_attribute_tree(query: str, chains: List[CausalChain]) -> AttributeN
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def build_dag_from_relationships(
|
||||||
|
query: str,
|
||||||
|
categories: List[CategoryDefinition],
|
||||||
|
attributes_by_category: dict,
|
||||||
|
relationships: List[DAGRelationship],
|
||||||
|
) -> AttributeDAG:
|
||||||
|
"""從屬性和關係建構 DAG"""
|
||||||
|
sorted_cats = sorted(categories, key=lambda x: x.order)
|
||||||
|
|
||||||
|
# 建立節點 - 每個屬性只出現一次
|
||||||
|
nodes: List[DAGNode] = []
|
||||||
|
node_id_map: dict = {} # (category, name) -> id
|
||||||
|
|
||||||
|
for cat in sorted_cats:
|
||||||
|
cat_name = cat.name
|
||||||
|
for idx, attr_name in enumerate(attributes_by_category.get(cat_name, [])):
|
||||||
|
node_id = f"{cat_name}_{idx}"
|
||||||
|
nodes.append(DAGNode(
|
||||||
|
id=node_id,
|
||||||
|
name=attr_name,
|
||||||
|
category=cat_name,
|
||||||
|
order=idx
|
||||||
|
))
|
||||||
|
node_id_map[(cat_name, attr_name)] = node_id
|
||||||
|
|
||||||
|
# 建立邊
|
||||||
|
edges: List[DAGEdge] = []
|
||||||
|
for rel in relationships:
|
||||||
|
source_key = (rel.source_category, rel.source)
|
||||||
|
target_key = (rel.target_category, rel.target)
|
||||||
|
if source_key in node_id_map and target_key in node_id_map:
|
||||||
|
edges.append(DAGEdge(
|
||||||
|
source_id=node_id_map[source_key],
|
||||||
|
target_id=node_id_map[target_key]
|
||||||
|
))
|
||||||
|
|
||||||
|
return AttributeDAG(
|
||||||
|
query=query,
|
||||||
|
categories=sorted_cats,
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[str, None]:
|
async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[str, None]:
|
||||||
"""Generate SSE events with dynamic category support"""
|
"""Generate SSE events with dynamic category support"""
|
||||||
try:
|
try:
|
||||||
temperature = request.temperature if request.temperature is not None else 0.7
|
temperature = request.temperature if request.temperature is not None else 0.7
|
||||||
|
|
||||||
# ========== Step 0: Category Analysis (if needed) ==========
|
# ========== Step 0: Category Analysis (if needed) ==========
|
||||||
|
# Only these modes need LLM category analysis
|
||||||
|
needs_step0 = request.category_mode in [
|
||||||
|
CategoryMode.FIXED_PLUS_DYNAMIC,
|
||||||
|
CategoryMode.CUSTOM_ONLY,
|
||||||
|
CategoryMode.DYNAMIC_AUTO,
|
||||||
|
]
|
||||||
|
|
||||||
step0_result = None
|
step0_result = None
|
||||||
if request.category_mode != CategoryMode.FIXED_ONLY:
|
if needs_step0:
|
||||||
yield f"event: step0_start\ndata: {json.dumps({'message': '分析類別...'}, ensure_ascii=False)}\n\n"
|
yield f"event: step0_start\ndata: {json.dumps({'message': '分析類別...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
step0_result = await execute_step0(request)
|
# For FIXED_PLUS_DYNAMIC, exclude the fixed category names
|
||||||
|
exclude_cats = None
|
||||||
|
if request.category_mode == CategoryMode.FIXED_PLUS_DYNAMIC:
|
||||||
|
exclude_cats = [cat.name for cat in FIXED_CATEGORIES]
|
||||||
|
|
||||||
|
step0_result = await execute_step0(request, exclude_categories=exclude_cats)
|
||||||
|
|
||||||
if step0_result:
|
if step0_result:
|
||||||
yield f"event: step0_complete\ndata: {json.dumps({'result': step0_result.model_dump()}, ensure_ascii=False)}\n\n"
|
yield f"event: step0_complete\ndata: {json.dumps({'result': step0_result.model_dump()}, ensure_ascii=False)}\n\n"
|
||||||
@@ -227,58 +323,58 @@ async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[s
|
|||||||
|
|
||||||
yield f"event: step1_complete\ndata: {json.dumps({'result': step1_result.model_dump()}, ensure_ascii=False)}\n\n"
|
yield f"event: step1_complete\ndata: {json.dumps({'result': step1_result.model_dump()}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# ========== Step 2: Generate Causal Chains (Dynamic) ==========
|
# ========== Step 2: Generate Relationships (DAG) ==========
|
||||||
causal_chains: List[DynamicCausalChain] = []
|
yield f"event: relationships_start\ndata: {json.dumps({'message': '生成關係...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
for i in range(request.chain_count):
|
step2_prompt = get_step2_dag_relationships_prompt(
|
||||||
chain_index = i + 1
|
|
||||||
|
|
||||||
yield f"event: chain_start\ndata: {json.dumps({'index': chain_index, 'total': request.chain_count, 'message': f'正在生成第 {chain_index}/{request.chain_count} 條因果鏈...'}, ensure_ascii=False)}\n\n"
|
|
||||||
|
|
||||||
step2_prompt = get_step2_dynamic_causal_chain_prompt(
|
|
||||||
query=request.query,
|
query=request.query,
|
||||||
categories=final_categories,
|
categories=final_categories,
|
||||||
attributes_by_category=step1_result.attributes,
|
attributes_by_category=step1_result.attributes,
|
||||||
existing_chains=[c.chain for c in causal_chains],
|
|
||||||
chain_index=chain_index,
|
|
||||||
)
|
)
|
||||||
|
logger.info(f"Step 2 (relationships) prompt: {step2_prompt[:300]}")
|
||||||
|
|
||||||
# Gradually increase temperature for diversity
|
relationships: List[DAGRelationship] = []
|
||||||
chain_temperature = min(temperature + 0.05 * i, 1.0)
|
|
||||||
|
|
||||||
max_retries = 2
|
max_retries = 2
|
||||||
chain = None
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
step2_response = await ollama_provider.generate(
|
step2_response = await ollama_provider.generate(
|
||||||
step2_prompt, model=request.model, temperature=chain_temperature
|
step2_prompt, model=request.model, temperature=temperature
|
||||||
)
|
)
|
||||||
logger.info(f"Chain {chain_index} response: {step2_response[:300]}")
|
logger.info(f"Relationships response: {step2_response[:500]}")
|
||||||
|
|
||||||
chain_data = extract_json_from_response(step2_response)
|
rel_data = extract_json_from_response(step2_response)
|
||||||
chain = DynamicCausalChain(chain=chain_data)
|
raw_relationships = rel_data.get("relationships", [])
|
||||||
|
|
||||||
|
for rel in raw_relationships:
|
||||||
|
relationships.append(DAGRelationship(
|
||||||
|
source_category=rel.get("source_category", ""),
|
||||||
|
source=rel.get("source", ""),
|
||||||
|
target_category=rel.get("target_category", ""),
|
||||||
|
target=rel.get("target", ""),
|
||||||
|
))
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Chain {chain_index} attempt {attempt + 1} failed: {e}")
|
logger.warning(f"Relationships attempt {attempt + 1} failed: {e}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
chain_temperature = min(chain_temperature + 0.1, 1.0)
|
temperature = min(temperature + 0.1, 1.0)
|
||||||
|
|
||||||
if chain:
|
yield f"event: relationships_complete\ndata: {json.dumps({'count': len(relationships)}, ensure_ascii=False)}\n\n"
|
||||||
causal_chains.append(chain)
|
|
||||||
yield f"event: chain_complete\ndata: {json.dumps({'index': chain_index, 'chain': chain.model_dump()}, ensure_ascii=False)}\n\n"
|
|
||||||
else:
|
|
||||||
yield f"event: chain_error\ndata: {json.dumps({'index': chain_index, 'error': f'生成失敗'}, ensure_ascii=False)}\n\n"
|
|
||||||
|
|
||||||
# ========== Assemble Final Tree (Dynamic) ==========
|
# ========== Build DAG ==========
|
||||||
final_tree = assemble_dynamic_attribute_tree(request.query, causal_chains, final_categories)
|
dag = build_dag_from_relationships(
|
||||||
|
query=request.query,
|
||||||
|
categories=final_categories,
|
||||||
|
attributes_by_category=step1_result.attributes,
|
||||||
|
relationships=relationships,
|
||||||
|
)
|
||||||
|
|
||||||
final_result = {
|
final_result = {
|
||||||
"query": request.query,
|
"query": request.query,
|
||||||
"step0_result": step0_result.model_dump() if step0_result else None,
|
"step0_result": step0_result.model_dump() if step0_result else None,
|
||||||
"categories_used": [c.model_dump() for c in final_categories],
|
"categories_used": [c.model_dump() for c in final_categories],
|
||||||
"step1_result": step1_result.model_dump(),
|
"step1_result": step1_result.model_dump(),
|
||||||
"causal_chains": [c.model_dump() for c in causal_chains],
|
"relationships": [r.model_dump() for r in relationships],
|
||||||
"attributes": final_tree.model_dump(),
|
"dag": dag.model_dump(),
|
||||||
}
|
}
|
||||||
yield f"event: done\ndata: {json.dumps(final_result, ensure_ascii=False)}\n\n"
|
yield f"event: done\ndata: {json.dumps(final_result, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -8,6 +9,8 @@ import httpx
|
|||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..models.schemas import AttributeNode
|
from ..models.schemas import AttributeNode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LLMProvider(ABC):
|
class LLMProvider(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -35,34 +38,56 @@ class OllamaProvider(LLMProvider):
|
|||||||
model = model or settings.default_model
|
model = model or settings.default_model
|
||||||
url = f"{self.base_url}/api/generate"
|
url = f"{self.base_url}/api/generate"
|
||||||
|
|
||||||
|
# Remove /no_think prefix for non-qwen models (it's qwen-specific)
|
||||||
|
clean_prompt = prompt
|
||||||
|
if not model.lower().startswith("qwen") and prompt.startswith("/no_think"):
|
||||||
|
clean_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "")
|
||||||
|
logger.info(f"Removed /no_think prefix for model {model}")
|
||||||
|
|
||||||
|
# Models known to support JSON format well
|
||||||
|
json_capable_models = ["qwen", "llama", "mistral", "gemma", "phi"]
|
||||||
|
model_lower = model.lower()
|
||||||
|
use_json_format = any(m in model_lower for m in json_capable_models)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": clean_prompt,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"format": "json",
|
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Only use format: json for models that support it
|
||||||
|
if use_json_format:
|
||||||
|
payload["format"] = "json"
|
||||||
|
else:
|
||||||
|
logger.info(f"Model {model} may not support JSON format, requesting without format constraint")
|
||||||
|
|
||||||
# Retry logic for larger models that may return empty responses
|
# Retry logic for larger models that may return empty responses
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
|
logger.info(f"LLM request attempt {attempt + 1}/{max_retries} to model {model}")
|
||||||
response = await self.client.post(url, json=payload)
|
response = await self.client.post(url, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
response_text = result.get("response", "")
|
response_text = result.get("response", "")
|
||||||
|
|
||||||
|
logger.info(f"LLM response (first 500 chars): {response_text[:500] if response_text else '(empty)'}")
|
||||||
|
|
||||||
# Check if response is valid (not empty or just "{}")
|
# Check if response is valid (not empty or just "{}")
|
||||||
if response_text and response_text.strip() not in ["", "{}", "{ }"]:
|
if response_text and response_text.strip() not in ["", "{}", "{ }"]:
|
||||||
return response_text
|
return response_text
|
||||||
|
|
||||||
|
logger.warning(f"Empty or invalid response on attempt {attempt + 1}, retrying...")
|
||||||
|
|
||||||
# If empty, retry with slightly higher temperature
|
# If empty, retry with slightly higher temperature
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
payload["options"]["temperature"] = min(temperature + 0.1 * (attempt + 1), 1.0)
|
payload["options"]["temperature"] = min(temperature + 0.1 * (attempt + 1), 1.0)
|
||||||
|
|
||||||
# Return whatever we got on last attempt
|
# Return whatever we got on last attempt
|
||||||
|
logger.error(f"All {max_retries} attempts returned empty response from model {model}")
|
||||||
return response_text
|
return response_text
|
||||||
|
|
||||||
async def list_models(self) -> List[str]:
|
async def list_models(self) -> List[str]:
|
||||||
@@ -124,21 +149,46 @@ class OpenAICompatibleProvider(LLMProvider):
|
|||||||
|
|
||||||
def extract_json_from_response(response: str) -> dict:
|
def extract_json_from_response(response: str) -> dict:
|
||||||
"""Extract JSON from LLM response, handling markdown code blocks and extra whitespace."""
|
"""Extract JSON from LLM response, handling markdown code blocks and extra whitespace."""
|
||||||
# Remove markdown code blocks if present
|
if not response or not response.strip():
|
||||||
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", response)
|
logger.error("LLM returned empty response")
|
||||||
if json_match:
|
raise ValueError("LLM returned empty response - the model may not support JSON format or the prompt was unclear")
|
||||||
json_str = json_match.group(1)
|
|
||||||
else:
|
|
||||||
json_str = response
|
json_str = response
|
||||||
|
|
||||||
# Clean up: remove extra whitespace, normalize spaces
|
# Try multiple extraction strategies
|
||||||
json_str = json_str.strip()
|
extraction_attempts = []
|
||||||
# Remove trailing whitespace before closing braces/brackets
|
|
||||||
json_str = re.sub(r'\s+([}\]])', r'\1', json_str)
|
|
||||||
# Remove multiple spaces/tabs/newlines
|
|
||||||
json_str = re.sub(r'[\t\n\r]+', ' ', json_str)
|
|
||||||
|
|
||||||
return json.loads(json_str)
|
# Strategy 1: Look for markdown code blocks
|
||||||
|
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", response)
|
||||||
|
if json_match:
|
||||||
|
extraction_attempts.append(json_match.group(1))
|
||||||
|
|
||||||
|
# Strategy 2: Look for JSON object pattern { ... }
|
||||||
|
json_obj_match = re.search(r'(\{[\s\S]*\})', response)
|
||||||
|
if json_obj_match:
|
||||||
|
extraction_attempts.append(json_obj_match.group(1))
|
||||||
|
|
||||||
|
# Strategy 3: Original response
|
||||||
|
extraction_attempts.append(response)
|
||||||
|
|
||||||
|
# Try each extraction attempt
|
||||||
|
for attempt_str in extraction_attempts:
|
||||||
|
# Clean up: remove extra whitespace, normalize spaces
|
||||||
|
cleaned = attempt_str.strip()
|
||||||
|
# Remove trailing whitespace before closing braces/brackets
|
||||||
|
cleaned = re.sub(r'\s+([}\]])', r'\1', cleaned)
|
||||||
|
# Normalize newlines but keep structure
|
||||||
|
cleaned = re.sub(r'[\t\r]+', ' ', cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All attempts failed
|
||||||
|
logger.error(f"Failed to parse JSON from response")
|
||||||
|
logger.error(f"Raw response: {response[:1000]}")
|
||||||
|
raise ValueError(f"Failed to parse LLM response as JSON. The model may not support structured output. Raw response: {response[:300]}...")
|
||||||
|
|
||||||
|
|
||||||
def parse_attribute_response(response: str) -> AttributeNode:
|
def parse_attribute_response(response: str) -> AttributeNode:
|
||||||
|
|||||||
78
frontend/package-lock.json
generated
78
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"@xyflow/react": "^12.9.3",
|
||||||
"antd": "^6.0.0",
|
"antd": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@@ -2454,7 +2455,7 @@
|
|||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2763,6 +2764,38 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.3.tgz",
|
||||||
|
"integrity": "sha512-PSWoJ8vHiEqSIkLIkge+0eiHWiw4C6dyFDA03VKWJkqbU4A13VlDIVwKqf/Znuysn2GQw/zA61zpHE4rGgax7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.73",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.73.tgz",
|
||||||
|
"integrity": "sha512-C2ymH2V4mYDkdVSiRx0D7R0s3dvfXiupVBcko6tXP5K4tVdSBMo22/e3V9yRNdn+2HQFv44RFKzwOyCcUUDAVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -3001,6 +3034,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -4809,6 +4848,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.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/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.2.6",
|
"version": "7.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
|
||||||
@@ -4954,6 +5002,34 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"@xyflow/react": "^12.9.3",
|
||||||
"antd": "^6.0.0",
|
"antd": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { ConfigProvider, Layout, theme, Typography } from 'antd';
|
import { ConfigProvider, Layout, theme, Typography, Space } from 'antd';
|
||||||
|
import { ApartmentOutlined } from '@ant-design/icons';
|
||||||
import { ThemeToggle } from './components/ThemeToggle';
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
import { InputPanel } from './components/InputPanel';
|
import { InputPanel } from './components/InputPanel';
|
||||||
import { MindmapPanel } from './components/MindmapPanel';
|
import { MindmapPanel } from './components/MindmapPanel';
|
||||||
import { useAttribute } from './hooks/useAttribute';
|
import { useAttribute } from './hooks/useAttribute';
|
||||||
import type { MindmapD3Ref } from './components/MindmapD3';
|
import type { MindmapDAGRef } from './components/MindmapDAG';
|
||||||
import type { CategoryMode } from './types';
|
import type { CategoryMode } from './types';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -22,7 +23,7 @@ function App() {
|
|||||||
nodeSpacing: 32,
|
nodeSpacing: 32,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
});
|
});
|
||||||
const mindmapRef = useRef<MindmapD3Ref>(null);
|
const mindmapRef = useRef<MindmapDAGRef>(null);
|
||||||
|
|
||||||
const handleAnalyze = async (
|
const handleAnalyze = async (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -36,12 +37,8 @@ function App() {
|
|||||||
await analyze(query, model, temperature, chainCount, categoryMode, customCategories, suggestedCategoryCount);
|
await analyze(query, model, temperature, chainCount, categoryMode, customCategories, suggestedCategoryCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExpandAll = useCallback(() => {
|
const handleResetView = useCallback(() => {
|
||||||
mindmapRef.current?.expandAll();
|
mindmapRef.current?.resetView();
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCollapseAll = useCallback(() => {
|
|
||||||
mindmapRef.current?.collapseAll();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,11 +54,37 @@ function App() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(90deg, #141414 0%, #1f1f1f 50%, #141414 100%)'
|
||||||
|
: 'linear-gradient(90deg, #fff 0%, #fafafa 50%, #fff 100%)',
|
||||||
|
borderBottom: isDark ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||||
|
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space align="center" size="middle">
|
||||||
|
<ApartmentOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: isDark ? '#177ddc' : '#1890ff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Title
|
||||||
|
level={4}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(90deg, #177ddc 0%, #69c0ff 100%)'
|
||||||
|
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title level={4} style={{ margin: 0, color: isDark ? '#fff' : '#000' }}>
|
|
||||||
Attribute Agent
|
Attribute Agent
|
||||||
</Title>
|
</Title>
|
||||||
|
</Space>
|
||||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||||
</Header>
|
</Header>
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -96,8 +119,7 @@ function App() {
|
|||||||
currentResult={currentResult}
|
currentResult={currentResult}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
onLoadHistory={loadFromHistory}
|
onLoadHistory={loadFromHistory}
|
||||||
onExpandAll={handleExpandAll}
|
onResetView={handleResetView}
|
||||||
onCollapseAll={handleCollapseAll}
|
|
||||||
visualSettings={visualSettings}
|
visualSettings={visualSettings}
|
||||||
onVisualSettingsChange={setVisualSettings}
|
onVisualSettingsChange={setVisualSettings}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -42,28 +42,37 @@ export function CategorySelector({
|
|||||||
Fixed (材料、功能、用途、使用族群)
|
Fixed (材料、功能、用途、使用族群)
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="fixed_plus_custom">
|
<Radio value="fixed_plus_custom">
|
||||||
Fixed + Custom
|
Fixed + Custom (手動新增)
|
||||||
|
</Radio>
|
||||||
|
<Radio value="fixed_plus_dynamic">
|
||||||
|
Fixed + Dynamic (LLM 建議額外類別)
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="custom_only">
|
<Radio value="custom_only">
|
||||||
Custom Only (LLM suggests)
|
Custom Only (LLM 建議)
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="dynamic_auto">
|
<Radio value="dynamic_auto">
|
||||||
Dynamic (LLM suggests, editable)
|
Dynamic (LLM 建議, 可編輯)
|
||||||
</Radio>
|
</Radio>
|
||||||
</Space>
|
</Space>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
{/* 動態模式:類別數量調整 */}
|
{/* 動態模式:類別數量調整 */}
|
||||||
{(mode === 'custom_only' || mode === 'dynamic_auto') && (
|
{(mode === 'custom_only' || mode === 'dynamic_auto' || mode === 'fixed_plus_dynamic') && (
|
||||||
<div>
|
<div>
|
||||||
<Text>Suggested Category Count: {suggestedCount}</Text>
|
<Text>
|
||||||
|
{mode === 'fixed_plus_dynamic'
|
||||||
|
? `額外建議類別數: ${suggestedCount}`
|
||||||
|
: `Suggested Category Count: ${suggestedCount}`}
|
||||||
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={2}
|
min={1}
|
||||||
max={8}
|
max={mode === 'fixed_plus_dynamic' ? 4 : 8}
|
||||||
step={1}
|
step={1}
|
||||||
value={suggestedCount}
|
value={suggestedCount}
|
||||||
onChange={onSuggestedCountChange}
|
onChange={onSuggestedCountChange}
|
||||||
marks={{ 2: '2', 3: '3', 5: '5', 8: '8' }}
|
marks={mode === 'fixed_plus_dynamic'
|
||||||
|
? { 1: '1', 2: '2', 3: '3', 4: '4' }
|
||||||
|
: { 2: '2', 3: '3', 5: '5', 8: '8' }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +129,7 @@ export function CategorySelector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 0 結果顯示 */}
|
{/* Step 0 結果顯示 */}
|
||||||
{step0Result && (mode === 'custom_only' || mode === 'dynamic_auto') && (
|
{step0Result && (mode === 'custom_only' || mode === 'dynamic_auto' || mode === 'fixed_plus_dynamic') && (
|
||||||
<div style={{ marginTop: 8, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 4 }}>
|
<div style={{ marginTop: 8, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 4 }}>
|
||||||
<Text strong>LLM Suggested:</Text>
|
<Text strong>LLM Suggested:</Text>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
|
|||||||
@@ -11,23 +11,36 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Collapse,
|
Collapse,
|
||||||
Progress,
|
Progress,
|
||||||
Tag,
|
Card,
|
||||||
|
Alert,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
DownloadOutlined,
|
ReloadOutlined,
|
||||||
ExpandAltOutlined,
|
|
||||||
ShrinkOutlined,
|
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
CheckCircleOutlined,
|
FileImageOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
CodeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { HistoryItem, AttributeNode, StreamProgress, CategoryMode, DynamicCausalChain, CausalChain } from '../types';
|
import type { AttributeDAG, CategoryMode } from '../types';
|
||||||
import { getModels } from '../services/api';
|
import { getModels } from '../services/api';
|
||||||
import { CategorySelector } from './CategorySelector';
|
import { CategorySelector } from './CategorySelector';
|
||||||
|
|
||||||
|
interface DAGProgress {
|
||||||
|
step: 'idle' | 'step0' | 'step1' | 'relationships' | 'done' | 'error';
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DAGHistoryItem {
|
||||||
|
query: string;
|
||||||
|
result: AttributeDAG;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Text } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
interface VisualSettings {
|
interface VisualSettings {
|
||||||
nodeSpacing: number;
|
nodeSpacing: number;
|
||||||
@@ -36,9 +49,9 @@ interface VisualSettings {
|
|||||||
|
|
||||||
interface InputPanelProps {
|
interface InputPanelProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
progress: StreamProgress;
|
progress: DAGProgress;
|
||||||
history: HistoryItem[];
|
history: DAGHistoryItem[];
|
||||||
currentResult: AttributeNode | null;
|
currentResult: AttributeDAG | null;
|
||||||
onAnalyze: (
|
onAnalyze: (
|
||||||
query: string,
|
query: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
@@ -48,9 +61,8 @@ interface InputPanelProps {
|
|||||||
customCategories?: string[],
|
customCategories?: string[],
|
||||||
suggestedCategoryCount?: number
|
suggestedCategoryCount?: number
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onLoadHistory: (item: HistoryItem) => void;
|
onLoadHistory: (item: DAGHistoryItem) => void;
|
||||||
onExpandAll?: () => void;
|
onResetView?: () => void;
|
||||||
onCollapseAll?: () => void;
|
|
||||||
visualSettings: VisualSettings;
|
visualSettings: VisualSettings;
|
||||||
onVisualSettingsChange: (settings: VisualSettings) => void;
|
onVisualSettingsChange: (settings: VisualSettings) => void;
|
||||||
}
|
}
|
||||||
@@ -62,8 +74,7 @@ export function InputPanel({
|
|||||||
currentResult,
|
currentResult,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
onLoadHistory,
|
onLoadHistory,
|
||||||
onExpandAll,
|
onResetView,
|
||||||
onCollapseAll,
|
|
||||||
visualSettings,
|
visualSettings,
|
||||||
onVisualSettingsChange,
|
onVisualSettingsChange,
|
||||||
}: InputPanelProps) {
|
}: InputPanelProps) {
|
||||||
@@ -137,7 +148,7 @@ export function InputPanel({
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${currentResult.name || 'mindmap'}.json`;
|
a.download = `${currentResult.query || 'dag'}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
@@ -148,134 +159,131 @@ export function InputPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeToMarkdown = (node: AttributeNode, level: number = 0): string => {
|
// Group nodes by category
|
||||||
const indent = ' '.repeat(level);
|
const nodesByCategory: Record<string, string[]> = {};
|
||||||
let md = `${indent}- ${node.name}\n`;
|
for (const node of currentResult.nodes) {
|
||||||
if (node.children) {
|
if (!nodesByCategory[node.category]) {
|
||||||
node.children.forEach((child) => {
|
nodesByCategory[node.category] = [];
|
||||||
md += nodeToMarkdown(child, level + 1);
|
}
|
||||||
});
|
nodesByCategory[node.category].push(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdown = `# ${currentResult.query}\n\n`;
|
||||||
|
for (const cat of currentResult.categories) {
|
||||||
|
const nodes = nodesByCategory[cat.name] || [];
|
||||||
|
markdown += `## ${cat.name}\n`;
|
||||||
|
for (const name of nodes) {
|
||||||
|
markdown += `- ${name}\n`;
|
||||||
|
}
|
||||||
|
markdown += '\n';
|
||||||
}
|
}
|
||||||
return md;
|
|
||||||
};
|
|
||||||
|
|
||||||
const markdown = `# ${currentResult.name}\n\n${currentResult.children?.map((c) => nodeToMarkdown(c, 0)).join('') || ''}`;
|
|
||||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${currentResult.name || 'mindmap'}.md`;
|
a.download = `${currentResult.query || 'dag'}.md`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportSVG = () => {
|
const handleExportSVG = () => {
|
||||||
const svg = document.querySelector('.mindmap-svg');
|
const reactFlowWrapper = document.querySelector('.react-flow');
|
||||||
if (!svg) {
|
if (!reactFlowWrapper) {
|
||||||
message.warning('No mindmap to export');
|
message.warning('No mindmap to export');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewport = reactFlowWrapper.querySelector('.react-flow__viewport');
|
||||||
|
if (!viewport) {
|
||||||
|
message.warning('No mindmap to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', String(reactFlowWrapper.clientWidth));
|
||||||
|
svg.setAttribute('height', String(reactFlowWrapper.clientHeight));
|
||||||
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
|
||||||
|
const viewportClone = viewport.cloneNode(true) as SVGGElement;
|
||||||
|
svg.appendChild(viewportClone);
|
||||||
|
|
||||||
const svgData = new XMLSerializer().serializeToString(svg);
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${currentResult?.name || 'mindmap'}.svg`;
|
a.download = `${currentResult?.query || 'dag'}.svg`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPNG = () => {
|
const handleExportPNG = () => {
|
||||||
const svg = document.querySelector('.mindmap-svg') as SVGSVGElement;
|
const reactFlowWrapper = document.querySelector('.react-flow') as HTMLElement;
|
||||||
if (!svg) {
|
if (!reactFlowWrapper) {
|
||||||
message.warning('No mindmap to export');
|
message.warning('No mindmap to export');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewport = reactFlowWrapper.querySelector('.react-flow__viewport');
|
||||||
|
if (!viewport) {
|
||||||
|
message.warning('No mindmap to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', String(reactFlowWrapper.clientWidth));
|
||||||
|
svg.setAttribute('height', String(reactFlowWrapper.clientHeight));
|
||||||
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
svg.appendChild(viewport.cloneNode(true));
|
||||||
|
|
||||||
const svgData = new XMLSerializer().serializeToString(svg);
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
canvas.width = svg.clientWidth * 2;
|
canvas.width = reactFlowWrapper.clientWidth * 2;
|
||||||
canvas.height = svg.clientHeight * 2;
|
canvas.height = reactFlowWrapper.clientHeight * 2;
|
||||||
ctx?.scale(2, 2);
|
ctx?.scale(2, 2);
|
||||||
ctx?.drawImage(img, 0, 0);
|
ctx?.drawImage(img, 0, 0);
|
||||||
const pngUrl = canvas.toDataURL('image/png');
|
const pngUrl = canvas.toDataURL('image/png');
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = pngUrl;
|
a.href = pngUrl;
|
||||||
a.download = `${currentResult?.name || 'mindmap'}.png`;
|
a.download = `${currentResult?.query || 'dag'}.png`;
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to format chain display (supports both fixed and dynamic chains)
|
|
||||||
const formatChain = (chain: CausalChain | DynamicCausalChain): string => {
|
|
||||||
if ('chain' in chain) {
|
|
||||||
// Dynamic chain
|
|
||||||
return Object.values(chain.chain).join(' → ');
|
|
||||||
} else {
|
|
||||||
// Fixed chain
|
|
||||||
return `${chain.material} → ${chain.function} → ${chain.usage} → ${chain.user}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderProgressIndicator = () => {
|
const renderProgressIndicator = () => {
|
||||||
if (progress.step === 'idle' || progress.step === 'done') return null;
|
if (progress.step === 'idle' || progress.step === 'done') return null;
|
||||||
|
|
||||||
const percent = progress.step === 'step0'
|
const percent = progress.step === 'step0'
|
||||||
? 5
|
? 15
|
||||||
: progress.step === 'step1'
|
: progress.step === 'step1'
|
||||||
? 10
|
? 50
|
||||||
: progress.step === 'chains'
|
: progress.step === 'relationships'
|
||||||
? 10 + (progress.currentChainIndex / progress.totalChains) * 90
|
? 85
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
<Alert
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
type={progress.step === 'error' ? 'error' : 'info'}
|
||||||
<Space>
|
icon={progress.step === 'error' ? undefined : <LoadingOutlined spin />}
|
||||||
{progress.step === 'error' ? (
|
message={progress.message}
|
||||||
<Tag color="error">Error</Tag>
|
description={
|
||||||
) : (
|
<Progress
|
||||||
<LoadingOutlined spin />
|
percent={Math.round(percent)}
|
||||||
)}
|
size="small"
|
||||||
<Text>{progress.message}</Text>
|
status={progress.step === 'error' ? 'exception' : 'active'}
|
||||||
</Space>
|
strokeColor={{ from: '#108ee9', to: '#87d068' }}
|
||||||
<Progress percent={Math.round(percent)} size="small" status={progress.step === 'error' ? 'exception' : 'active'} />
|
/>
|
||||||
|
}
|
||||||
{/* Show categories used */}
|
style={{ marginBottom: 16 }}
|
||||||
{progress.categoriesUsed && progress.categoriesUsed.length > 0 && (
|
showIcon
|
||||||
<div>
|
/>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Categories:</Text>
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
{progress.categoriesUsed.map((cat, i) => (
|
|
||||||
<Tag key={i} color={cat.is_fixed ? 'default' : 'blue'}>
|
|
||||||
{cat.name}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{progress.completedChains.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Completed chains:</Text>
|
|
||||||
<div style={{ maxHeight: 120, overflow: 'auto', marginTop: 4 }}>
|
|
||||||
{progress.completedChains.map((chain, i) => (
|
|
||||||
<div key={i} style={{ fontSize: 11, padding: '2px 0' }}>
|
|
||||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />
|
|
||||||
{formatChain(chain)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,7 +299,6 @@ export function InputPanel({
|
|||||||
onCustomCategoriesChange={setCustomCategories}
|
onCustomCategoriesChange={setCustomCategories}
|
||||||
suggestedCount={suggestedCategoryCount}
|
suggestedCount={suggestedCategoryCount}
|
||||||
onSuggestedCountChange={setSuggestedCategoryCount}
|
onSuggestedCountChange={setSuggestedCategoryCount}
|
||||||
step0Result={progress.step0Result}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -300,9 +307,9 @@ export function InputPanel({
|
|||||||
key: 'llm',
|
key: 'llm',
|
||||||
label: 'LLM Parameters',
|
label: 'LLM Parameters',
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<div>
|
<div>
|
||||||
<Text>Temperature: {temperature}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>Temperature: {temperature}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
@@ -313,7 +320,7 @@ export function InputPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text>Chain Count: {chainCount}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>Chain Count: {chainCount}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
@@ -330,9 +337,9 @@ export function InputPanel({
|
|||||||
key: 'visual',
|
key: 'visual',
|
||||||
label: 'Visual Settings',
|
label: 'Visual Settings',
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<div>
|
<div>
|
||||||
<Text>Node Spacing: {visualSettings.nodeSpacing}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>Node Spacing: {visualSettings.nodeSpacing}px</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={20}
|
min={20}
|
||||||
max={80}
|
max={80}
|
||||||
@@ -341,7 +348,7 @@ export function InputPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text>Font Size: {visualSettings.fontSize}px</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>Font Size: {visualSettings.fontSize}px</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={10}
|
min={10}
|
||||||
max={18}
|
max={18}
|
||||||
@@ -349,14 +356,14 @@ export function InputPanel({
|
|||||||
onChange={(v) => onVisualSettingsChange({ ...visualSettings, fontSize: v })}
|
onChange={(v) => onVisualSettingsChange({ ...visualSettings, fontSize: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Button
|
||||||
<Button icon={<ExpandAltOutlined />} onClick={onExpandAll} disabled={!currentResult}>
|
icon={<ReloadOutlined />}
|
||||||
Expand All
|
onClick={onResetView}
|
||||||
|
disabled={!currentResult}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Reset View
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<ShrinkOutlined />} onClick={onCollapseAll} disabled={!currentResult}>
|
|
||||||
Collapse All
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -364,28 +371,63 @@ export function InputPanel({
|
|||||||
key: 'export',
|
key: 'export',
|
||||||
label: 'Export',
|
label: 'Export',
|
||||||
children: (
|
children: (
|
||||||
<Space wrap>
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExportPNG} disabled={!currentResult}>
|
<Button.Group style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
icon={<FileImageOutlined />}
|
||||||
|
onClick={handleExportPNG}
|
||||||
|
disabled={!currentResult}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
PNG
|
PNG
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExportSVG} disabled={!currentResult}>
|
<Button
|
||||||
|
icon={<FileImageOutlined />}
|
||||||
|
onClick={handleExportSVG}
|
||||||
|
disabled={!currentResult}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
SVG
|
SVG
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExportJSON} disabled={!currentResult}>
|
</Button.Group>
|
||||||
|
<Button.Group style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
icon={<CodeOutlined />}
|
||||||
|
onClick={handleExportJSON}
|
||||||
|
disabled={!currentResult}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
JSON
|
JSON
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExportMarkdown} disabled={!currentResult}>
|
<Button
|
||||||
Markdown
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={handleExportMarkdown}
|
||||||
|
disabled={!currentResult}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
MD
|
||||||
</Button>
|
</Button>
|
||||||
|
</Button.Group>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 16 }}>
|
<div style={{
|
||||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
display: 'flex',
|
||||||
<Text strong>Model</Text>
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
padding: 16,
|
||||||
|
gap: 16
|
||||||
|
}}>
|
||||||
|
{/* Main Input Card */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Text strong>Analyze Object</Text>}
|
||||||
|
styles={{ body: { padding: 12 } }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
@@ -393,17 +435,14 @@ export function InputPanel({
|
|||||||
loading={loadingModels}
|
loading={loadingModels}
|
||||||
placeholder="Select a model"
|
placeholder="Select a model"
|
||||||
options={models.map((m) => ({ label: m, value: m }))}
|
options={models.map((m) => ({ label: m, value: m }))}
|
||||||
|
size="middle"
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
|
||||||
<Text strong>Input</Text>
|
|
||||||
<TextArea
|
<TextArea
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
placeholder="Enter an object to analyze (e.g., umbrella)"
|
placeholder="Enter an object to analyze (e.g., umbrella, smartphone)"
|
||||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -411,36 +450,56 @@ export function InputPanel({
|
|||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
block
|
block
|
||||||
|
size="large"
|
||||||
>
|
>
|
||||||
Analyze
|
Analyze
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Progress Indicator */}
|
||||||
{renderProgressIndicator()}
|
{renderProgressIndicator()}
|
||||||
|
|
||||||
<Collapse items={collapseItems} defaultActiveKey={['llm']} size="small" style={{ marginBottom: 16 }} />
|
{/* Settings Collapse */}
|
||||||
|
<Collapse
|
||||||
|
items={collapseItems}
|
||||||
|
defaultActiveKey={[]}
|
||||||
|
size="small"
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '4px 0' }} />
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
{/* History Section */}
|
||||||
<Text strong>
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||||
<HistoryOutlined /> History
|
<Title level={5} style={{ marginBottom: 8 }}>
|
||||||
</Text>
|
<HistoryOutlined style={{ marginRight: 8 }} />
|
||||||
|
History
|
||||||
|
</Title>
|
||||||
<List
|
<List
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={history}
|
dataSource={history}
|
||||||
locale={{ emptyText: 'No history yet' }}
|
locale={{ emptyText: 'No history yet' }}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
style={{ cursor: 'pointer', padding: '8px 0' }}
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
onClick={() => onLoadHistory(item)}
|
onClick={() => onLoadHistory(item)}
|
||||||
|
className="history-item"
|
||||||
>
|
>
|
||||||
<Text ellipsis style={{ maxWidth: '100%' }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text ellipsis style={{ display: 'block' }}>
|
||||||
{item.query}
|
{item.query}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
{item.timestamp.toLocaleTimeString()}
|
{item.timestamp.toLocaleTimeString()}
|
||||||
</Text>
|
</Text>
|
||||||
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
104
frontend/src/components/MindmapDAG.tsx
Normal file
104
frontend/src/components/MindmapDAG.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
useReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
Background,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
|
import type { AttributeDAG } from '../types';
|
||||||
|
import { nodeTypes, useDAGLayout } from './dag';
|
||||||
|
import '../styles/mindmap.css';
|
||||||
|
|
||||||
|
interface VisualSettings {
|
||||||
|
nodeSpacing: number;
|
||||||
|
fontSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MindmapDAGProps {
|
||||||
|
data: AttributeDAG;
|
||||||
|
isDark: boolean;
|
||||||
|
visualSettings: VisualSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MindmapDAGRef {
|
||||||
|
resetView: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner component that uses useReactFlow
|
||||||
|
const MindmapDAGInner = forwardRef<MindmapDAGRef, MindmapDAGProps>(
|
||||||
|
({ data, isDark, visualSettings }, ref) => {
|
||||||
|
const { setViewport } = useReactFlow();
|
||||||
|
|
||||||
|
// Layout configuration
|
||||||
|
const layoutConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
nodeHeight: 32,
|
||||||
|
headerGap: 12,
|
||||||
|
headerHeight: 28,
|
||||||
|
nodeSpacing: Math.max(visualSettings.nodeSpacing, 40),
|
||||||
|
fontSize: visualSettings.fontSize,
|
||||||
|
isDark,
|
||||||
|
}),
|
||||||
|
[visualSettings.nodeSpacing, visualSettings.fontSize, isDark]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate nodes with layout
|
||||||
|
const nodes = useDAGLayout(data, layoutConfig);
|
||||||
|
|
||||||
|
// Expose resetView via ref
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
resetView: () => {
|
||||||
|
setViewport({ x: 50, y: 50, zoom: 1 }, { duration: 300 });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[setViewport]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={[]}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.3}
|
||||||
|
maxZoom={3}
|
||||||
|
defaultViewport={{ x: 50, y: 50, zoom: 1 }}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
color={isDark ? '#333' : '#e0e0e0'}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MindmapDAGInner.displayName = 'MindmapDAGInner';
|
||||||
|
|
||||||
|
// Wrapper with ReactFlowProvider
|
||||||
|
export const MindmapDAG = forwardRef<MindmapDAGRef, MindmapDAGProps>(
|
||||||
|
(props, ref) => (
|
||||||
|
<div
|
||||||
|
className={`mindmap-container ${props.isDark ? 'mindmap-dark' : 'mindmap-light'}`}
|
||||||
|
>
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<MindmapDAGInner {...props} ref={ref} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
MindmapDAG.displayName = 'MindmapDAG';
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { Empty, Spin } from 'antd';
|
import { Empty, Spin } from 'antd';
|
||||||
import type { AttributeNode } from '../types';
|
import type { AttributeDAG } from '../types';
|
||||||
import { MindmapD3 } from './MindmapD3';
|
import { MindmapDAG } from './MindmapDAG';
|
||||||
import type { MindmapD3Ref } from './MindmapD3';
|
import type { MindmapDAGRef } from './MindmapDAG';
|
||||||
|
|
||||||
interface VisualSettings {
|
interface VisualSettings {
|
||||||
nodeSpacing: number;
|
nodeSpacing: number;
|
||||||
@@ -10,14 +10,14 @@ interface VisualSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MindmapPanelProps {
|
interface MindmapPanelProps {
|
||||||
data: AttributeNode | null;
|
data: AttributeDAG | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
visualSettings: VisualSettings;
|
visualSettings: VisualSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MindmapPanel = forwardRef<MindmapD3Ref, MindmapPanelProps>(
|
export const MindmapPanel = forwardRef<MindmapDAGRef, MindmapPanelProps>(
|
||||||
({ data, loading, error, isDark, visualSettings }, ref) => {
|
({ data, loading, error, isDark, visualSettings }, ref) => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +64,7 @@ export const MindmapPanel = forwardRef<MindmapD3Ref, MindmapPanelProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MindmapD3 ref={ref} data={data} isDark={isDark} visualSettings={visualSettings} />;
|
return <MindmapDAG ref={ref} data={data} isDark={isDark} visualSettings={visualSettings} />;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
12
frontend/src/components/dag/index.ts
Normal file
12
frontend/src/components/dag/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { QueryNode } from './nodes/QueryNode';
|
||||||
|
import { CategoryHeaderNode } from './nodes/CategoryHeaderNode';
|
||||||
|
import { AttributeNode } from './nodes/AttributeNode';
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
query: QueryNode,
|
||||||
|
categoryHeader: CategoryHeaderNode,
|
||||||
|
attribute: AttributeNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { QueryNode, CategoryHeaderNode, AttributeNode };
|
||||||
|
export { useDAGLayout } from './useDAGLayout';
|
||||||
41
frontend/src/components/dag/nodes/AttributeNode.tsx
Normal file
41
frontend/src/components/dag/nodes/AttributeNode.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
|
interface AttributeNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
fillColor: string;
|
||||||
|
strokeColor: string;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttributeNode = memo(({ data }: AttributeNodeProps) => {
|
||||||
|
const { label, fillColor, strokeColor, fontSize } = data;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: fillColor,
|
||||||
|
border: `${isHovered ? 3 : 2}px solid ${strokeColor}`,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-width 0.2s ease, filter 0.2s ease',
|
||||||
|
filter: isHovered ? 'brightness(1.12)' : 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AttributeNode.displayName = 'AttributeNode';
|
||||||
51
frontend/src/components/dag/nodes/CategoryHeaderNode.tsx
Normal file
51
frontend/src/components/dag/nodes/CategoryHeaderNode.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface CategoryHeaderNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
isFixed: boolean;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategoryHeaderNode = memo(({ data }: CategoryHeaderNodeProps) => {
|
||||||
|
const { label, color, isFixed, isDark } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: color,
|
||||||
|
border: isFixed ? 'none' : `2px dashed ${isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)'}`,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{!isFixed && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)',
|
||||||
|
marginLeft: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CategoryHeaderNode.displayName = 'CategoryHeaderNode';
|
||||||
34
frontend/src/components/dag/nodes/QueryNode.tsx
Normal file
34
frontend/src/components/dag/nodes/QueryNode.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface QueryNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
isDark: boolean;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryNode = memo(({ data }: QueryNodeProps) => {
|
||||||
|
const { label, isDark, fontSize } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isDark ? '#177ddc' : '#1890ff',
|
||||||
|
border: `2px solid ${isDark ? '#1890ff' : '#096dd9'}`,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryNode.displayName = 'QueryNode';
|
||||||
155
frontend/src/components/dag/useDAGLayout.ts
Normal file
155
frontend/src/components/dag/useDAGLayout.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
import type { AttributeDAG, DAGNode } from '../../types';
|
||||||
|
|
||||||
|
interface LayoutConfig {
|
||||||
|
nodeHeight: number;
|
||||||
|
headerGap: number;
|
||||||
|
headerHeight: number;
|
||||||
|
nodeSpacing: number;
|
||||||
|
fontSize: number;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_PALETTE = [
|
||||||
|
{ dark: '#177ddc', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#854eca', light: '#722ed1' }, // purple
|
||||||
|
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
|
||||||
|
{ dark: '#d87a16', light: '#fa8c16' }, // orange
|
||||||
|
{ dark: '#49aa19', light: '#52c41a' }, // green
|
||||||
|
{ dark: '#1677ff', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
|
||||||
|
{ dark: '#faad14', light: '#ffc53d' }, // gold
|
||||||
|
{ dark: '#a0d911', light: '#bae637' }, // lime
|
||||||
|
];
|
||||||
|
|
||||||
|
function darken(hex: string, amount: number): string {
|
||||||
|
const num = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.max(0, ((num >> 16) & 0xff) - Math.floor(255 * amount));
|
||||||
|
const g = Math.max(0, ((num >> 8) & 0xff) - Math.floor(255 * amount));
|
||||||
|
const b = Math.max(0, (num & 0xff) - Math.floor(255 * amount));
|
||||||
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDAGLayout(
|
||||||
|
data: AttributeDAG | null,
|
||||||
|
config: LayoutConfig
|
||||||
|
): Node[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
nodeHeight,
|
||||||
|
headerGap,
|
||||||
|
headerHeight,
|
||||||
|
nodeSpacing,
|
||||||
|
fontSize,
|
||||||
|
isDark,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const sortedCategories = [...data.categories].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// Unified column spacing
|
||||||
|
const columnGap = 60; // Consistent gap between ALL adjacent elements
|
||||||
|
|
||||||
|
// Build category color map
|
||||||
|
const categoryColors: Record<string, { fill: string; stroke: string }> = {};
|
||||||
|
sortedCategories.forEach((cat, index) => {
|
||||||
|
const paletteIndex = index % COLOR_PALETTE.length;
|
||||||
|
const color = isDark
|
||||||
|
? COLOR_PALETTE[paletteIndex].dark
|
||||||
|
: COLOR_PALETTE[paletteIndex].light;
|
||||||
|
categoryColors[cat.name] = {
|
||||||
|
fill: color,
|
||||||
|
stroke: darken(color, 0.15),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group nodes by category
|
||||||
|
const nodesByCategory: Record<string, DAGNode[]> = {};
|
||||||
|
for (const node of data.nodes) {
|
||||||
|
if (!nodesByCategory[node.category]) {
|
||||||
|
nodesByCategory[node.category] = [];
|
||||||
|
}
|
||||||
|
nodesByCategory[node.category].push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort nodes within each category by order, then re-index locally
|
||||||
|
for (const cat of Object.keys(nodesByCategory)) {
|
||||||
|
nodesByCategory[cat].sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate max column height for centering
|
||||||
|
const maxNodesInColumn = Math.max(
|
||||||
|
...sortedCategories.map((cat) => (nodesByCategory[cat.name] || []).length),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const maxTotalHeight = maxNodesInColumn * (nodeHeight + nodeSpacing) - nodeSpacing;
|
||||||
|
const contentStartY = headerHeight + headerGap;
|
||||||
|
|
||||||
|
// Layout constants - UNIFORM spacing for all columns
|
||||||
|
const colStep = 160; // Distance between column left edges (uniform for all)
|
||||||
|
|
||||||
|
// Helper function for column X position
|
||||||
|
const getColumnX = (colIndex: number) => colIndex * colStep;
|
||||||
|
|
||||||
|
// 1. Add Query Node (column 0, centered vertically)
|
||||||
|
const queryY = contentStartY + (maxTotalHeight - nodeHeight) / 2;
|
||||||
|
nodes.push({
|
||||||
|
id: 'query-node',
|
||||||
|
type: 'query',
|
||||||
|
position: { x: getColumnX(0), y: queryY },
|
||||||
|
data: {
|
||||||
|
label: data.query,
|
||||||
|
isDark,
|
||||||
|
fontSize,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Add Category Headers (starting from column 1)
|
||||||
|
sortedCategories.forEach((cat, colIndex) => {
|
||||||
|
const columnX = getColumnX(colIndex + 1); // +1 because column 0 is query
|
||||||
|
nodes.push({
|
||||||
|
id: `header-${cat.name}`,
|
||||||
|
type: 'categoryHeader',
|
||||||
|
position: { x: columnX, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: cat.name,
|
||||||
|
color: categoryColors[cat.name]?.fill || '#666',
|
||||||
|
isFixed: cat.is_fixed,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Add Attribute Nodes
|
||||||
|
sortedCategories.forEach((cat, colIndex) => {
|
||||||
|
const categoryNodes = nodesByCategory[cat.name] || [];
|
||||||
|
const columnX = getColumnX(colIndex + 1); // +1 because column 0 is query
|
||||||
|
|
||||||
|
categoryNodes.forEach((node, rowIndex) => {
|
||||||
|
const y = contentStartY + rowIndex * (nodeHeight + nodeSpacing);
|
||||||
|
nodes.push({
|
||||||
|
id: node.id,
|
||||||
|
type: 'attribute',
|
||||||
|
position: { x: columnX, y },
|
||||||
|
data: {
|
||||||
|
label: node.name,
|
||||||
|
fillColor: categoryColors[node.category]?.fill || '#666',
|
||||||
|
strokeColor: categoryColors[node.category]?.stroke || '#444',
|
||||||
|
fontSize,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}, [data, config]);
|
||||||
|
}
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type {
|
import type {
|
||||||
AttributeNode,
|
AttributeDAG,
|
||||||
HistoryItem,
|
DAGStreamAnalyzeResponse
|
||||||
StreamProgress,
|
|
||||||
StreamAnalyzeResponse
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { CategoryMode } from '../types';
|
import { CategoryMode } from '../types';
|
||||||
import { analyzeAttributesStream } from '../services/api';
|
import { analyzeAttributesStream } from '../services/api';
|
||||||
|
|
||||||
|
interface DAGProgress {
|
||||||
|
step: 'idle' | 'step0' | 'step1' | 'relationships' | 'done' | 'error';
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DAGHistoryItem {
|
||||||
|
query: string;
|
||||||
|
result: AttributeDAG;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAttribute() {
|
export function useAttribute() {
|
||||||
const [progress, setProgress] = useState<StreamProgress>({
|
const [progress, setProgress] = useState<DAGProgress>({
|
||||||
step: 'idle',
|
step: 'idle',
|
||||||
currentChainIndex: 0,
|
|
||||||
totalChains: 0,
|
|
||||||
completedChains: [],
|
|
||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentResult, setCurrentResult] = useState<AttributeNode | null>(null);
|
const [currentResult, setCurrentResult] = useState<AttributeDAG | null>(null);
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const [history, setHistory] = useState<DAGHistoryItem[]>([]);
|
||||||
|
|
||||||
const analyze = useCallback(async (
|
const analyze = useCallback(async (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -32,9 +39,6 @@ export function useAttribute() {
|
|||||||
// 重置狀態
|
// 重置狀態
|
||||||
setProgress({
|
setProgress({
|
||||||
step: 'idle',
|
step: 'idle',
|
||||||
currentChainIndex: 0,
|
|
||||||
totalChains: chainCount,
|
|
||||||
completedChains: [],
|
|
||||||
message: '準備開始分析...',
|
message: '準備開始分析...',
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -53,17 +57,15 @@ export function useAttribute() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStep0Start: () => {
|
onStep0Start: () => {
|
||||||
setProgress(prev => ({
|
setProgress({
|
||||||
...prev,
|
|
||||||
step: 'step0',
|
step: 'step0',
|
||||||
message: '正在分析類別...',
|
message: '正在分析類別...',
|
||||||
}));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onStep0Complete: (result) => {
|
onStep0Complete: () => {
|
||||||
setProgress(prev => ({
|
setProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
step0Result: result,
|
|
||||||
message: '類別分析完成',
|
message: '類別分析完成',
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -71,64 +73,49 @@ export function useAttribute() {
|
|||||||
onCategoriesResolved: (categories) => {
|
onCategoriesResolved: (categories) => {
|
||||||
setProgress(prev => ({
|
setProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
categoriesUsed: categories,
|
|
||||||
message: `使用 ${categories.length} 個類別`,
|
message: `使用 ${categories.length} 個類別`,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onStep1Start: () => {
|
onStep1Start: () => {
|
||||||
setProgress(prev => ({
|
setProgress({
|
||||||
...prev,
|
|
||||||
step: 'step1',
|
step: 'step1',
|
||||||
message: '正在分析物件屬性列表...',
|
message: '正在分析物件屬性列表...',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onStep1Complete: () => {
|
||||||
|
setProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
message: '屬性列表分析完成',
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onStep1Complete: (step1Result) => {
|
onRelationshipsStart: () => {
|
||||||
|
setProgress({
|
||||||
|
step: 'relationships',
|
||||||
|
message: '正在生成關係...',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onRelationshipsComplete: (count) => {
|
||||||
setProgress(prev => ({
|
setProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
step1Result,
|
message: `生成 ${count} 個關係`,
|
||||||
message: '屬性列表分析完成,開始生成因果鏈...',
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onChainStart: (index, total) => {
|
onDone: (response: DAGStreamAnalyzeResponse) => {
|
||||||
setProgress(prev => ({
|
setProgress({
|
||||||
...prev,
|
|
||||||
step: 'chains',
|
|
||||||
currentChainIndex: index,
|
|
||||||
totalChains: total,
|
|
||||||
message: `正在生成第 ${index}/${total} 條因果鏈...`,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onChainComplete: (index, chain) => {
|
|
||||||
setProgress(prev => ({
|
|
||||||
...prev,
|
|
||||||
completedChains: [...prev.completedChains, chain],
|
|
||||||
message: `第 ${index} 條因果鏈生成完成`,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onChainError: (index, errorMsg) => {
|
|
||||||
setProgress(prev => ({
|
|
||||||
...prev,
|
|
||||||
message: `第 ${index} 條因果鏈生成失敗: ${errorMsg}`,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onDone: (response: StreamAnalyzeResponse) => {
|
|
||||||
setProgress(prev => ({
|
|
||||||
...prev,
|
|
||||||
step: 'done',
|
step: 'done',
|
||||||
message: '分析完成!',
|
message: '分析完成!',
|
||||||
}));
|
});
|
||||||
setCurrentResult(response.attributes);
|
setCurrentResult(response.dag);
|
||||||
|
|
||||||
setHistory((prev) => [
|
setHistory((prev) => [
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
result: response.attributes,
|
result: response.dag,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
@@ -136,37 +123,32 @@ export function useAttribute() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onError: (errorMsg) => {
|
onError: (errorMsg) => {
|
||||||
setProgress(prev => ({
|
setProgress({
|
||||||
...prev,
|
|
||||||
step: 'error',
|
step: 'error',
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
message: `錯誤: ${errorMsg}`,
|
message: `錯誤: ${errorMsg}`,
|
||||||
}));
|
});
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
setProgress(prev => ({
|
setProgress({
|
||||||
...prev,
|
|
||||||
step: 'error',
|
step: 'error',
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
message: `錯誤: ${errorMessage}`,
|
message: `錯誤: ${errorMessage}`,
|
||||||
}));
|
});
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadFromHistory = useCallback((item: HistoryItem) => {
|
const loadFromHistory = useCallback((item: DAGHistoryItem) => {
|
||||||
setCurrentResult(item.result);
|
setCurrentResult(item.result);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress({
|
setProgress({
|
||||||
step: 'done',
|
step: 'done',
|
||||||
currentChainIndex: 0,
|
|
||||||
totalChains: 0,
|
|
||||||
completedChains: [],
|
|
||||||
message: '從歷史記錄載入',
|
message: '從歷史記錄載入',
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -176,14 +158,11 @@ export function useAttribute() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setProgress({
|
setProgress({
|
||||||
step: 'idle',
|
step: 'idle',
|
||||||
currentChainIndex: 0,
|
|
||||||
totalChains: 0,
|
|
||||||
completedChains: [],
|
|
||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isLoading = progress.step === 'step0' || progress.step === 'step1' || progress.step === 'chains';
|
const isLoading = progress.step === 'step0' || progress.step === 'step1' || progress.step === 'relationships';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
|
|||||||
@@ -8,9 +8,102 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global transitions for smooth theme switching */
|
||||||
|
.ant-layout,
|
||||||
|
.ant-layout-header,
|
||||||
|
.ant-layout-sider,
|
||||||
|
.ant-layout-content,
|
||||||
|
.ant-card,
|
||||||
|
.ant-collapse,
|
||||||
|
.ant-btn {
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History item hover effect */
|
||||||
|
.history-item {
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background-color: rgba(24, 144, 255, 0.08) !important;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover enhancement */
|
||||||
|
.ant-btn {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card subtle shadow */
|
||||||
|
.ant-card {
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03), 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse panel enhancement */
|
||||||
|
.ant-collapse {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-collapse-item {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider enhancement */
|
||||||
|
.ant-slider-handle {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-handle:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select dropdown enhancement */
|
||||||
|
.ant-select-dropdown {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert enhancement */
|
||||||
|
.ant-alert {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
ModelListResponse,
|
ModelListResponse,
|
||||||
StreamAnalyzeRequest,
|
StreamAnalyzeRequest,
|
||||||
StreamAnalyzeResponse,
|
|
||||||
Step1Result,
|
Step1Result,
|
||||||
CausalChain,
|
|
||||||
Step0Result,
|
Step0Result,
|
||||||
CategoryDefinition,
|
CategoryDefinition,
|
||||||
DynamicStep1Result,
|
DynamicStep1Result,
|
||||||
DynamicCausalChain
|
DAGStreamAnalyzeResponse
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||||||
@@ -19,10 +17,9 @@ export interface SSECallbacks {
|
|||||||
onCategoriesResolved?: (categories: CategoryDefinition[]) => void;
|
onCategoriesResolved?: (categories: CategoryDefinition[]) => void;
|
||||||
onStep1Start?: () => void;
|
onStep1Start?: () => void;
|
||||||
onStep1Complete?: (result: Step1Result | DynamicStep1Result) => void;
|
onStep1Complete?: (result: Step1Result | DynamicStep1Result) => void;
|
||||||
onChainStart?: (index: number, total: number) => void;
|
onRelationshipsStart?: () => void;
|
||||||
onChainComplete?: (index: number, chain: CausalChain | DynamicCausalChain) => void;
|
onRelationshipsComplete?: (count: number) => void;
|
||||||
onChainError?: (index: number, error: string) => void;
|
onDone?: (response: DAGStreamAnalyzeResponse) => void;
|
||||||
onDone?: (response: StreamAnalyzeResponse) => void;
|
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +84,11 @@ export async function analyzeAttributesStream(
|
|||||||
case 'step1_complete':
|
case 'step1_complete':
|
||||||
callbacks.onStep1Complete?.(eventData.result);
|
callbacks.onStep1Complete?.(eventData.result);
|
||||||
break;
|
break;
|
||||||
case 'chain_start':
|
case 'relationships_start':
|
||||||
callbacks.onChainStart?.(eventData.index, eventData.total);
|
callbacks.onRelationshipsStart?.();
|
||||||
break;
|
break;
|
||||||
case 'chain_complete':
|
case 'relationships_complete':
|
||||||
callbacks.onChainComplete?.(eventData.index, eventData.chain);
|
callbacks.onRelationshipsComplete?.(eventData.count);
|
||||||
break;
|
|
||||||
case 'chain_error':
|
|
||||||
callbacks.onChainError?.(eventData.index, eventData.error);
|
|
||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
callbacks.onDone?.(eventData);
|
callbacks.onDone?.(eventData);
|
||||||
|
|||||||
@@ -2,289 +2,29 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-svg {
|
/* React Flow overrides */
|
||||||
|
.react-flow {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
.mindmap-dark .react-flow {
|
||||||
cursor: pointer;
|
background: #141414;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node rect {
|
.mindmap-light .react-flow {
|
||||||
stroke-width: 2px;
|
background: #fafafa;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node rect:hover {
|
/* Hide React Flow attribution */
|
||||||
stroke-width: 3px;
|
.react-flow__attribution {
|
||||||
filter: brightness(1.1);
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node text {
|
/* Background pattern */
|
||||||
font-size: 13px;
|
.react-flow__background {
|
||||||
font-weight: 500;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 2px;
|
|
||||||
transition: stroke 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme */
|
|
||||||
.mindmap-light .node-rect {
|
|
||||||
stroke: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.collapsed {
|
|
||||||
stroke-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme - Category colors */
|
|
||||||
.mindmap-light .node-rect.category-root {
|
|
||||||
fill: #1890ff;
|
|
||||||
stroke: #096dd9;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.category-root {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.category-材料 {
|
|
||||||
fill: #722ed1;
|
|
||||||
stroke: #531dab;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.category-材料 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.category-功能 {
|
|
||||||
fill: #13c2c2;
|
|
||||||
stroke: #08979c;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.category-功能 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.category-用途 {
|
|
||||||
fill: #fa8c16;
|
|
||||||
stroke: #d46b08;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.category-用途 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.category-使用族群 {
|
|
||||||
fill: #52c41a;
|
|
||||||
stroke: #389e0d;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.category-使用族群 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme - Fallback depth colors */
|
|
||||||
.mindmap-light .node-rect.depth-0 {
|
|
||||||
fill: #1890ff;
|
|
||||||
stroke: #096dd9;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-0 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-1 {
|
|
||||||
fill: #722ed1;
|
|
||||||
stroke: #531dab;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-1 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-2 {
|
|
||||||
fill: #13c2c2;
|
|
||||||
stroke: #08979c;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-2 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-3 {
|
|
||||||
fill: #fa8c16;
|
|
||||||
stroke: #d46b08;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-3 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-4 {
|
|
||||||
fill: #52c41a;
|
|
||||||
stroke: #389e0d;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-4 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-5 {
|
|
||||||
fill: #1890ff;
|
|
||||||
stroke: #096dd9;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-5 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-6 {
|
|
||||||
fill: #f759ab;
|
|
||||||
stroke: #eb2f96;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-6 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-7 {
|
|
||||||
fill: #ffc53d;
|
|
||||||
stroke: #faad14;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-7 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .node-rect.depth-8 {
|
|
||||||
fill: #bae637;
|
|
||||||
stroke: #a0d911;
|
|
||||||
}
|
|
||||||
.mindmap-light .node-text.depth-8 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-light .link {
|
|
||||||
stroke: #bfbfbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme */
|
|
||||||
.mindmap-dark .node-rect {
|
|
||||||
stroke: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.collapsed {
|
|
||||||
stroke-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme - Category colors */
|
|
||||||
.mindmap-dark .node-rect.category-root {
|
|
||||||
fill: #177ddc;
|
|
||||||
stroke: #1890ff;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.category-root {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.category-材料 {
|
|
||||||
fill: #854eca;
|
|
||||||
stroke: #9254de;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.category-材料 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.category-功能 {
|
|
||||||
fill: #13a8a8;
|
|
||||||
stroke: #36cfc9;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.category-功能 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.category-用途 {
|
|
||||||
fill: #d87a16;
|
|
||||||
stroke: #ffa940;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.category-用途 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.category-使用族群 {
|
|
||||||
fill: #49aa19;
|
|
||||||
stroke: #73d13d;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.category-使用族群 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme - Fallback depth colors */
|
|
||||||
.mindmap-dark .node-rect.depth-0 {
|
|
||||||
fill: #177ddc;
|
|
||||||
stroke: #1890ff;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-0 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-1 {
|
|
||||||
fill: #854eca;
|
|
||||||
stroke: #9254de;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-1 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-2 {
|
|
||||||
fill: #13a8a8;
|
|
||||||
stroke: #36cfc9;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-2 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-3 {
|
|
||||||
fill: #d87a16;
|
|
||||||
stroke: #ffa940;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-3 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-4 {
|
|
||||||
fill: #49aa19;
|
|
||||||
stroke: #73d13d;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-4 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-5 {
|
|
||||||
fill: #1677ff;
|
|
||||||
stroke: #4096ff;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-5 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-6 {
|
|
||||||
fill: #eb2f96;
|
|
||||||
stroke: #f759ab;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-6 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-7 {
|
|
||||||
fill: #faad14;
|
|
||||||
stroke: #ffc53d;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-7 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .node-rect.depth-8 {
|
|
||||||
fill: #a0d911;
|
|
||||||
stroke: #bae637;
|
|
||||||
}
|
|
||||||
.mindmap-dark .node-text.depth-8 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindmap-dark .link {
|
|
||||||
stroke: #434343;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface DynamicCausalChain {
|
|||||||
export const CategoryMode = {
|
export const CategoryMode = {
|
||||||
FIXED_ONLY: 'fixed_only',
|
FIXED_ONLY: 'fixed_only',
|
||||||
FIXED_PLUS_CUSTOM: 'fixed_plus_custom',
|
FIXED_PLUS_CUSTOM: 'fixed_plus_custom',
|
||||||
|
FIXED_PLUS_DYNAMIC: 'fixed_plus_dynamic',
|
||||||
CUSTOM_ONLY: 'custom_only',
|
CUSTOM_ONLY: 'custom_only',
|
||||||
DYNAMIC_AUTO: 'dynamic_auto',
|
DYNAMIC_AUTO: 'dynamic_auto',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -113,3 +114,40 @@ export interface StreamAnalyzeResponse {
|
|||||||
causal_chains: (CausalChain | DynamicCausalChain)[];
|
causal_chains: (CausalChain | DynamicCausalChain)[];
|
||||||
attributes: AttributeNode;
|
attributes: AttributeNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== DAG (Directed Acyclic Graph) types =====
|
||||||
|
|
||||||
|
export interface DAGNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DAGEdge {
|
||||||
|
source_id: string;
|
||||||
|
target_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeDAG {
|
||||||
|
query: string;
|
||||||
|
categories: CategoryDefinition[];
|
||||||
|
nodes: DAGNode[];
|
||||||
|
edges: DAGEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DAGRelationship {
|
||||||
|
source_category: string;
|
||||||
|
source: string;
|
||||||
|
target_category: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DAGStreamAnalyzeResponse {
|
||||||
|
query: string;
|
||||||
|
step0_result?: Step0Result;
|
||||||
|
categories_used: CategoryDefinition[];
|
||||||
|
step1_result: DynamicStep1Result;
|
||||||
|
relationships: DAGRelationship[];
|
||||||
|
dag: AttributeDAG;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user