Files
Arcrun/landing/app/mira/projects/page.tsx
T
uncle6me-web 5d00e71275 chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)
頂層 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>
2026-07-03 07:13:33 +08:00

301 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 全清單 SSOTclone 到 Hetzner = 激活進工作態
// 資料源:mira daemon GET /projects(轉發 AI-Meka GitHub 掃描器)
import { useEffect, useMemo, useState } from 'react';
import '../mira.css';
// mira daemonnginx 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 blocktype=note, source=ai-project-detector
// G1:一篇河道筆記可拆多條 todo,各自掛 suggested-repodesign.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 blocksource=ai-project-detectorG1 一篇拆多條)
// client 過濾 is_todo:trueKI-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 分組(outlinerRepo 父 → 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="加 reponame 或 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>
);
}