Initial commit
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Node / frontend artifacts
|
||||
node_modules/
|
||||
dist/
|
||||
.cache/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.eslintcache
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*.local
|
||||
.pids
|
||||
|
||||
# Python artifacts
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.cover
|
||||
.coverage*
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.py,cover
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
15
backend/app/config.py
Normal file
15
backend/app/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
ollama_base_url: str = "http://192.168.30.36:11434"
|
||||
default_model: str = "qwen3:8b"
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
41
backend/app/main.py
Normal file
41
backend/app/main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import attributes
|
||||
from .services.llm_service import ollama_provider
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await ollama_provider.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Attribute Agent API",
|
||||
description="API for analyzing objects and extracting their attributes",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(attributes.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Attribute Agent API is running"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
61
backend/app/models/schemas.py
Normal file
61
backend/app/models/schemas.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class AttributeNode(BaseModel):
|
||||
name: str
|
||||
category: Optional[str] = None # 材料, 功能, 用途, 使用族群
|
||||
children: Optional[List["AttributeNode"]] = None
|
||||
|
||||
|
||||
AttributeNode.model_rebuild()
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
query: str
|
||||
model: Optional[str] = None
|
||||
temperature: Optional[float] = 0.7
|
||||
categories: Optional[List[str]] = None # 如果為 None,使用預設類別
|
||||
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
query: str
|
||||
attributes: AttributeNode
|
||||
|
||||
|
||||
class ModelListResponse(BaseModel):
|
||||
models: List[str]
|
||||
|
||||
|
||||
# ===== Multi-step streaming schemas =====
|
||||
|
||||
class Step1Result(BaseModel):
|
||||
"""Step 1 的結果:各類別屬性列表"""
|
||||
materials: List[str]
|
||||
functions: List[str]
|
||||
usages: List[str]
|
||||
users: List[str]
|
||||
|
||||
|
||||
class CausalChain(BaseModel):
|
||||
"""單條因果鏈"""
|
||||
material: str
|
||||
function: str
|
||||
usage: str
|
||||
user: str
|
||||
|
||||
|
||||
class StreamAnalyzeRequest(BaseModel):
|
||||
"""多步驟分析請求"""
|
||||
query: str
|
||||
model: Optional[str] = None
|
||||
temperature: Optional[float] = 0.7
|
||||
chain_count: int = 5 # 用戶可設定要生成多少條因果鏈
|
||||
|
||||
|
||||
class StreamAnalyzeResponse(BaseModel):
|
||||
"""最終完整結果"""
|
||||
query: str
|
||||
step1_result: Step1Result
|
||||
causal_chains: List[CausalChain]
|
||||
attributes: AttributeNode
|
||||
0
backend/app/prompts/__init__.py
Normal file
0
backend/app/prompts/__init__.py
Normal file
117
backend/app/prompts/attribute_prompt.py
Normal file
117
backend/app/prompts/attribute_prompt.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from typing import List, Optional
|
||||
|
||||
DEFAULT_CATEGORIES = ["材料", "功能", "用途", "使用族群", "特性"]
|
||||
|
||||
CATEGORY_DESCRIPTIONS = {
|
||||
"材料": "物件由什麼材料組成",
|
||||
"功能": "物件能做什麼",
|
||||
"用途": "物件在什麼場景使用",
|
||||
"使用族群": "誰會使用這個物件",
|
||||
"特性": "物件有什麼特徵",
|
||||
}
|
||||
|
||||
|
||||
def get_attribute_prompt(query: str, categories: Optional[List[str]] = None) -> str:
|
||||
"""Generate prompt with causal chain structure."""
|
||||
|
||||
prompt = f"""分析「{query}」的屬性,以因果鏈方式呈現:材料→功能→用途→使用族群。
|
||||
|
||||
請列出 3-5 種材料,每種材料延伸出完整因果鏈。
|
||||
|
||||
JSON 格式:
|
||||
{{"name": "{query}", "children": [{{"name": "材料名", "category": "材料", "children": [{{"name": "功能名", "category": "功能", "children": [{{"name": "用途名", "category": "用途", "children": [{{"name": "族群名", "category": "使用族群"}}]}}]}}]}}]}}
|
||||
|
||||
只回傳 JSON。"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def get_step1_attributes_prompt(query: str) -> str:
|
||||
"""Step 1: 生成各類別的屬性列表(平行結構)"""
|
||||
return f"""/no_think
|
||||
分析「{query}」,列出以下四個類別的屬性。每個類別列出 3-5 個常見屬性。
|
||||
|
||||
只回傳 JSON,格式如下:
|
||||
{{"materials": ["材料1", "材料2", "材料3"], "functions": ["功能1", "功能2", "功能3"], "usages": ["用途1", "用途2", "用途3"], "users": ["族群1", "族群2", "族群3"]}}
|
||||
|
||||
物件:{query}"""
|
||||
|
||||
|
||||
def get_step2_causal_chain_prompt(
|
||||
query: str,
|
||||
materials: List[str],
|
||||
functions: List[str],
|
||||
usages: List[str],
|
||||
users: List[str],
|
||||
existing_chains: List[dict],
|
||||
chain_index: int
|
||||
) -> str:
|
||||
"""Step 2: 生成單條因果鏈"""
|
||||
existing_chains_text = ""
|
||||
if existing_chains:
|
||||
chains_list = [
|
||||
f"- {c['material']} → {c['function']} → {c['usage']} → {c['user']}"
|
||||
for c in existing_chains
|
||||
]
|
||||
existing_chains_text = f"""
|
||||
【已生成的因果鏈,請勿重複】
|
||||
{chr(10).join(chains_list)}
|
||||
"""
|
||||
|
||||
return f"""/no_think
|
||||
為「{query}」生成第 {chain_index} 條因果鏈。
|
||||
|
||||
【可選材料】{', '.join(materials)}
|
||||
【可選功能】{', '.join(functions)}
|
||||
【可選用途】{', '.join(usages)}
|
||||
【可選族群】{', '.join(users)}
|
||||
{existing_chains_text}
|
||||
【規則】
|
||||
1. 從每個類別選擇一個屬性,組成合理的因果鏈
|
||||
2. 因果關係必須合邏輯(材料決定功能,功能決定用途,用途決定族群)
|
||||
3. 不要與已生成的因果鏈重複
|
||||
|
||||
只回傳 JSON:
|
||||
{{"material": "選擇的材料", "function": "選擇的功能", "usage": "選擇的用途", "user": "選擇的族群"}}"""
|
||||
|
||||
|
||||
def get_flat_attribute_prompt(query: str, categories: Optional[List[str]] = None) -> str:
|
||||
"""Generate prompt with flat/parallel categories (original design)."""
|
||||
cats = categories if categories else DEFAULT_CATEGORIES
|
||||
|
||||
# Build category list
|
||||
category_lines = []
|
||||
for cat in cats:
|
||||
desc = CATEGORY_DESCRIPTIONS.get(cat, f"{cat}的相關屬性")
|
||||
category_lines.append(f"- {cat}:{desc}")
|
||||
|
||||
categories_text = "\n".join(category_lines)
|
||||
|
||||
prompt = f"""/no_think
|
||||
你是一個物件屬性分析專家。請將用戶輸入的物件拆解成以下屬性類別。
|
||||
|
||||
【必須包含的類別】
|
||||
{categories_text}
|
||||
|
||||
【重要】回傳格式必須是合法的 JSON,每個節點都必須有 "name" 欄位:
|
||||
|
||||
```json
|
||||
{{
|
||||
"name": "物件名稱",
|
||||
"children": [
|
||||
{{
|
||||
"name": "類別名稱",
|
||||
"children": [
|
||||
{{"name": "屬性1"}},
|
||||
{{"name": "屬性2"}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
只回傳 JSON,不要有任何其他文字。
|
||||
|
||||
用戶輸入:{query}"""
|
||||
|
||||
return prompt
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
178
backend/app/routers/attributes.py
Normal file
178
backend/app/routers/attributes.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..models.schemas import (
|
||||
ModelListResponse,
|
||||
StreamAnalyzeRequest,
|
||||
Step1Result,
|
||||
CausalChain,
|
||||
AttributeNode,
|
||||
)
|
||||
from ..prompts.attribute_prompt import (
|
||||
get_step1_attributes_prompt,
|
||||
get_step2_causal_chain_prompt,
|
||||
)
|
||||
from ..services.llm_service import ollama_provider, extract_json_from_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["attributes"])
|
||||
|
||||
|
||||
def assemble_attribute_tree(query: str, chains: List[CausalChain]) -> AttributeNode:
|
||||
"""將因果鏈組裝成樹狀結構"""
|
||||
# 以材料為第一層分組
|
||||
material_map = {}
|
||||
|
||||
for chain in chains:
|
||||
if chain.material not in material_map:
|
||||
material_map[chain.material] = []
|
||||
material_map[chain.material].append(chain)
|
||||
|
||||
# 構建樹狀結構
|
||||
root = AttributeNode(name=query, children=[])
|
||||
|
||||
for material, material_chains in material_map.items():
|
||||
material_node = AttributeNode(name=material, category="材料", children=[])
|
||||
|
||||
# 以功能為第二層分組
|
||||
function_map = {}
|
||||
for chain in material_chains:
|
||||
if chain.function not in function_map:
|
||||
function_map[chain.function] = []
|
||||
function_map[chain.function].append(chain)
|
||||
|
||||
for function, function_chains in function_map.items():
|
||||
function_node = AttributeNode(name=function, category="功能", children=[])
|
||||
|
||||
# 以用途為第三層分組
|
||||
usage_map = {}
|
||||
for chain in function_chains:
|
||||
if chain.usage not in usage_map:
|
||||
usage_map[chain.usage] = []
|
||||
usage_map[chain.usage].append(chain)
|
||||
|
||||
for usage, usage_chains in usage_map.items():
|
||||
usage_node = AttributeNode(
|
||||
name=usage,
|
||||
category="用途",
|
||||
children=[
|
||||
AttributeNode(name=c.user, category="使用族群")
|
||||
for c in usage_chains
|
||||
],
|
||||
)
|
||||
function_node.children.append(usage_node)
|
||||
|
||||
material_node.children.append(function_node)
|
||||
|
||||
root.children.append(material_node)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[str, None]:
|
||||
"""生成 SSE 事件流"""
|
||||
try:
|
||||
temperature = request.temperature if request.temperature is not None else 0.7
|
||||
|
||||
# ========== Step 1: 生成屬性列表 ==========
|
||||
yield f"event: step1_start\ndata: {json.dumps({'message': '正在分析屬性列表...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
step1_prompt = get_step1_attributes_prompt(request.query)
|
||||
logger.info(f"Step 1 prompt: {step1_prompt[:200]}")
|
||||
|
||||
step1_response = await ollama_provider.generate(
|
||||
step1_prompt, model=request.model, temperature=temperature
|
||||
)
|
||||
logger.info(f"Step 1 response: {step1_response[:500]}")
|
||||
|
||||
step1_data = extract_json_from_response(step1_response)
|
||||
step1_result = Step1Result(**step1_data)
|
||||
|
||||
yield f"event: step1_complete\ndata: {json.dumps({'result': step1_result.model_dump()}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# ========== Step 2: 逐條生成因果鏈 ==========
|
||||
causal_chains: List[CausalChain] = []
|
||||
|
||||
for i in range(request.chain_count):
|
||||
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_causal_chain_prompt(
|
||||
query=request.query,
|
||||
materials=step1_result.materials,
|
||||
functions=step1_result.functions,
|
||||
usages=step1_result.usages,
|
||||
users=step1_result.users,
|
||||
existing_chains=[c.model_dump() for c in causal_chains],
|
||||
chain_index=chain_index,
|
||||
)
|
||||
|
||||
# 逐漸提高 temperature 增加多樣性
|
||||
chain_temperature = min(temperature + 0.05 * i, 1.0)
|
||||
|
||||
max_retries = 2
|
||||
chain = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
step2_response = await ollama_provider.generate(
|
||||
step2_prompt, model=request.model, temperature=chain_temperature
|
||||
)
|
||||
logger.info(f"Chain {chain_index} response: {step2_response[:300]}")
|
||||
|
||||
chain_data = extract_json_from_response(step2_response)
|
||||
chain = CausalChain(**chain_data)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Chain {chain_index} attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
chain_temperature = min(chain_temperature + 0.1, 1.0)
|
||||
|
||||
if chain:
|
||||
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'第 {chain_index} 條因果鏈生成失敗'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# ========== 組裝最終結構 ==========
|
||||
final_tree = assemble_attribute_tree(request.query, causal_chains)
|
||||
|
||||
final_result = {
|
||||
"query": request.query,
|
||||
"step1_result": step1_result.model_dump(),
|
||||
"causal_chains": [c.model_dump() for c in causal_chains],
|
||||
"attributes": final_tree.model_dump(),
|
||||
}
|
||||
yield f"event: done\ndata: {json.dumps(final_result, ensure_ascii=False)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSE generation error: {e}")
|
||||
yield f"event: error\ndata: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_stream(request: StreamAnalyzeRequest):
|
||||
"""多步驟分析 with SSE streaming"""
|
||||
return StreamingResponse(
|
||||
generate_sse_events(request),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelListResponse)
|
||||
async def list_models():
|
||||
"""List available LLM models."""
|
||||
try:
|
||||
models = await ollama_provider.list_models()
|
||||
return ModelListResponse(models=models)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
160
backend/app/services/llm_service.py
Normal file
160
backend/app/services/llm_service.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import json
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
from ..models.schemas import AttributeNode
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
@abstractmethod
|
||||
async def generate(
|
||||
self, prompt: str, model: Optional[str] = None, temperature: float = 0.7
|
||||
) -> str:
|
||||
"""Generate a response from the LLM."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> List[str]:
|
||||
"""List available models."""
|
||||
pass
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
def __init__(self, base_url: str = None):
|
||||
self.base_url = base_url or settings.ollama_base_url
|
||||
# Increase timeout for larger models (14B, 32B, etc.)
|
||||
self.client = httpx.AsyncClient(timeout=300.0)
|
||||
|
||||
async def generate(
|
||||
self, prompt: str, model: Optional[str] = None, temperature: float = 0.7
|
||||
) -> str:
|
||||
model = model or settings.default_model
|
||||
url = f"{self.base_url}/api/generate"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
},
|
||||
}
|
||||
|
||||
# Retry logic for larger models that may return empty responses
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
response = await self.client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
response_text = result.get("response", "")
|
||||
|
||||
# Check if response is valid (not empty or just "{}")
|
||||
if response_text and response_text.strip() not in ["", "{}", "{ }"]:
|
||||
return response_text
|
||||
|
||||
# If empty, retry with slightly higher temperature
|
||||
if attempt < max_retries - 1:
|
||||
payload["options"]["temperature"] = min(temperature + 0.1 * (attempt + 1), 1.0)
|
||||
|
||||
# Return whatever we got on last attempt
|
||||
return response_text
|
||||
|
||||
async def list_models(self) -> List[str]:
|
||||
url = f"{self.base_url}/api/tags"
|
||||
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
models = result.get("models", [])
|
||||
return [m.get("name", "") for m in models if m.get("name")]
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(LLMProvider):
|
||||
def __init__(self, base_url: str = None, api_key: str = None):
|
||||
self.base_url = base_url or settings.openai_base_url
|
||||
self.api_key = api_key or settings.openai_api_key
|
||||
# Increase timeout for larger models
|
||||
self.client = httpx.AsyncClient(timeout=300.0)
|
||||
|
||||
async def generate(self, prompt: str, model: Optional[str] = None) -> str:
|
||||
model = model or settings.default_model
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
|
||||
response = await self.client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"]
|
||||
|
||||
async def list_models(self) -> List[str]:
|
||||
url = f"{self.base_url}/v1/models"
|
||||
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
response = await self.client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
return [m.get("id", "") for m in result.get("data", []) if m.get("id")]
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
def extract_json_from_response(response: str) -> dict:
|
||||
"""Extract JSON from LLM response, handling markdown code blocks and extra whitespace."""
|
||||
# Remove markdown code blocks if present
|
||||
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", response)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
json_str = response
|
||||
|
||||
# Clean up: remove extra whitespace, normalize spaces
|
||||
json_str = json_str.strip()
|
||||
# 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)
|
||||
|
||||
|
||||
def parse_attribute_response(response: str) -> AttributeNode:
|
||||
"""Parse LLM response into AttributeNode structure."""
|
||||
data = extract_json_from_response(response)
|
||||
return AttributeNode.model_validate(data)
|
||||
|
||||
|
||||
def get_llm_provider(provider_type: str = "ollama") -> LLMProvider:
|
||||
"""Factory function to get LLM provider."""
|
||||
if provider_type == "ollama":
|
||||
return OllamaProvider()
|
||||
elif provider_type == "openai":
|
||||
return OpenAICompatibleProvider()
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
|
||||
ollama_provider = OllamaProvider()
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
httpx>=0.26.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
python-dotenv>=1.0.0
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4959
frontend/package-lock.json
generated
Normal file
4959
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"antd": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
107
frontend/src/App.tsx
Normal file
107
frontend/src/App.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { ConfigProvider, Layout, theme, Typography } from 'antd';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import { InputPanel } from './components/InputPanel';
|
||||
import { MindmapPanel } from './components/MindmapPanel';
|
||||
import { useAttribute } from './hooks/useAttribute';
|
||||
import type { MindmapD3Ref } from './components/MindmapD3';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
|
||||
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
|
||||
nodeSpacing: 32,
|
||||
fontSize: 14,
|
||||
});
|
||||
const mindmapRef = useRef<MindmapD3Ref>(null);
|
||||
|
||||
const handleAnalyze = async (
|
||||
query: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
chainCount?: number
|
||||
) => {
|
||||
await analyze(query, model, temperature, chainCount);
|
||||
};
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
mindmapRef.current?.expandAll();
|
||||
}, []);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
mindmapRef.current?.collapseAll();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0, color: isDark ? '#fff' : '#000' }}>
|
||||
Attribute Agent
|
||||
</Title>
|
||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||
</Header>
|
||||
<Layout>
|
||||
<Content
|
||||
style={{
|
||||
padding: 16,
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MindmapPanel
|
||||
ref={mindmapRef}
|
||||
data={currentResult}
|
||||
loading={loading}
|
||||
error={error}
|
||||
isDark={isDark}
|
||||
visualSettings={visualSettings}
|
||||
/>
|
||||
</Content>
|
||||
<Sider
|
||||
width={350}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<InputPanel
|
||||
loading={loading}
|
||||
progress={progress}
|
||||
history={history}
|
||||
currentResult={currentResult}
|
||||
onAnalyze={handleAnalyze}
|
||||
onLoadHistory={loadFromHistory}
|
||||
onExpandAll={handleExpandAll}
|
||||
onCollapseAll={handleCollapseAll}
|
||||
visualSettings={visualSettings}
|
||||
onVisualSettingsChange={setVisualSettings}
|
||||
/>
|
||||
</Sider>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
386
frontend/src/components/InputPanel.tsx
Normal file
386
frontend/src/components/InputPanel.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
List,
|
||||
Typography,
|
||||
Space,
|
||||
message,
|
||||
Slider,
|
||||
Divider,
|
||||
Collapse,
|
||||
Progress,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
HistoryOutlined,
|
||||
DownloadOutlined,
|
||||
ExpandAltOutlined,
|
||||
ShrinkOutlined,
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { HistoryItem, AttributeNode, StreamProgress } from '../types';
|
||||
import { getModels } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface InputPanelProps {
|
||||
loading: boolean;
|
||||
progress: StreamProgress;
|
||||
history: HistoryItem[];
|
||||
currentResult: AttributeNode | null;
|
||||
onAnalyze: (query: string, model?: string, temperature?: number, chainCount?: number) => Promise<void>;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
visualSettings: VisualSettings;
|
||||
onVisualSettingsChange: (settings: VisualSettings) => void;
|
||||
}
|
||||
|
||||
export function InputPanel({
|
||||
loading,
|
||||
progress,
|
||||
history,
|
||||
currentResult,
|
||||
onAnalyze,
|
||||
onLoadHistory,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
visualSettings,
|
||||
onVisualSettingsChange,
|
||||
}: InputPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
const [chainCount, setChainCount] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchModels() {
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const response = await getModels();
|
||||
setModels(response.models);
|
||||
if (response.models.length > 0 && !selectedModel) {
|
||||
const defaultModel = response.models.find((m) => m.includes('qwen3')) || response.models[0];
|
||||
setSelectedModel(defaultModel);
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to fetch models');
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('Please enter a query');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAnalyze(query.trim(), selectedModel, temperature, chainCount);
|
||||
setQuery('');
|
||||
} catch {
|
||||
message.error('Analysis failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJSON = () => {
|
||||
if (!currentResult) {
|
||||
message.warning('No data to export');
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(currentResult, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult.name || 'mindmap'}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
if (!currentResult) {
|
||||
message.warning('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeToMarkdown = (node: AttributeNode, level: number = 0): string => {
|
||||
const indent = ' '.repeat(level);
|
||||
let md = `${indent}- ${node.name}\n`;
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => {
|
||||
md += nodeToMarkdown(child, level + 1);
|
||||
});
|
||||
}
|
||||
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 url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult.name || 'mindmap'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportSVG = () => {
|
||||
const svg = document.querySelector('.mindmap-svg');
|
||||
if (!svg) {
|
||||
message.warning('No mindmap to export');
|
||||
return;
|
||||
}
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentResult?.name || 'mindmap'}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportPNG = () => {
|
||||
const svg = document.querySelector('.mindmap-svg') as SVGSVGElement;
|
||||
if (!svg) {
|
||||
message.warning('No mindmap to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = svg.clientWidth * 2;
|
||||
canvas.height = svg.clientHeight * 2;
|
||||
ctx?.scale(2, 2);
|
||||
ctx?.drawImage(img, 0, 0);
|
||||
const pngUrl = canvas.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.href = pngUrl;
|
||||
a.download = `${currentResult?.name || 'mindmap'}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||||
};
|
||||
|
||||
const renderProgressIndicator = () => {
|
||||
if (progress.step === 'idle' || progress.step === 'done') return null;
|
||||
|
||||
const percent = progress.step === 'step1'
|
||||
? 10
|
||||
: progress.step === 'chains'
|
||||
? 10 + (progress.currentChainIndex / progress.totalChains) * 90
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
{progress.step === 'error' ? (
|
||||
<Tag color="error">Error</Tag>
|
||||
) : (
|
||||
<LoadingOutlined spin />
|
||||
)}
|
||||
<Text>{progress.message}</Text>
|
||||
</Space>
|
||||
<Progress percent={Math.round(percent)} size="small" status={progress.step === 'error' ? 'exception' : 'active'} />
|
||||
|
||||
{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 }} />
|
||||
{chain.material} → {chain.function} → {chain.usage} → {chain.user}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const collapseItems = [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM Parameters',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text>Temperature: {temperature}</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
marks={{ 0: '0', 0.5: '0.5', 1: '1' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>Chain Count: {chainCount}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={chainCount}
|
||||
onChange={setChainCount}
|
||||
marks={{ 1: '1', 5: '5', 10: '10' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visual',
|
||||
label: 'Visual Settings',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text>Node Spacing: {visualSettings.nodeSpacing}</Text>
|
||||
<Slider
|
||||
min={20}
|
||||
max={80}
|
||||
value={visualSettings.nodeSpacing}
|
||||
onChange={(v) => onVisualSettingsChange({ ...visualSettings, nodeSpacing: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>Font Size: {visualSettings.fontSize}px</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={18}
|
||||
value={visualSettings.fontSize}
|
||||
onChange={(v) => onVisualSettingsChange({ ...visualSettings, fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ExpandAltOutlined />} onClick={onExpandAll} disabled={!currentResult}>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button icon={<ShrinkOutlined />} onClick={onCollapseAll} disabled={!currentResult}>
|
||||
Collapse All
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: 'Export',
|
||||
children: (
|
||||
<Space wrap>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportPNG} disabled={!currentResult}>
|
||||
PNG
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportSVG} disabled={!currentResult}>
|
||||
SVG
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportJSON} disabled={!currentResult}>
|
||||
JSON
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportMarkdown} disabled={!currentResult}>
|
||||
Markdown
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 16 }}>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Text strong>Model</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
loading={loadingModels}
|
||||
placeholder="Select a model"
|
||||
options={models.map((m) => ({ label: m, value: m }))}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Text strong>Input</Text>
|
||||
<TextArea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter an object to analyze (e.g., umbrella)"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleAnalyze}
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
Analyze
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{renderProgressIndicator()}
|
||||
|
||||
<Collapse items={collapseItems} defaultActiveKey={['llm']} size="small" style={{ marginBottom: 16 }} />
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Text strong>
|
||||
<HistoryOutlined /> History
|
||||
</Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={history}
|
||||
locale={{ emptyText: 'No history yet' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '8px 0' }}
|
||||
onClick={() => onLoadHistory(item)}
|
||||
>
|
||||
<Text ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.query}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{item.timestamp.toLocaleTimeString()}
|
||||
</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
355
frontend/src/components/MindmapD3.tsx
Normal file
355
frontend/src/components/MindmapD3.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import type { AttributeNode } from '../types';
|
||||
import '../styles/mindmap.css';
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface MindmapD3Props {
|
||||
data: AttributeNode;
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
interface TreeNode extends d3.HierarchyPointNode<AttributeNode> {
|
||||
_children?: TreeNode[];
|
||||
x0?: number;
|
||||
y0?: number;
|
||||
}
|
||||
|
||||
export interface MindmapD3Ref {
|
||||
expandAll: () => void;
|
||||
collapseAll: () => void;
|
||||
}
|
||||
|
||||
export const MindmapD3 = forwardRef<MindmapD3Ref, MindmapD3Props>(
|
||||
({ data, isDark, visualSettings }, ref) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<TreeNode | null>(null);
|
||||
const updateFnRef = useRef<((source: TreeNode) => void) | null>(null);
|
||||
|
||||
const expandAllNodes = useCallback((node: TreeNode) => {
|
||||
if (node._children) {
|
||||
node.children = node._children;
|
||||
node._children = undefined;
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach(expandAllNodes);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const collapseAllNodes = useCallback((node: TreeNode) => {
|
||||
if (node.children) {
|
||||
node.children.forEach(collapseAllNodes);
|
||||
if (node.depth > 0) {
|
||||
node._children = node.children;
|
||||
node.children = undefined;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
expandAll: () => {
|
||||
if (rootRef.current && updateFnRef.current) {
|
||||
expandAllNodes(rootRef.current);
|
||||
updateFnRef.current(rootRef.current);
|
||||
}
|
||||
},
|
||||
collapseAll: () => {
|
||||
if (rootRef.current && updateFnRef.current) {
|
||||
collapseAllNodes(rootRef.current);
|
||||
updateFnRef.current(rootRef.current);
|
||||
}
|
||||
},
|
||||
}), [expandAllNodes, collapseAllNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current || !data) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const margin = { top: 40, right: 20, bottom: 40, left: 20 };
|
||||
const { nodeSpacing, fontSize } = visualSettings;
|
||||
|
||||
// Clear previous content
|
||||
d3.select(svgRef.current).selectAll('*').remove();
|
||||
|
||||
const svg = d3
|
||||
.select(svgRef.current)
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||
|
||||
// Add zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.3, 3])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(margin.left, margin.top));
|
||||
|
||||
// Create hierarchy first to calculate dynamic spacing
|
||||
const root = d3.hierarchy(data) as TreeNode;
|
||||
// For horizontal layout: x0 is vertical center, y0 is left edge
|
||||
root.x0 = height / 2;
|
||||
root.y0 = 0;
|
||||
rootRef.current = root;
|
||||
|
||||
// Create horizontal tree layout (left to right)
|
||||
const verticalNodeSpacing = Math.max(nodeSpacing, 35);
|
||||
const horizontalLevelSpacing = 180; // space between levels (left to right)
|
||||
const treeLayout = d3.tree<AttributeNode>()
|
||||
.nodeSize([verticalNodeSpacing, horizontalLevelSpacing])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1 : 1.2));
|
||||
|
||||
// Initialize all nodes as expanded
|
||||
root.descendants().forEach((d: TreeNode) => {
|
||||
d._children = undefined;
|
||||
});
|
||||
|
||||
// Category labels for header
|
||||
const categoryLabels = ['', '材料', '功能', '用途', '使用族群'];
|
||||
const headerHeight = 40;
|
||||
|
||||
function update(source: TreeNode) {
|
||||
const duration = 300;
|
||||
const nodes = treeLayout(root);
|
||||
const descendants = nodes.descendants() as TreeNode[];
|
||||
const links = nodes.links();
|
||||
|
||||
// For horizontal layout: swap x and y
|
||||
// d.x becomes vertical position, d.y becomes horizontal position
|
||||
// Center the tree vertically (leave room for header)
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
descendants.forEach((d) => {
|
||||
if (d.x < minX) minX = d.x;
|
||||
if (d.x > maxX) maxX = d.x;
|
||||
});
|
||||
const treeHeight = maxX - minX;
|
||||
const offsetY = (height - margin.top - margin.bottom - treeHeight - headerHeight) / 2 - minX + headerHeight;
|
||||
const offsetX = 50; // left margin for root node
|
||||
|
||||
// Draw category headers with background
|
||||
g.selectAll('.category-header-group').remove();
|
||||
const maxDepth = Math.max(...descendants.map(d => d.depth));
|
||||
const categoryColors: Record<string, string> = {
|
||||
'材料': isDark ? '#854eca' : '#722ed1',
|
||||
'功能': isDark ? '#13a8a8' : '#13c2c2',
|
||||
'用途': isDark ? '#d87a16' : '#fa8c16',
|
||||
'使用族群': isDark ? '#49aa19' : '#52c41a',
|
||||
};
|
||||
|
||||
for (let depth = 1; depth <= Math.min(maxDepth, categoryLabels.length - 1); depth++) {
|
||||
const label = categoryLabels[depth];
|
||||
if (label) {
|
||||
const headerX = depth * horizontalLevelSpacing + offsetX;
|
||||
const headerY = offsetY + minX - 45; // Position above the first node with more space
|
||||
|
||||
const headerGroup = g.append('g')
|
||||
.attr('class', 'category-header-group');
|
||||
|
||||
// Background rectangle
|
||||
const textWidth = label.length * 16 + 16;
|
||||
headerGroup.append('rect')
|
||||
.attr('x', headerX - textWidth / 2)
|
||||
.attr('y', headerY - 14)
|
||||
.attr('width', textWidth)
|
||||
.attr('height', 24)
|
||||
.attr('rx', 4)
|
||||
.attr('fill', categoryColors[label] || (isDark ? '#444' : '#ddd'))
|
||||
.attr('opacity', 0.9);
|
||||
|
||||
// Text label
|
||||
headerGroup.append('text')
|
||||
.attr('x', headerX)
|
||||
.attr('y', headerY)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', '13px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#fff')
|
||||
.text(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Update nodes
|
||||
const node = g
|
||||
.selectAll<SVGGElement, TreeNode>('g.node')
|
||||
.data(descendants, (d) => d.data.name + d.depth);
|
||||
|
||||
// Enter new nodes - swap x,y for horizontal layout
|
||||
const nodeEnter = node
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', () => `translate(${(source.y0 || 0) + offsetX},${(source.x0 || 0) + offsetY})`)
|
||||
.on('click', (_event, d: TreeNode) => {
|
||||
if (d.children) {
|
||||
d._children = d.children;
|
||||
d.children = undefined;
|
||||
} else if (d._children) {
|
||||
d.children = d._children;
|
||||
d._children = undefined;
|
||||
}
|
||||
update(d);
|
||||
});
|
||||
|
||||
// Add rounded rectangle for nodes
|
||||
nodeEnter
|
||||
.append('rect')
|
||||
.attr('width', (d) => Math.max(d.data.name.length * fontSize, 60))
|
||||
.attr('height', 28)
|
||||
.attr('x', (d) => -Math.max(d.data.name.length * fontSize, 60) / 2)
|
||||
.attr('y', -14)
|
||||
.attr('rx', 6)
|
||||
.attr('ry', 6)
|
||||
.attr('class', (d) => {
|
||||
const hasChildren = d.children || d._children;
|
||||
const isCollapsed = d._children && !d.children;
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-rect depth-${d.depth} ${categoryClass} ${hasChildren ? 'has-children' : ''} ${isCollapsed ? 'collapsed' : ''}`;
|
||||
})
|
||||
.style('opacity', 0);
|
||||
|
||||
// Add text labels inside rect
|
||||
nodeEnter
|
||||
.append('text')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', (d) => {
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-text depth-${d.depth} ${categoryClass}`;
|
||||
})
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.text((d) => d.data.name)
|
||||
.style('opacity', 0);
|
||||
|
||||
// Merge enter and update
|
||||
const nodeUpdate = nodeEnter.merge(node);
|
||||
|
||||
// Horizontal layout: y is horizontal (depth), x is vertical (spread)
|
||||
nodeUpdate
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('transform', (d) => `translate(${d.y + offsetX},${d.x + offsetY})`);
|
||||
|
||||
nodeUpdate
|
||||
.select('rect')
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('width', (d) => Math.max(d.data.name.length * fontSize, 60))
|
||||
.attr('x', (d) => -Math.max(d.data.name.length * fontSize, 60) / 2)
|
||||
.style('opacity', 1)
|
||||
.attr('class', (d) => {
|
||||
const hasChildren = d.children || d._children;
|
||||
const isCollapsed = d._children && !d.children;
|
||||
const category = d.data.category || (d.depth === 0 ? 'root' : '');
|
||||
const categoryClass = category ? `category-${category}` : '';
|
||||
return `node-rect depth-${d.depth} ${categoryClass} ${hasChildren ? 'has-children' : ''} ${isCollapsed ? 'collapsed' : ''}`;
|
||||
});
|
||||
|
||||
nodeUpdate
|
||||
.select('text')
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('opacity', 1);
|
||||
|
||||
// Exit nodes
|
||||
const nodeExit = node
|
||||
.exit<TreeNode>()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('transform', () => `translate(${source.y + offsetX},${source.x + offsetY})`)
|
||||
.remove();
|
||||
|
||||
nodeExit.select('rect').style('opacity', 0);
|
||||
nodeExit.select('text').style('opacity', 0);
|
||||
|
||||
// Update links - horizontal curves (left to right)
|
||||
const link = g
|
||||
.selectAll<SVGPathElement, d3.HierarchyPointLink<AttributeNode>>('path.link')
|
||||
.data(links, (d) => d.target.data.name + (d.target as TreeNode).depth);
|
||||
|
||||
const linkEnter = link
|
||||
.enter()
|
||||
.insert('path', 'g')
|
||||
.attr('class', 'link')
|
||||
.attr('d', () => {
|
||||
const o = { x: (source.y0 || 0) + offsetX, y: (source.x0 || 0) + offsetY };
|
||||
return `M${o.x},${o.y} L${o.x},${o.y}`;
|
||||
});
|
||||
|
||||
linkEnter
|
||||
.merge(link)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('d', (d) => {
|
||||
// Horizontal layout: source.y is horizontal, source.x is vertical
|
||||
const sx = d.source.y + offsetX;
|
||||
const sy = d.source.x + offsetY;
|
||||
const tx = d.target.y + offsetX;
|
||||
const ty = d.target.x + offsetY;
|
||||
const midX = (sx + tx) / 2;
|
||||
return `M${sx},${sy} C${midX},${sy} ${midX},${ty} ${tx},${ty}`;
|
||||
});
|
||||
|
||||
link
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('d', () => {
|
||||
const o = { x: source.y + offsetX, y: source.x + offsetY };
|
||||
return `M${o.x},${o.y} L${o.x},${o.y}`;
|
||||
})
|
||||
.remove();
|
||||
|
||||
// Store positions for next transition (keep original x,y for d3 tree)
|
||||
descendants.forEach((d) => {
|
||||
d.x0 = d.x;
|
||||
d.y0 = d.y;
|
||||
});
|
||||
}
|
||||
|
||||
updateFnRef.current = update;
|
||||
update(root);
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
svg.attr('width', newWidth).attr('height', newHeight);
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [data, visualSettings, isDark]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mindmap-container ${isDark ? 'mindmap-dark' : 'mindmap-light'}`}
|
||||
>
|
||||
<svg ref={svgRef} className="mindmap-svg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MindmapD3.displayName = 'MindmapD3';
|
||||
71
frontend/src/components/MindmapPanel.tsx
Normal file
71
frontend/src/components/MindmapPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Empty, Spin } from 'antd';
|
||||
import type { AttributeNode } from '../types';
|
||||
import { MindmapD3 } from './MindmapD3';
|
||||
import type { MindmapD3Ref } from './MindmapD3';
|
||||
|
||||
interface VisualSettings {
|
||||
nodeSpacing: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface MindmapPanelProps {
|
||||
data: AttributeNode | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
isDark: boolean;
|
||||
visualSettings: VisualSettings;
|
||||
}
|
||||
|
||||
export const MindmapPanel = forwardRef<MindmapD3Ref, MindmapPanelProps>(
|
||||
({ data, loading, error, isDark, visualSettings }, ref) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Analyzing..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Empty description="Enter a query to analyze attributes" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MindmapD3 ref={ref} data={data} isDark={isDark} visualSettings={visualSettings} />;
|
||||
}
|
||||
);
|
||||
|
||||
MindmapPanel.displayName = 'MindmapPanel';
|
||||
18
frontend/src/components/ThemeToggle.tsx
Normal file
18
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Switch } from 'antd';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
isDark: boolean;
|
||||
onToggle: (isDark: boolean) => void;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ isDark, onToggle }: ThemeToggleProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={isDark}
|
||||
onChange={onToggle}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
frontend/src/hooks/useAttribute.ts
Normal file
163
frontend/src/hooks/useAttribute.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
AttributeNode,
|
||||
HistoryItem,
|
||||
StreamProgress,
|
||||
StreamAnalyzeResponse,
|
||||
CausalChain
|
||||
} from '../types';
|
||||
import { analyzeAttributesStream } from '../services/api';
|
||||
|
||||
export function useAttribute() {
|
||||
const [progress, setProgress] = useState<StreamProgress>({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentResult, setCurrentResult] = useState<AttributeNode | null>(null);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const analyze = useCallback(async (
|
||||
query: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
chainCount: number = 5
|
||||
) => {
|
||||
// 重置狀態
|
||||
setProgress({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: chainCount,
|
||||
completedChains: [],
|
||||
message: '準備開始分析...',
|
||||
});
|
||||
setError(null);
|
||||
setCurrentResult(null);
|
||||
|
||||
try {
|
||||
await analyzeAttributesStream(
|
||||
{ query, chain_count: chainCount, model, temperature },
|
||||
{
|
||||
onStep1Start: () => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'step1',
|
||||
message: '正在分析物件屬性列表...',
|
||||
}));
|
||||
},
|
||||
|
||||
onStep1Complete: (step1Result) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step1Result,
|
||||
message: '屬性列表分析完成,開始生成因果鏈...',
|
||||
}));
|
||||
},
|
||||
|
||||
onChainStart: (index, total) => {
|
||||
setProgress(prev => ({
|
||||
...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',
|
||||
message: '分析完成!',
|
||||
}));
|
||||
setCurrentResult(response.attributes);
|
||||
|
||||
setHistory((prev) => [
|
||||
{
|
||||
query,
|
||||
result: response.attributes,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
},
|
||||
|
||||
onError: (errorMsg) => {
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: errorMsg,
|
||||
message: `錯誤: ${errorMsg}`,
|
||||
}));
|
||||
setError(errorMsg);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: errorMessage,
|
||||
message: `錯誤: ${errorMessage}`,
|
||||
}));
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFromHistory = useCallback((item: HistoryItem) => {
|
||||
setCurrentResult(item.result);
|
||||
setError(null);
|
||||
setProgress({
|
||||
step: 'done',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '從歷史記錄載入',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setCurrentResult(null);
|
||||
setError(null);
|
||||
setProgress({
|
||||
step: 'idle',
|
||||
currentChainIndex: 0,
|
||||
totalChains: 0,
|
||||
completedChains: [],
|
||||
message: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isLoading = progress.step === 'step1' || progress.step === 'chains';
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
progress,
|
||||
error,
|
||||
currentResult,
|
||||
history,
|
||||
analyze,
|
||||
loadFromHistory,
|
||||
clearResult,
|
||||
};
|
||||
}
|
||||
16
frontend/src/index.css
Normal file
16
frontend/src/index.css
Normal file
@@ -0,0 +1,16 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
106
frontend/src/services/api.ts
Normal file
106
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ModelListResponse,
|
||||
StreamAnalyzeRequest,
|
||||
StreamAnalyzeResponse,
|
||||
Step1Result,
|
||||
CausalChain
|
||||
} from '../types';
|
||||
|
||||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||||
const API_BASE_URL = `http://${window.location.hostname}:8000/api`;
|
||||
|
||||
export interface SSECallbacks {
|
||||
onStep1Start?: () => void;
|
||||
onStep1Complete?: (result: Step1Result) => void;
|
||||
onChainStart?: (index: number, total: number) => void;
|
||||
onChainComplete?: (index: number, chain: CausalChain) => void;
|
||||
onChainError?: (index: number, error: string) => void;
|
||||
onDone?: (response: StreamAnalyzeResponse) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export async function analyzeAttributesStream(
|
||||
request: StreamAnalyzeRequest,
|
||||
callbacks: SSECallbacks
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 解析 SSE 事件
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || ''; // 保留未完成的部分
|
||||
|
||||
for (const chunk of lines) {
|
||||
if (!chunk.trim()) continue;
|
||||
|
||||
const eventMatch = chunk.match(/event: (\w+)/);
|
||||
const dataMatch = chunk.match(/data: (.+)/s);
|
||||
|
||||
if (eventMatch && dataMatch) {
|
||||
const eventType = eventMatch[1];
|
||||
try {
|
||||
const eventData = JSON.parse(dataMatch[1]);
|
||||
|
||||
switch (eventType) {
|
||||
case 'step1_start':
|
||||
callbacks.onStep1Start?.();
|
||||
break;
|
||||
case 'step1_complete':
|
||||
callbacks.onStep1Complete?.(eventData.result);
|
||||
break;
|
||||
case 'chain_start':
|
||||
callbacks.onChainStart?.(eventData.index, eventData.total);
|
||||
break;
|
||||
case 'chain_complete':
|
||||
callbacks.onChainComplete?.(eventData.index, eventData.chain);
|
||||
break;
|
||||
case 'chain_error':
|
||||
callbacks.onChainError?.(eventData.index, eventData.error);
|
||||
break;
|
||||
case 'done':
|
||||
callbacks.onDone?.(eventData);
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(eventData.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE event:', e, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModels(): Promise<ModelListResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/models`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
226
frontend/src/styles/mindmap.css
Normal file
226
frontend/src/styles/mindmap.css
Normal file
@@ -0,0 +1,226 @@
|
||||
.mindmap-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mindmap-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node rect {
|
||||
stroke-width: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.node rect:hover {
|
||||
stroke-width: 3px;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.node text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
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 .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 .link {
|
||||
stroke: #434343;
|
||||
}
|
||||
77
frontend/src/types/index.ts
Normal file
77
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface AttributeNode {
|
||||
name: string;
|
||||
category?: string; // 材料, 功能, 用途, 使用族群
|
||||
children?: AttributeNode[];
|
||||
}
|
||||
|
||||
export interface AnalyzeRequest {
|
||||
query: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORIES = ['材料', '功能', '用途', '使用族群', '特性'];
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
'材料': '物件由什麼材料組成',
|
||||
'功能': '物件能做什麼',
|
||||
'用途': '物件在什麼場景使用',
|
||||
'使用族群': '誰會使用這個物件',
|
||||
'特性': '物件有什麼特徵',
|
||||
};
|
||||
|
||||
export interface AnalyzeResponse {
|
||||
query: string;
|
||||
attributes: AttributeNode;
|
||||
}
|
||||
|
||||
export interface ModelListResponse {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
query: string;
|
||||
result: AttributeNode;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// ===== Multi-step streaming types =====
|
||||
|
||||
export interface Step1Result {
|
||||
materials: string[];
|
||||
functions: string[];
|
||||
usages: string[];
|
||||
users: string[];
|
||||
}
|
||||
|
||||
export interface CausalChain {
|
||||
material: string;
|
||||
function: string;
|
||||
usage: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface StreamAnalyzeRequest {
|
||||
query: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
chain_count: number;
|
||||
}
|
||||
|
||||
export interface StreamProgress {
|
||||
step: 'idle' | 'step1' | 'chains' | 'done' | 'error';
|
||||
step1Result?: Step1Result;
|
||||
currentChainIndex: number;
|
||||
totalChains: number;
|
||||
completedChains: CausalChain[];
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamAnalyzeResponse {
|
||||
query: string;
|
||||
step1_result: Step1Result;
|
||||
causal_chains: CausalChain[];
|
||||
attributes: AttributeNode;
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
72
start.sh
Executable file
72
start.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Attribute Agent - Start Script
|
||||
# This script starts both backend and frontend services
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BACKEND_DIR="$PROJECT_DIR/backend"
|
||||
FRONTEND_DIR="$PROJECT_DIR/frontend"
|
||||
PID_FILE="$PROJECT_DIR/.pids"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Starting Attribute Agent...${NC}"
|
||||
|
||||
# Check if already running
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
echo -e "${YELLOW}Services might already be running. Run ./stop.sh first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start Backend
|
||||
echo -e "${GREEN}[1/2] Starting Backend...${NC}"
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# Create virtual environment if not exists
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate and install dependencies
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -q
|
||||
|
||||
# Start uvicorn in background
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
|
||||
BACKEND_PID=$!
|
||||
echo "Backend PID: $BACKEND_PID"
|
||||
|
||||
# Start Frontend
|
||||
echo -e "${GREEN}[2/2] Starting Frontend...${NC}"
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
# Install dependencies if node_modules doesn't exist
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# Start vite dev server in background
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
echo "Frontend PID: $FRONTEND_PID"
|
||||
|
||||
# Save PIDs
|
||||
echo "$BACKEND_PID" > "$PID_FILE"
|
||||
echo "$FRONTEND_PID" >> "$PID_FILE"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Attribute Agent is running!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Backend: ${YELLOW}http://localhost:8000${NC}"
|
||||
echo -e "Frontend: ${YELLOW}http://localhost:5173${NC}"
|
||||
echo -e "API Docs: ${YELLOW}http://localhost:8000/docs${NC}"
|
||||
echo ""
|
||||
echo -e "Run ${YELLOW}./stop.sh${NC} to stop all services"
|
||||
32
stop.sh
Executable file
32
stop.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Attribute Agent - Stop Script
|
||||
# This script stops both backend and frontend services
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$PROJECT_DIR/.pids"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}Stopping Attribute Agent...${NC}"
|
||||
|
||||
# Kill processes from PID file
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
while read -r pid; do
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Stopping process $pid..."
|
||||
kill "$pid" 2>/dev/null
|
||||
fi
|
||||
done < "$PID_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# Also kill any remaining uvicorn and vite processes for this project
|
||||
pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
pkill -f "vite.*novelty-seeking" 2>/dev/null
|
||||
|
||||
echo -e "${GREEN}All services stopped.${NC}"
|
||||
Reference in New Issue
Block a user