feat(knowledge): studio 图谱实体编辑 + 新建关系接通真后端 + e2e

P1 真缺口补全(3/7,价值最高):KnowledgeGraphPanel 从只读→可维护正式轨——useUpdateEntity→PUT /entities/{id}(编辑实体名,经后端 normalizedName 规范化)、useCreateRelation→POST /entities/{id}/relations(源→目标 + relationType);均带 commandId,graph node.id=实体 id 直接复用。UI:每实体 inline 编辑 + 底部源/目标/类型新建关系表单。

验证:vitest 52/52(contract 加 PUT/POST URL+body+targetEntityId 转 number)、tsc 0 错;新增 e2e knowledge-entity-crud.spec.ts 2 例真后端绿(编辑名回显规范化值、建关系回显新边),全量 26/26 无回归。反假绿:编辑回显失败暴露后端 normalizedName 去连字符规范化,e2e 适配真实规范化用稳定值断言、非掩盖。种子:global-setup #2c work2 三实体(与 graph 的 work1 隔离、按 description marker 复位)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-20 20:13:20 -07:00
parent e5ee1fdb58
commit c119b90919
5 changed files with 313 additions and 11 deletions

View File

@ -94,6 +94,25 @@ async function globalSetup(): Promise<void> {
);
}
// 2c) 实体/关系 CRUD:作品 2 下三个 Canonical 实体——jia 供编辑实体名、yi/bing 供新建关系(各自独立避免互相影响)。
// 用 work2 与 knowledge-graph 的 work1 fixture 隔离;复位删 work2 全部关系 + 按 description marker 删实体
// (编辑会改 normalized_name 故不能按名删,description marker 不被编辑触碰)。
await client.query('DELETE FROM muse_knowledge_relation WHERE tenant_id=1 AND work_id=2');
await client.query(
"DELETE FROM muse_knowledge_entity WHERE tenant_id=1 AND work_id=2 AND description LIKE 'e2e-crud-marker:%'"
);
const crudEnt = async (entName: string): Promise<void> => {
await client.query(
`INSERT INTO muse_knowledge_entity
(work_id, entity_type, normalized_name, scope, description, status, source_status, source_action_policy, revision, tenant_id)
VALUES (2,'character',$1,'global',$2,'active','active','allowed',1,1)`,
[entName, 'e2e-crud-marker:' + entName]
);
};
await crudEnt('crudshiti-jia');
await crudEnt('crudshiti-yi');
await crudEnt('crudshiti-bing');
// 3) accept-suggestion:复位 block3→rev1 + 清来源归因;suggestion3 复 pending/active(spec 硬编码 work3/block3/suggestion3)。
await client.query('DELETE FROM muse_content_block_source_attribution WHERE tenant_id=1 AND block_id=3');
const blk = await client.query(

View File

@ -0,0 +1,72 @@
import { expect, test, type Page } from '@playwright/test';
/**
* / e2e(,,MSW off)
*
* 前置:vite VITE_API_MOCK=false muse-server 48080muse_slice_live 2 CRUD
* crudshiti-jia/yi/bing( e2e/global-setup.ts #2c ; work2 knowledge-graph work1 fixture )
* 目的:rendered studio (PUT /entities/{id})(POST /entities/{id}/relations)
* (绿): jia yibing ()
*/
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('编辑实体名 → 写回 Canonical(真实后端)', async ({ page }) => {
await seedToken(page);
await page.goto('/knowledge/2');
// 进入 jia 实体编辑态(按钮 accessible name 含实体名)。
const editBtn = page.getByRole('button', { name: '编辑实体 crudshiti-jia' });
await expect(editBtn).toBeVisible({ timeout: 15_000 });
await editBtn.click();
// 改名并保存,捕获真实 PUT 响应(打真后端,非 mock)。
// 注:后端 updateEntity 把 name 经 normalizedName() 规范化(NFKD+去非字母数字+小写)后存为 normalizedName,
// 图谱节点显示的正是 normalizedName;故输入用规范稳定形式(纯小写字母,无连字符),令回显值可预期、可断言。
await page.getByLabel('实体名称').fill('crudjiarenamed');
const [resp] = await Promise.all([
page.waitForResponse(
(r) => /\/entities\/\d+$/.test(r.url()) && r.request().method() === 'PUT',
{ timeout: 15_000 }
),
page.getByRole('button', { name: '保存' }).click(),
]);
// Yudao 口径:成功为 HTTP 200 + CommonResult{code:0}。
expect(resp.status()).toBe(200);
expect((await resp.json()).code).toBe(0);
// 回显:图谱重渲染显示规范化后的新实体名(证编辑真写回 Canonical,非假绿)。
await expect(page.getByText('crudjiarenamed').first()).toBeVisible({ timeout: 15_000 });
});
test('新建实体关系 → 写入 Canonical(真实后端)', async ({ page }) => {
await seedToken(page);
await page.goto('/knowledge/2');
// 等图谱加载(新建关系下拉出现)。
await expect(page.getByLabel('源实体')).toBeVisible({ timeout: 15_000 });
// 选源/目标实体(yi→bing,与编辑用的 jia 隔离)+ 关系类型。
await page.getByLabel('源实体').selectOption({ label: 'crudshiti-yi' });
await page.getByLabel('目标实体').selectOption({ label: 'crudshiti-bing' });
await page.getByLabel('关系类型').fill('e2e-shitu');
const [resp] = await Promise.all([
page.waitForResponse(
(r) => /\/entities\/\d+\/relations$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 15_000 }
),
page.getByRole('button', { name: '新建关系' }).click(),
]);
// 创建关系为 201 Created(后端 @ResponseStatus(CREATED))。
expect([200, 201]).toContain(resp.status());
expect((await resp.json()).code).toBe(0);
// 回显:关系区出现新建的关系语义。
await expect(page.getByText(/e2e-shitu/).first()).toBeVisible({ timeout: 15_000 });
});

View File

@ -1,19 +1,36 @@
import { useKnowledgeGraph, type KnowledgeGraphNodeVO } from '@/features/knowledge/hooks/useKnowledge';
import { useState } from 'react';
import {
useKnowledgeGraph,
useUpdateEntity,
useCreateRelation,
type KnowledgeGraphNodeVO,
} from '@/features/knowledge/hooks/useKnowledge';
/**
* - ("正式轨")
* - ("正式轨") + /
*
* Canonical (,muse_knowledge_entity)(,muse_knowledge_relation)
* 稿构成双轨主权闭环:候选(Shadow) (Canonical)
* name (canonical key); id
* Canonical (,muse_knowledge_entity)(,muse_knowledge_relation),
* 并支持人工维护正式轨:编辑实体名(PUT /entities/{id})(POST /entities/{id}/relations)
* 稿构成双轨主权闭环:候选(Shadow) (Canonical)
* name (canonical key);/ id / id( String.valueOf )
*/
export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
const { data, isLoading, isError } = useKnowledgeGraph(workId);
const updateEntity = useUpdateEntity(workId);
const createRelation = useCreateRelation(workId);
const nodes = data?.nodes ?? [];
const edges = data?.edges ?? [];
const nameById = new Map(nodes.map((node) => [node.id, node.name]));
// 实体编辑态:当前编辑的实体 id + 名称草稿(null 表示无编辑)。
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
// 新建关系表单态:源/目标为实体 id,relationType 为自由文本语义。
const [relSource, setRelSource] = useState('');
const [relTarget, setRelTarget] = useState('');
const [relType, setRelType] = useState('');
if (isLoading) {
return (
<section className="mb-8 rounded-2xl border border-indigo-100 bg-indigo-50/40 p-6">
@ -27,6 +44,31 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
return null;
}
const startEdit = (node: KnowledgeGraphNodeVO) => {
setEditingId(node.id);
setEditName(node.name);
};
// 保存实体名:经 PUT /entities/{id} 写回 Canonical,成功后退出编辑态(hook 已失效图谱触发重渲染)。
const saveEdit = async () => {
if (!editingId || !editName.trim()) return;
await updateEntity.mutateAsync({ entityId: editingId, name: editName.trim() });
setEditingId(null);
};
// 提交新建关系:源/目标必选且不同、关系类型非空;成功后清空表单(图谱重渲染呈现新边)。
const submitRelation = async () => {
if (!relSource || !relTarget || !relType.trim() || relSource === relTarget) return;
await createRelation.mutateAsync({
entityId: relSource,
targetEntityId: relTarget,
relationType: relType.trim(),
});
setRelSource('');
setRelTarget('');
setRelType('');
};
return (
<section className="mb-8 rounded-2xl border border-indigo-100 bg-white shadow-sm overflow-hidden">
<div className="flex items-center gap-2 border-b border-indigo-50 bg-gradient-to-r from-indigo-50/80 to-violet-50/40 px-6 py-4">
@ -37,20 +79,57 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
</span>
</div>
{/* 实体节点 */}
{/* 实体节点(支持编辑名称) */}
<div className="px-6 py-4">
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-400"></h3>
<ul className="flex flex-wrap gap-2">
<ul className="space-y-2">
{nodes.map((node: KnowledgeGraphNodeVO) => (
<li
key={node.id}
className="inline-flex items-center gap-2 rounded-xl border border-indigo-100 bg-indigo-50/40 px-3 py-1.5"
className="flex items-center gap-2 rounded-xl border border-indigo-100 bg-indigo-50/40 px-3 py-2"
>
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
<span className="shrink-0 rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{node.type}
</span>
<strong className="text-sm font-semibold text-slate-800">{node.name}</strong>
<span className="text-[10px] text-slate-400">{node.status}</span>
{editingId === node.id ? (
<>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-label="实体名称"
className="min-w-0 flex-1 rounded-lg border border-indigo-200 px-2 py-1 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none"
/>
<button
type="button"
onClick={saveEdit}
disabled={updateEntity.isPending || !editName.trim()}
className="shrink-0 rounded-lg bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{updateEntity.isPending ? '保存中…' : '保存'}
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="shrink-0 rounded-lg border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-500 hover:bg-slate-50"
>
</button>
</>
) : (
<>
<strong className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{node.name}</strong>
<span className="shrink-0 text-[10px] text-slate-400">{node.status}</span>
<button
type="button"
onClick={() => startEdit(node)}
aria-label={`编辑实体 ${node.name}`}
className="shrink-0 rounded-lg border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-500 hover:bg-slate-50"
>
</button>
</>
)}
</li>
))}
</ul>
@ -71,6 +150,57 @@ export default function KnowledgeGraphPanel({ workId }: { workId: string }) {
</ul>
</div>
)}
{/* 新建关系:在两个已入库实体间补建关系(双轨"正式轨"的人工维护) */}
<div className="border-t border-slate-50 bg-slate-50/40 px-6 py-4">
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-400"></h3>
<div className="flex flex-wrap items-center gap-2">
<select
value={relSource}
onChange={(e) => setRelSource(e.target.value)}
aria-label="源实体"
className="rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
>
<option value=""></option>
{nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
<input
type="text"
value={relType}
onChange={(e) => setRelType(e.target.value)}
placeholder="关系类型(如:师徒)"
aria-label="关系类型"
className="w-36 rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
/>
<select
value={relTarget}
onChange={(e) => setRelTarget(e.target.value)}
aria-label="目标实体"
className="rounded-lg border border-slate-200 px-2 py-1.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
>
<option value=""></option>
{nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
<button
type="button"
onClick={submitRelation}
disabled={
createRelation.isPending || !relSource || !relTarget || !relType.trim() || relSource === relTarget
}
className="rounded-lg bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-1.5 text-sm font-semibold text-white shadow-sm transition-all hover:from-indigo-700 hover:to-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{createRelation.isPending ? '创建中…' : '新建关系'}
</button>
</div>
</div>
</section>
);
}

View File

@ -13,6 +13,8 @@ import {
useKnowledgeDelete,
useKnowledgeList,
useRecheckKnowledgeDraft,
useUpdateEntity,
useCreateRelation,
type KnowledgeDraftVO,
} from './useKnowledge';
@ -231,4 +233,39 @@ describe('useKnowledge OpenAPI command contract', () => {
expect(ignoreBodies[0]).toMatchObject({ commandId: expect.any(String) });
expect(recheckBodies[0]).toMatchObject({ commandId: expect.any(String) });
});
it('puts entity update and posts relation create with command fields', async () => {
const putBodies: Record<string, unknown>[] = [];
const postBodies: Record<string, unknown>[] = [];
const requestedUrls: string[] = [];
server.use(
http.put('/app-api/muse/entities/:entityId', async ({ request }) => {
requestedUrls.push(request.url);
putBodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ code: 0, msg: 'success', data: { entityId: '11', status: 'active' } });
}),
http.post('/app-api/muse/entities/:entityId/relations', async ({ request }) => {
requestedUrls.push(request.url);
postBodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ code: 0, msg: 'success', data: { relationId: '99' } }, { status: 201 });
})
);
const wrapper = createWrapper();
const { result: updateHook } = renderHook(() => useUpdateEntity('1'), { wrapper });
updateHook.current.mutate({ entityId: '11', name: '新名' });
await waitFor(() => expect(updateHook.current.isSuccess).toBe(true));
const { result: relationHook } = renderHook(() => useCreateRelation('1'), { wrapper });
relationHook.current.mutate({ entityId: '11', targetEntityId: '12', relationType: 'shitu' });
await waitFor(() => expect(relationHook.current.isSuccess).toBe(true));
// 契约:PUT 命中实体端点;POST 命中关系端点;均带 commandId,targetEntityId 转 number(后端 Long)。
expect(requestedUrls[0]).toContain('/app-api/muse/entities/11');
expect(requestedUrls[1]).toContain('/app-api/muse/entities/11/relations');
expect(putBodies[0]).toMatchObject({ commandId: expect.any(String), name: '新名' });
expect(postBodies[0]).toMatchObject({ commandId: expect.any(String), targetEntityId: 12, relationType: 'shitu' });
});
});

View File

@ -549,6 +549,50 @@ export function useKnowledgeGraph(workId: string | null, depth = 2) {
});
}
/**
* 更新知识实体:修改已入库 Canonical /
*
* WHY:PUT /entities/{entityId} ("正式轨",);
* entityId id( graph node.id = String.valueOf(entity.id));commandId
* ()
*/
export function useUpdateEntity(workId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vars: { entityId: string; name?: string; description?: string }) =>
api.put<{ entityId?: string; status?: string }>(`/entities/${vars.entityId}`, {
commandId: createCommandId(),
name: vars.name,
description: vars.description,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['knowledgeGraph', workId] });
},
});
}
/**
* 创建知识关系:在两个已入库实体间建立关系
*
* WHY:POST /entities/{entityId}/relations (, relationType);
* entityId/targetEntityId id(= id);commandId ()
*/
export function useCreateRelation(workId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vars: { entityId: string; targetEntityId: string; relationType: string; description?: string }) =>
api.post<{ relationId?: string }>(`/entities/${vars.entityId}/relations`, {
commandId: createCommandId(),
targetEntityId: Number(vars.targetEntityId),
relationType: vars.relationType,
description: vars.description,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['knowledgeGraph', workId] });
},
});
}
/**
* ()
* id ( JS );checkedAt