5d00e71275
頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定, Gitea private=除機敏值/build 產物/.github 外全 push。 解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。 機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
'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<RepoSummary[] | null>(null);
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState<Filter>('sdd');
|
||
const [cloning, setCloning] = useState<string | null>(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<string, Todo[]>();
|
||
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 (
|
||
<main className="mira-page">
|
||
<div className="mira-content">
|
||
<header className="mira-proj-header">
|
||
<h1 className="mira-proj-title">📋 專案總管</h1>
|
||
<p className="mira-proj-sub">已 clone 到工作台的 repo · 🤖 Mira 做 / 👤 等你</p>
|
||
<div className="mira-proj-controls">
|
||
<div className="mira-proj-sort">
|
||
{([['sdd', '有進度'], ['all', '全部']] as [Filter, string][]).map(([k, label]) => (
|
||
<button
|
||
key={k}
|
||
className={`mira-proj-sort-btn${filter === k ? ' is-active' : ''}`}
|
||
onClick={() => setFilter(k)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mira-proj-add">
|
||
<input
|
||
className="mira-proj-add-input"
|
||
placeholder="加 repo:name 或 owner/name"
|
||
value={newRepo}
|
||
onChange={e => setNewRepo(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') addRepo(); }}
|
||
/>
|
||
<button className="mira-proj-add-btn" onClick={addRepo} disabled={!!cloning || !newRepo.trim()}>
|
||
{cloning ? 'clone 中…' : '↓ clone'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{error && (
|
||
<div className="mira-card mira-proj-error">
|
||
<strong>讀取 repo 清單失敗:</strong>{error}
|
||
<div className="mira-proj-error-hint">
|
||
掃描器在 mira daemon({DAEMON}/projects)。確認 daemon 已部署掃描 endpoint(階段 10-A)。
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!error && repos === null && <div className="mira-proj-loading">載入中…</div>}
|
||
{!error && repos?.length === 0 && (
|
||
<div className="mira-card mira-proj-empty">GitHub 沒掃到任何 repo。</div>
|
||
)}
|
||
|
||
<div className="mira-proj-grid">
|
||
{shown.map(r => (
|
||
<RepoCard
|
||
key={r.full_name}
|
||
r={r}
|
||
cloning={cloning === r.full_name}
|
||
onClone={() => clone(r.full_name)}
|
||
todos={todosByRepo.get(r.name) ?? []}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{newTodos.length > 0 && (
|
||
<div className="mira-card mira-proj-new-group">
|
||
<div className="mira-proj-new-title">💡 建議新建專案({newTodos.length})</div>
|
||
<ul className="mira-proj-todo-list">
|
||
{newTodos.map(t => (
|
||
<li key={t.id} className="mira-proj-todo-item">
|
||
<span className="mira-proj-todo-dot">·</span> {t.content}
|
||
{t.raw_id && (
|
||
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}>來自河道 ↗</a>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mira-card mira-proj-card">
|
||
<div className="mira-proj-card-top">
|
||
<span className="mira-proj-name">
|
||
{r.name}
|
||
{r.fork && <span className="mira-proj-tag">fork</span>}
|
||
{r.archived && <span className="mira-proj-tag">archived</span>}
|
||
</span>
|
||
<span className="mira-proj-time mira-en">{relTime(r.last_activity)}</span>
|
||
</div>
|
||
<div className="mira-proj-path mira-en">{r.full_name}</div>
|
||
|
||
{/* 河道偵測到、建議整進此專案的待辦(outliner 子項)*/}
|
||
{todos.length > 0 && (
|
||
<ul className="mira-proj-todo-list">
|
||
{todos.map(t => (
|
||
<li key={t.id} className="mira-proj-todo-item">
|
||
<span className="mira-proj-todo-dot">·</span> {t.content}
|
||
{t.raw_id && (
|
||
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}>來自河道 ↗</a>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{!r.cloned ? (
|
||
<div className="mira-proj-uncloned">
|
||
<span className="mira-proj-uncloned-hint">尚未 clone 到 Hetzner</span>
|
||
<button className="mira-proj-clone-btn" onClick={onClone} disabled={cloning}>
|
||
{cloning ? 'clone 中…' : '↓ clone 進工作態'}
|
||
</button>
|
||
</div>
|
||
) : !r.has_sdd ? (
|
||
<div className="mira-proj-nosdd">已 clone · 無 SDD 進度追蹤(無 .agents/specs)</div>
|
||
) : (
|
||
<>
|
||
<div className="mira-proj-bar">
|
||
<div className="mira-proj-bar-fill" style={{ width: `${percent}%` }} />
|
||
</div>
|
||
<div className="mira-proj-stats">
|
||
<span className="mira-proj-pct mira-en">{percent}%</span>
|
||
<span className="mira-en">{r.done}/{r.total}</span>
|
||
{r.leo_count > 0 && <span className="mira-proj-chip mira-chip-leo">👤 {r.leo_count} 等你</span>}
|
||
{r.ai_count > 0 && <span className="mira-proj-chip mira-chip-ai">🤖 {r.ai_count} Mira</span>}
|
||
{r.blocked_count > 0 && <span className="mira-proj-chip mira-chip-blocked">⛔ {r.blocked_count}</span>}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|