feat(agent-infra): 卡D+E——栈确认UI + 看板腐化指示器

卡D:AgentInfraPanel——扫描→确认契约→生成三件套,嵌入 GovernancePanel
卡E:ProjectCard 加腐化评分小圆点(读缓存,不触发扫描)
agent-infrastructure 模块全部 5 张卡完成,status→done
This commit is contained in:
lanrtop 2026-06-29 18:31:38 +09:00
parent 0c5730381d
commit 1651b21743
5 changed files with 253 additions and 7 deletions

View File

@ -381,8 +381,8 @@ modules:
- id: agent-infrastructure - id: agent-infrastructure
name: Agent 友好基础设施生成 name: Agent 友好基础设施生成
area: backend area: backend
status: planned status: done
progress: 0 progress: 100
position: [3750, 0] position: [3750, 0]
edges: edges:

View File

@ -105,15 +105,15 @@
- files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/db.rs, src/lib/commands.ts - files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/db.rs, src/lib/commands.ts
- acceptance: `scan_doc_freshness(project_path, project_id)``silent_cmd("git").current_dir(project_path)` 读取契约路径 vs `docs/ai-context/project-context.yaml` 的最后 commit 时间戳project-context.yaml 不存在直接返回 RED结果写入 `doc_freshness_cache`migration 补齐);前端封装就绪 - acceptance: `scan_doc_freshness(project_path, project_id)``silent_cmd("git").current_dir(project_path)` 读取契约路径 vs `docs/ai-context/project-context.yaml` 的最后 commit 时间戳project-context.yaml 不存在直接返回 RED结果写入 `doc_freshness_cache`migration 补齐);前端封装就绪
### 📋 D. 栈确认 UI + 生成按钮 ### D. 栈确认 UI + 生成按钮
- status: todo - status: done
- complexity: M - complexity: M
- depends: A, B - depends: A, B
- files: src/components/dashboard/AgentInfraPanel.tsx, src/components/dashboard/BlueprintModal.tsx - files: src/components/dashboard/AgentInfraPanel.tsx, src/components/dashboard/BlueprintModal.tsx
- acceptance: BlueprintModal 新增"Agent 基础设施"区块,展示 A 返回的检测结果(可编辑 contract 路径)、"生成三件套"按钮调用 B、展示写入结果written/skipped/failedUI 无类型错误 - acceptance: BlueprintModal 新增"Agent 基础设施"区块,展示 A 返回的检测结果(可编辑 contract 路径)、"生成三件套"按钮调用 B、展示写入结果written/skipped/failedUI 无类型错误
### 🔵 E. 看板腐化指示器 ### E. 看板腐化指示器
- status: in_progress - status: done
- complexity: S - complexity: S
- depends: C - depends: C
- files: src/components/dashboard/ProjectCard.tsx, src/lib/commands.ts - files: src/components/dashboard/ProjectCard.tsx, src/lib/commands.ts

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import {
scanAgentInfraStack,
generateAgentInfraFiles,
type AgentInfraStack,
type ContractInput,
type FileResult,
} from "../../lib/commands";
const CONFIDENCE_BADGE: Record<string, string> = {
high: "bg-green-50 text-green-700 border-green-200",
medium: "bg-amber-50 text-amber-700 border-amber-200",
low: "bg-gray-50 text-gray-500 border-gray-200",
};
const CONFIDENCE_LABEL: Record<string, string> = {
high: "确认",
medium: "推断",
low: "弱信号",
};
const ACTION_ICON: Record<string, string> = {
written: "✓",
updated: "↻",
skipped: "○",
failed: "✗",
};
const ACTION_COLOR: Record<string, string> = {
written: "text-green-600",
updated: "text-blue-600",
skipped: "text-gray-400",
failed: "text-red-600",
};
export function AgentInfraPanel({
projectPath,
projectId,
}: {
projectPath: string;
projectId?: string;
}) {
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [stack, setStack] = useState<AgentInfraStack | null>(null);
const [contracts, setContracts] = useState<ContractInput[]>([]);
const [report, setReport] = useState<FileResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleScan() {
setLoading(true);
setError(null);
setReport(null);
try {
const result = await scanAgentInfraStack(projectPath);
setStack(result);
setContracts(
result.detected_contracts.map((c) => ({
label: c.label,
paths: [...c.paths],
doc: c.doc,
}))
);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
function removeContract(idx: number) {
setContracts((prev) => prev.filter((_, i) => i !== idx));
}
async function handleGenerate() {
if (!contracts.length) return;
setGenerating(true);
setError(null);
try {
const result = await generateAgentInfraFiles(projectPath, contracts);
setReport(result.files);
} catch (e) {
setError(String(e));
} finally {
setGenerating(false);
}
}
return (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
{/* 标题行 */}
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
Agent
</p>
{stack && (
<div className="flex gap-1">
{stack.context_yaml_exists ? (
<span className="text-[10px] text-green-600 bg-green-50 border border-green-200 px-1.5 py-0.5 rounded-full">
yaml
</span>
) : (
<span className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">
yaml
</span>
)}
{stack.meta_rules_present && (
<span className="text-[10px] text-blue-600 bg-blue-50 border border-blue-200 px-1.5 py-0.5 rounded-full">
</span>
)}
</div>
)}
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">
<code className="text-[10px] bg-gray-100 px-1 rounded">project-context.yaml</code>
<code className="text-[10px] bg-gray-100 px-1 rounded">doc-freshness.md</code>
</p>
{/* 扫描按钮 */}
{!stack && (
<button
onClick={handleScan}
disabled={loading}
className="w-full py-1.5 rounded-lg bg-indigo-600 text-white text-xs hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{loading ? "扫描中…" : "🔍 扫描技术栈"}
</button>
)}
{/* 扫描结果 + 可编辑契约列表 */}
{stack && (
<div className="space-y-2">
{contracts.length === 0 ? (
<p className="text-[11px] text-gray-400 italic">
</p>
) : (
<div className="space-y-1">
<p className="text-[10px] text-gray-500 font-semibold">
{contracts.length}
</p>
{contracts.map((c, idx) => {
const original = stack.detected_contracts[idx];
const conf = original?.confidence ?? "medium";
return (
<div
key={idx}
className="flex items-start gap-1.5 bg-gray-50 rounded p-1.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[10px] font-medium text-gray-700">
{c.label}
</span>
<span
className={`text-[9px] border px-1 rounded-full ${CONFIDENCE_BADGE[conf] ?? CONFIDENCE_BADGE.medium}`}
>
{CONFIDENCE_LABEL[conf] ?? conf}
</span>
</div>
<p className="text-[10px] text-gray-400 font-mono mt-0.5 truncate">
{c.paths.join(", ")}
</p>
</div>
<button
onClick={() => removeContract(idx)}
className="text-gray-300 hover:text-red-400 text-xs flex-shrink-0 mt-0.5"
title="移除此契约"
>
</button>
</div>
);
})}
</div>
)}
{/* 重新扫描 + 生成 */}
<div className="flex gap-1.5 pt-0.5">
<button
onClick={() => { setStack(null); setContracts([]); setReport(null); }}
className="flex-1 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-xs hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleGenerate}
disabled={generating || !projectId}
title={!projectId ? "需要 projectId" : undefined}
className="flex-1 py-1.5 rounded-lg bg-indigo-600 text-white text-xs hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{generating ? "生成中…" : "⚡ 生成三件套"}
</button>
</div>
</div>
)}
{/* 生成结果 */}
{report && (
<div className="space-y-0.5 pt-0.5 border-t border-gray-100">
<p className="text-[10px] text-gray-500 font-semibold"></p>
{report.map((f) => (
<p key={f.path} className="text-[10px] font-mono flex items-center gap-1">
<span className={ACTION_COLOR[f.action] ?? "text-gray-500"}>
{ACTION_ICON[f.action] ?? "?"}
</span>
<span className="text-gray-600 truncate flex-1">{f.path}</span>
<span className="text-gray-400 flex-shrink-0">{f.detail}</span>
</p>
))}
</div>
)}
{error && (
<p className="text-[11px] text-red-600 font-mono break-all">{error}</p>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { AgentInfraPanel } from "./AgentInfraPanel";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { import {
@ -1545,6 +1546,9 @@ function GovernancePanel({
)} )}
</div> </div>
{/* ── Agent 基础设施(接入包 v2───────────────────────── */}
<AgentInfraPanel projectPath={projectPath} projectId={projectId} />
{/* 项目信息 */} {/* 项目信息 */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-300 truncate">{projectName}</p> <p className="text-[10px] text-gray-300 truncate">{projectName}</p>

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus } from "../../lib/commands"; import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal"; import { ProjectToolsModal } from "./ProjectToolsModal";
@ -214,6 +214,13 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
retry: false, retry: false,
}); });
const { data: freshnessCache } = useQuery<CachedFreshness | null>({
queryKey: ["docFreshness", p.id],
queryFn: () => getDocFreshnessCache(p.id),
staleTime: 60000,
retry: false,
});
// PR 链接:从 repo_url 解析出 GitHub 路径 // PR 链接:从 repo_url 解析出 GitHub 路径
const prUrl = (() => { const prUrl = (() => {
if (!p.repo_url || !gitStat) return null; if (!p.repo_url || !gitStat) return null;
@ -401,6 +408,20 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
<span className="text-[10px] text-purple-600">Buff</span> <span className="text-[10px] text-purple-600">Buff</span>
</button> </button>
)} )}
{/* 文档腐化评分指示器(仅有缓存时显示) */}
{freshnessCache && (
<button
onClick={() => setShowBlueprint(true)}
title={`文档新鲜度:${freshnessCache.score === "green" ? "正常" : freshnessCache.score === "yellow" ? "注意" : "高风险"}\n检查时间${freshnessCache.checked_at}`}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-gray-200 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className={`w-1.5 h-1.5 rounded-full ${
freshnessCache.score === "green" ? "bg-green-400" :
freshnessCache.score === "yellow" ? "bg-amber-400" : "bg-red-500"
}`} />
<span className="text-[10px] text-gray-500"></span>
</button>
)}
</div> </div>
</div> </div>