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:
hermes 2026-06-10 01:29:30 +00:00
parent d8643a309a
commit 62489ad4f9
7 changed files with 165 additions and 12 deletions

View File

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

View File

@ -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.

View File

@ -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 }
})
}

View File

@ -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'

View File

@ -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 = []

View File

@ -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',

View File

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