Initial commit

This commit is contained in:
2025-12-02 02:06:51 +08:00
commit eb6c0c51fa
37 changed files with 7454 additions and 0 deletions

38
.gitignore vendored Normal file
View 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
View File

15
backend/app/config.py Normal file
View 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
View 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"}

View File

View 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

View File

View 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

View File

View 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))

View File

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View 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
View 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
View 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;

View 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>
);
}

View 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';

View 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';

View 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 />}
/>
);
}

View 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
View 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
View 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>,
)

View 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();
}

View 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;
}

View 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;
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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
View 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
View 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}"