'use client'; // Mira repo 總管 + 工作台 // SDD: polaris/mira/.agents/specs/mira-app/design.md §3.9.1.2 + §5.2 v2.1 // 對應 task: 階段 10-B // GitHub = repo 全清單 SSOT;clone 到 Hetzner = 激活進工作態 // 資料源:mira daemon GET /projects(轉發 AI-Meka GitHub 掃描器) import { useEffect, useMemo, useState } from 'react'; import '../mira.css'; // mira daemon(nginx mira.uncle6.me/mira/ → 容器) const DAEMON = process.env.NEXT_PUBLIC_MIRA_DAEMON ?? 'https://mira.uncle6.me/mira'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const KBDB_BASE = 'https://kbdb.finally.click'; // 既有技術債(同 feed/wiki),KI-3 未解前沿用 export type RepoSummary = { name: string; full_name: string; cloned: boolean; has_sdd: boolean; archived: boolean; fork: boolean; total: number; done: number; in_progress: number; ai_count: number; leo_count: number; blocked_count: number; last_activity: string | null; }; // project_detector 拆出的 todo block(type=note, source=ai-project-detector) // G1:一篇河道筆記可拆多條 todo,各自掛 suggested-repo(design.md §3.7.5.7) type Todo = { id: string; content: string; suggested_repo: string; // repo name 或 "new" raw_id: string; // 溯源回河道原 raw(raw: tag),給「來自河道」連結 }; type Filter = 'sdd' | 'all'; // 從 tags_json 解析 detector 打的 tag function parseTodoTags(tagsJson: string | null): { isTodo: boolean; repo: string; rawId: string } { let tags: string[] = []; try { tags = JSON.parse(tagsJson || '[]'); } catch { /* */ } const isTodo = tags.includes('is_todo:true'); const repoTag = tags.find(t => t.startsWith('suggested-repo:')); const repo = repoTag ? repoTag.slice('suggested-repo:'.length) : ''; const rawTag = tags.find(t => t.startsWith('raw:')); const rawId = rawTag ? rawTag.slice('raw:'.length) : ''; return { isTodo, repo, rawId }; } export default function ProjectsPage() { const [repos, setRepos] = useState(null); const [todos, setTodos] = useState([]); const [error, setError] = useState(null); const [filter, setFilter] = useState('sdd'); const [cloning, setCloning] = useState(null); const [newRepo, setNewRepo] = useState(''); const load = () => { setError(null); fetch(`${DAEMON}/projects`) .then(async r => { if (!r.ok) throw new Error(`daemon 回 ${r.status}`); return r.json() as Promise<{ repos: RepoSummary[] }>; }) .then(d => setRepos(d.repos ?? [])) .catch(e => setError(e instanceof Error ? e.message : String(e))); void loadTodos(); }; // 撈 project_detector 拆出的 todo block(source=ai-project-detector,G1 一篇拆多條) // client 過濾 is_todo:true(KI-3 tag query bug 故用 source filter 而非 tag query) const loadTodos = async () => { try { const me = await fetch(`${API_BASE}/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null); if (!me?.api_key) return; const res = await fetch(`${KBDB_BASE}/blocks?type=note&source=ai-project-detector&limit=300`, { headers: { Authorization: `Bearer ${me.api_key}` }, }); if (!res.ok) return; const data = await res.json() as { blocks?: Array<{ id: string; content: string; tags_json: string | null }> }; const list: Todo[] = []; for (const b of data.blocks ?? []) { const { isTodo, repo, rawId } = parseTodoTags(b.tags_json); if (isTodo) list.push({ id: b.id, content: b.content, suggested_repo: repo || 'new', raw_id: rawId }); } setTodos(list); } catch { /* todos best-effort */ } }; useEffect(load, []); const clone = async (fullName: string) => { setCloning(fullName); setError(null); try { const r = await fetch(`${DAEMON}/projects/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: fullName }), }); if (!r.ok) throw new Error(`clone 失敗 ${r.status}`); setNewRepo(''); load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setCloning(null); } }; const addRepo = () => { const v = newRepo.trim(); if (!v) return; // 允許輸入 "name" 或 "owner/name" const full = v.includes('/') ? v : `richblack/${v}`; clone(full); }; const shown = useMemo(() => { if (!repos) return []; const arr = repos.filter(r => { if (filter === 'sdd') return r.has_sdd; return true; }); // 有進度等你處理的優先,其次已 clone,其次最近活動 arr.sort((a, b) => b.leo_count - a.leo_count || Number(b.cloned) - Number(a.cloned) || (b.last_activity ?? '').localeCompare(a.last_activity ?? ''), ); return arr; }, [repos, filter]); // 待辦按 suggested_repo 分組(outliner:Repo 父 → todo 子) const todosByRepo = useMemo(() => { const m = new Map(); for (const t of todos) { const key = t.suggested_repo || 'new'; if (!m.has(key)) m.set(key, []); m.get(key)!.push(t); } return m; }, [todos]); const newTodos = todosByRepo.get('new') ?? []; return (

📋 專案總管

已 clone 到工作台的 repo · 🤖 Mira 做 / 👤 等你

{([['sdd', '有進度'], ['all', '全部']] as [Filter, string][]).map(([k, label]) => ( ))}
setNewRepo(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addRepo(); }} />
{error && (
讀取 repo 清單失敗:{error}
掃描器在 mira daemon({DAEMON}/projects)。確認 daemon 已部署掃描 endpoint(階段 10-A)。
)} {!error && repos === null &&
載入中…
} {!error && repos?.length === 0 && (
GitHub 沒掃到任何 repo。
)}
{shown.map(r => ( clone(r.full_name)} todos={todosByRepo.get(r.name) ?? []} /> ))}
{newTodos.length > 0 && (
💡 建議新建專案({newTodos.length})
)}
); } function pct(r: RepoSummary): number { return r.total === 0 ? 0 : Math.round((r.done / r.total) * 100); } function relTime(iso: string | null): string { if (!iso) return '—'; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return '—'; const days = Math.floor((Date.now() - t) / 86400000); if (days <= 0) return '今天'; if (days === 1) return '昨天'; if (days < 30) return `${days} 天前`; return `${Math.floor(days / 30)} 個月前`; } function RepoCard({ r, cloning, onClone, todos }: { r: RepoSummary; cloning: boolean; onClone: () => void; todos: Todo[] }) { const percent = pct(r); return (
{r.name} {r.fork && fork} {r.archived && archived} {relTime(r.last_activity)}
{r.full_name}
{/* 河道偵測到、建議整進此專案的待辦(outliner 子項)*/} {todos.length > 0 && ( )} {!r.cloned ? (
尚未 clone 到 Hetzner
) : !r.has_sdd ? (
已 clone · 無 SDD 進度追蹤(無 .agents/specs)
) : ( <>
{percent}% {r.done}/{r.total} {r.leo_count > 0 && 👤 {r.leo_count} 等你} {r.ai_count > 0 && 🤖 {r.ai_count} Mira} {r.blocked_count > 0 && ⛔ {r.blocked_count}}
)}
); }