feat(frontend): add /graph/:id redirect view + merge graph projects into home history
- New GraphView.vue: lightweight page at /graph/<id> that resolves a graph_id back to its owning project_id via /api/graph/project/list, then router-replaces to /process/<projectId> so the existing project page handles the full graph visualisation. - HistoryDatabase.vue: fetch /api/simulation/history and /api/graph/project/list in parallel and merge by project_id (sims win on conflict, project list fills the gap so home history is non-empty for users who only built graphs). Newest first by created_at. - frontend/src/api/graph.js: add listProjects(limit) wrapper. - frontend/src/router/index.js: register /graph/:id route, props: true. - frontend/src/api/index.js: switch VITE_API_BASE_URL fallback from || to ?? so an empty string is honoured (Vite uses empty to mean 'same origin', letting Traefik reverse-proxy /api/* to the backend container without an absolute baseURL). - Dockerfile: VITE_API_BASE_URL="" baked in at build time (default). - graphiti_service.py: strip lowercase <think> / <antmlthinking> reasoning blocks in addition to <think> (M2.7 vs M3/Qwen emit different reasoning tags); accept M2.7's 'entity_types' plural list shape in _normalize_extraction_payload.
This commit is contained in:
parent
d8643a309a
commit
62489ad4f9
|
|
@ -10,7 +10,7 @@ FROM python:3.11-slim
|
|||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VITE_API_BASE_URL=/api \
|
||||
VITE_API_BASE_URL="" \
|
||||
HF_HUB_OFFLINE=0 \
|
||||
SENTENCE_TRANSFORMERS_HOME=/root/.cache/huggingface
|
||||
|
||||
|
|
|
|||
|
|
@ -316,9 +316,13 @@ class MinimaxLLMClient(LLMClient):
|
|||
def _strip_reasoning_and_fence(s: str) -> str:
|
||||
"""Remove <think>...</think> reasoning blocks and markdown code fences."""
|
||||
s = s.strip()
|
||||
# Strip <think>...</think> blocks
|
||||
# Strip <think>...</think> AND <think>...</think> reasoning blocks.
|
||||
# M2.7 (the active model) emits the lowercase <think> tag; M3/Qwen emits <think>.
|
||||
# Both must be stripped before JSON extraction.
|
||||
import re
|
||||
s = re.sub(r"<think>.*?</think>", "", s, flags=re.DOTALL)
|
||||
s = re.sub(r"<think>.*?</think>", "", s, flags=re.DOTALL)
|
||||
s = re.sub(r"<antmlthinking>.*?</antmlthinking>", "", s, flags=re.DOTALL)
|
||||
s = s.strip()
|
||||
# Strip markdown code fences
|
||||
if s.startswith("```"):
|
||||
|
|
@ -475,14 +479,30 @@ def _normalize_extraction_payload(data: Dict[str, Any], model: Type[BaseModel])
|
|||
|
||||
# Only rename entity_type_id if missing and the model expects it
|
||||
if "entity_type_id" in model_field_set and "entity_type_id" not in item_norm:
|
||||
# M2.7 commonly emits "entity_types": ["Country", "Entity"] (a plural
|
||||
# list of strings) instead of the singular "type"/"entity_type".
|
||||
# Try the singular aliases first, then the plural list.
|
||||
chosen = None
|
||||
for alias in ("type", "entity_type", "label", "category", "kind"):
|
||||
if alias in item_norm:
|
||||
val = item_norm.pop(alias)
|
||||
if isinstance(val, int):
|
||||
item_norm["entity_type_id"] = val
|
||||
elif isinstance(val, str):
|
||||
item_norm["entity_type_id"] = _entity_type_id_for_label(val)
|
||||
chosen = item_norm.pop(alias)
|
||||
break
|
||||
if chosen is None and "entity_types" in item_norm:
|
||||
v = item_norm.pop("entity_types")
|
||||
if isinstance(v, list) and v:
|
||||
for candidate in v:
|
||||
if isinstance(candidate, str) and candidate.lower() != "entity":
|
||||
chosen = candidate
|
||||
break
|
||||
if chosen is None:
|
||||
chosen = v[0] if isinstance(v[0], str) else None
|
||||
elif isinstance(v, str):
|
||||
chosen = v
|
||||
if chosen is not None:
|
||||
if isinstance(chosen, int):
|
||||
item_norm["entity_type_id"] = chosen
|
||||
elif isinstance(chosen, str):
|
||||
item_norm["entity_type_id"] = _entity_type_id_for_label(chosen)
|
||||
|
||||
# episode_indices default — M3 sometimes emits {"item": "0"} (JSON-schema
|
||||
# items shape) or a bare string instead of a list of ints.
|
||||
|
|
|
|||
|
|
@ -68,3 +68,16 @@ export function getProject(projectId) {
|
|||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目(最近 N 个),用于首页 history 视图显示
|
||||
* @param {Number} limit
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function listProjects(limit = 20) {
|
||||
return service({
|
||||
url: '/api/graph/project/list',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import i18n from '../i18n'
|
|||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5001',
|
||||
timeout: 300000, // 5分钟超时(本体生成可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<div v-if="projects.length > 0" class="cards-container" :class="{ expanded: isExpanded }" :style="containerStyle">
|
||||
<div
|
||||
v-for="(project, index) in projects"
|
||||
:key="project.simulation_id"
|
||||
:key="project.simulation_id || project.project_id"
|
||||
class="project-card"
|
||||
:class="{ expanded: isExpanded, hovering: hoveringCard === index }"
|
||||
:style="getCardStyle(index)"
|
||||
|
|
@ -195,6 +195,26 @@ import { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } f
|
|||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSimulationHistory } from '../api/simulation'
|
||||
import { listProjects } from '../api/graph'
|
||||
|
||||
// Normalize a project record from /api/graph/project/list into the same
|
||||
// shape the card UI expects (which was originally designed for
|
||||
// /api/simulation/history items). Projects come from disk (the "graph was
|
||||
// built" view) — simulations come from the agent run. We merge both so the
|
||||
// homepage history is non-empty.
|
||||
function projectToHistoryItem(p) {
|
||||
return {
|
||||
project_id: p.project_id,
|
||||
simulation_id: p.simulation_id || null,
|
||||
report_id: p.report_id || null,
|
||||
name: p.name,
|
||||
simulation_requirement: p.simulation_requirement || p.analysis_summary || '',
|
||||
created_at: p.created_at,
|
||||
files: p.files || [],
|
||||
graph_id: p.graph_id,
|
||||
_source: 'project',
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -440,10 +460,32 @@ const goToReport = () => {
|
|||
const loadHistory = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getSimulationHistory(20)
|
||||
if (response.success) {
|
||||
projects.value = response.data || []
|
||||
// Fetch BOTH simulations and projects in parallel, then merge by project_id
|
||||
// so the homepage history shows the user's actual graph work, not just
|
||||
// agent-run simulations (which most users will have zero of).
|
||||
const [simResp, projResp] = await Promise.all([
|
||||
getSimulationHistory(20).catch(() => ({ success: false, data: [] })),
|
||||
listProjects(20).catch(() => ({ success: false, data: [] })),
|
||||
])
|
||||
|
||||
const sims = (simResp?.success && Array.isArray(simResp.data)) ? simResp.data : []
|
||||
const projs = (projResp?.success && Array.isArray(projResp.data)) ? projResp.data.map(projectToHistoryItem) : []
|
||||
|
||||
// Merge with simulations taking priority (they have richer fields),
|
||||
// but include every project the user has ever built a graph for.
|
||||
const seen = new Set()
|
||||
const merged = []
|
||||
for (const s of sims) {
|
||||
if (s.project_id) seen.add(s.project_id)
|
||||
merged.push(s)
|
||||
}
|
||||
for (const p of projs) {
|
||||
if (!seen.has(p.project_id)) merged.push(p)
|
||||
}
|
||||
// Newest first
|
||||
merged.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
|
||||
|
||||
projects.value = merged
|
||||
} catch (error) {
|
||||
console.error('加载历史项目失败:', error)
|
||||
projects.value = []
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../views/Home.vue'
|
||||
import GraphView from '../views/GraphView.vue'
|
||||
import Process from '../views/MainView.vue'
|
||||
import SimulationView from '../views/SimulationView.vue'
|
||||
import SimulationRunView from '../views/SimulationRunView.vue'
|
||||
|
|
@ -12,6 +13,12 @@ const routes = [
|
|||
name: 'Home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/graph/:id',
|
||||
name: 'Graph',
|
||||
component: GraphView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/process/:projectId',
|
||||
name: 'Process',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="graph-redirect">
|
||||
<div class="loader">
|
||||
<div class="spinner" />
|
||||
<h2>Loading graph…</h2>
|
||||
<p v-if="graphId">graph_id: <code>{{ graphId }}</code></p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const graphId = ref(route.params.id)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Look up the project that owns this graph_id by scanning the project list
|
||||
// Note: must use /api/... prefix since baseURL is empty
|
||||
const res = await axios.get('/api/graph/project/list', { params: { limit: 200 } })
|
||||
if (!res.data?.success) throw new Error(res.data?.error || 'project list failed')
|
||||
const projects = res.data.data || []
|
||||
const match = projects.find(p => p.graph_id === graphId.value)
|
||||
if (!match) {
|
||||
error.value = `No project found for graph_id "${graphId.value}". The graph may not exist or has been deleted.`
|
||||
return
|
||||
}
|
||||
// Redirect to the project page, which has the full graph visualization
|
||||
await router.replace({ name: 'Process', params: { projectId: match.project_id } })
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-redirect {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #0e1116 0%, #1a1f2b 100%);
|
||||
color: #cbd5e1;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.loader {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(99, 102, 241, 0.2);
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1.5rem;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
h2 { font-size: 1.5rem; margin: 0 0 0.75rem; color: #f1f5f9; font-weight: 600; }
|
||||
p { font-size: 0.9rem; color: #94a3b8; margin: 0.25rem 0; }
|
||||
code { background: rgba(99, 102, 241, 0.15); padding: 0.15em 0.4em; border-radius: 4px; color: #c7d2fe; font-size: 0.85em; }
|
||||
.err { color: #fca5a5; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue