initial commit

This commit is contained in:
black_zero
2026-03-01 21:26:34 +08:00
commit 05d5c4016f
38 changed files with 7617 additions and 0 deletions

494
src/app/App.tsx Normal file
View File

@@ -0,0 +1,494 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { detectBrowserLanguage, resolveEffectiveLanguage } from "./i18n/language";
import { retryListMembership, syncFromGitHub } from "./core/githubSync";
import { db } from "./data/db";
import { useLiveQuery } from "./data/useLiveQuery";
import { validatePat } from "./services/githubAuth";
import { usePreferenceStore } from "./store/preferences";
import { ApplyUpdatesModal } from "./ui/ApplyUpdatesModal";
import { AssignListModal } from "./ui/AssignListModal";
import { FirstRunPrompt } from "./ui/FirstRunPrompt";
import { LlmClassificationModal } from "./ui/LlmClassificationModal";
import { PatModal } from "./ui/PatModal";
import { SettingsModal } from "./ui/SettingsModal";
type RepoPreview = {
id: string;
name: string;
description: string;
tags: string[];
language?: string | null;
stars?: number;
updatedAt?: string;
};
type SyncStatusCode = "idle" | "running" | "completed" | "failed" | "ready" | "retrying";
type SyncMessage = {
key: string;
values?: Record<string, number | string>;
};
const previewRepos: RepoPreview[] = [];
const previewLists: { id: string; name: string; count: number }[] = [
{ id: "all", name: "", count: 0 },
];
export default function App() {
const { t, i18n } = useTranslation();
const { preferences, setReadmeOptIn, markOnboarded, setPatToken, setLastSyncedAt } =
usePreferenceStore();
const [activeList, setActiveList] = useState("all");
const [isPatModalOpen, setIsPatModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isClassificationOpen, setIsClassificationOpen] = useState(false);
const [isApplyUpdatesOpen, setIsApplyUpdatesOpen] = useState(false);
const [assignRepo, setAssignRepo] = useState<{ id: string; name: string } | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncStatusCode>("idle");
const [syncMessage, setSyncMessage] = useState<SyncMessage>({
key: "app.sync.detail.connectPat",
});
const [syncError, setSyncError] = useState("");
const [syncStage, setSyncStage] = useState("idle");
const [syncCurrent, setSyncCurrent] = useState(0);
const [syncTotal, setSyncTotal] = useState(0);
const [syncFailed, setSyncFailed] = useState(0);
const [failedListIds, setFailedListIds] = useState<string[]>([]);
const [languageFilter, setLanguageFilter] = useState("all");
const [showUnlisted, setShowUnlisted] = useState(false);
const [recentOnly, setRecentOnly] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const browserLanguage = useMemo(() => detectBrowserLanguage(), []);
const effectiveLanguage = useMemo(
() => resolveEffectiveLanguage(preferences.uiLanguage, browserLanguage),
[preferences.uiLanguage, browserLanguage]
);
useEffect(() => {
void i18n.changeLanguage(effectiveLanguage);
document.documentElement.lang = effectiveLanguage;
}, [i18n, effectiveLanguage]);
useEffect(() => {
if (!preferences.hasCompletedOnboarding) {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}
return undefined;
}, [preferences.hasCompletedOnboarding]);
const lists = useLiveQuery(
async () => {
const listRows = await db.lists.toArray();
const repoListRows = await db.repoLists.toArray();
const countMap = new Map<string, number>();
for (const row of repoListRows) {
for (const listId of row.listIds) {
countMap.set(listId, (countMap.get(listId) ?? 0) + 1);
}
}
const listCounts = listRows.map((list) => ({
id: list.id,
name: list.name,
count: countMap.get(list.id) ?? 0,
}));
const total = await db.repos.count();
return [{ id: "all", name: "", count: total }, ...listCounts];
},
[],
previewLists
);
const repos = useLiveQuery(
async () => {
const repoRows = await db.repos.toArray();
const listMap = new Map<string, string>();
const listRows = await db.lists.toArray();
for (const list of listRows) listMap.set(list.id, list.name);
const repoListRows = await db.repoLists.toArray();
const repoListLookup = new Map(repoListRows.map((row) => [row.repoId, row.listIds]));
return repoRows.map((repo) => ({
id: repo.id,
name: repo.fullName,
description: repo.description || "",
tags: (repoListLookup.get(repo.id) || [])
.map((listId) => listMap.get(listId))
.filter((name): name is string => Boolean(name))
.slice(0, 4),
language: repo.language,
stars: repo.stargazerCount,
updatedAt: repo.updatedAt,
}));
},
[],
previewRepos
);
const languageOptions = useMemo(() => {
const set = new Set<string>();
for (const repo of repos) {
if (repo.language) set.add(repo.language);
}
return ["all", ...Array.from(set).sort()];
}, [repos]);
const visibleRepos = useMemo(() => {
if (activeList === "all") return repos;
const listName = lists.find((list) => list.id === activeList)?.name;
if (!listName) return [];
return repos.filter((repo) => repo.tags.includes(listName));
}, [activeList, repos, lists]);
const filteredRepos = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const threshold = Date.now() - 1000 * 60 * 60 * 24 * 180;
return visibleRepos.filter((repo) => {
if (query) {
const haystack = `${repo.name} ${repo.description}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
if (languageFilter !== "all" && repo.language !== languageFilter) return false;
if (showUnlisted && repo.tags.length > 0) return false;
if (recentOnly && repo.updatedAt) {
const updatedAt = Date.parse(repo.updatedAt);
if (!Number.isNaN(updatedAt) && updatedAt < threshold) return false;
}
return true;
});
}, [visibleRepos, languageFilter, showUnlisted, recentOnly, searchQuery]);
const lastSyncText = useMemo(() => {
if (!preferences.lastSyncedAt) return "";
const parsed = Date.parse(preferences.lastSyncedAt);
if (Number.isNaN(parsed)) return preferences.lastSyncedAt;
return new Date(parsed).toLocaleString(i18n.language);
}, [preferences.lastSyncedAt, i18n.language]);
const syncDetail = useMemo(() => {
if (syncError) return syncError;
if (syncStatus === "running" || syncStatus === "retrying") {
const stageText = t(`progress.sync.${syncStage}`, { defaultValue: syncStage });
return t("app.sync.detail.progress", {
stage: stageText,
current: syncCurrent,
total: syncTotal,
});
}
return t(syncMessage.key, syncMessage.values);
}, [syncError, syncStatus, syncStage, syncCurrent, syncTotal, syncMessage, t]);
useEffect(() => {
if (activeList === "all") return;
const exists = lists.some((list) => list.id === activeList);
if (!exists) setActiveList("all");
}, [activeList, lists]);
return (
<div className="app">
<header className="app-header">
<div className="brand">
<div className="brand-icon"></div>
<div>
<h1 className="brand-title">{t("common.appName")}</h1>
<p className="brand-subtitle">{t("app.subtitle")}</p>
</div>
</div>
<div className="header-actions">
<button className="button" onClick={() => setIsPatModalOpen(true)}>
{preferences.viewerLogin
? `PAT: ${preferences.viewerLogin}`
: t("app.actions.connectPat")}
</button>
<button className="button" onClick={() => setIsSettingsOpen(true)}>
{t("common.actions.settings")}
</button>
<button
className="button primary"
onClick={async () => {
if (!preferences.patToken) {
setIsPatModalOpen(true);
return;
}
setSyncStatus("running");
setSyncError("");
setSyncMessage({ key: "app.sync.detail.preparing" });
setSyncStage("idle");
setSyncCurrent(0);
setSyncTotal(0);
setSyncFailed(0);
try {
const result = await syncFromGitHub({ token: preferences.patToken }, (progress) => {
setSyncStage(progress.stage);
setSyncCurrent(progress.current);
setSyncTotal(progress.total);
setSyncFailed(progress.failed);
});
setSyncStatus("completed");
setSyncMessage({
key: "app.sync.detail.loaded",
values: { repos: result.repos, lists: result.lists },
});
setFailedListIds(result.failedListIds);
setLastSyncedAt(new Date().toISOString());
if (result.failedListIds.length === 0) {
setSyncFailed(0);
}
} catch (error) {
setSyncStatus("failed");
setSyncError((error as Error).message || t("app.sync.detail.syncFailed"));
}
}}
>
{t("app.actions.syncStarLists")}
</button>
</div>
</header>
<main className="app-main">
<section className="panel">
<h2>{t("app.sections.starLists")}</h2>
{lists.length === 1 && lists[0].id === "all" ? (
<div className="empty-state">
<p>{t("app.empty.noListsTitle")}</p>
<p>{t("app.empty.noListsHint")}</p>
</div>
) : (
lists.map((list) => (
<div
key={list.id}
className={`list-item ${activeList === list.id ? "active" : ""}`}
role="button"
tabIndex={0}
onClick={() => setActiveList(list.id)}
onKeyDown={(event) => {
if (event.key === "Enter") setActiveList(list.id);
}}
>
<span>{list.id === "all" ? t("common.values.allStarred") : list.name}</span>
<span>{list.count}</span>
</div>
))
)}
</section>
<section className="panel">
<h2>{t("app.sections.repositories")}</h2>
{repos.length === 0 ? (
<div className="empty-state">
<p>{t("app.empty.noDataTitle")}</p>
<p>{t("app.empty.noDataHint")}</p>
</div>
) : (
<div className="filters">
<div className="filter-group">
<label htmlFor="language-select">{t("common.labels.language")}</label>
<select
id="language-select"
value={languageFilter}
onChange={(event) => setLanguageFilter(event.target.value)}
>
{languageOptions.map((option) => (
<option key={option} value={option}>
{option === "all" ? t("common.values.all") : option}
</option>
))}
</select>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={showUnlisted}
onChange={(event) => setShowUnlisted(event.target.checked)}
/>
{t("app.filters.noList")}
</label>
<label className="checkbox">
<input
type="checkbox"
checked={recentOnly}
onChange={(event) => setRecentOnly(event.target.checked)}
/>
{t("app.filters.updatedLastSixMonths")}
</label>
<div className="filter-group search">
<label htmlFor="repo-search">{t("common.labels.search")}</label>
<input
id="repo-search"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("app.filters.searchPlaceholder")}
/>
</div>
</div>
)}
{repos.length > 0 && filteredRepos.length === 0 ? (
<div className="empty-state">
<p>{t("app.empty.noFilteredTitle")}</p>
<p>{t("app.empty.noFilteredHint")}</p>
</div>
) : repos.length === 0 ? null : (
<div className="repo-grid">
{filteredRepos.map((repo) => (
<article key={repo.id} className="repo-card">
<h3 className="repo-title">{repo.name}</h3>
<p className="repo-desc">{repo.description || t("app.values.noDescription")}</p>
<div>
{repo.tags.map((tag) => (
<span className="tag" key={tag}>
{tag}
</span>
))}
</div>
<div className="repo-meta">
<span>{repo.language || t("common.labels.unknown")}</span>
<span> {repo.stars ?? 0}</span>
</div>
<button
className="button"
onClick={() => setAssignRepo({ id: repo.id, name: repo.name })}
>
{t("app.actions.assignList")}
</button>
</article>
))}
</div>
)}
</section>
<section className="sidebar-meta">
<div className="meta-card">
<h3 className="meta-title">{t("app.sections.syncStatus")}</h3>
<p className="meta-desc">
{t("common.labels.status")}: {t(`app.sync.status.${syncStatus}`, { defaultValue: syncStatus })}
</p>
<p className="meta-desc">{syncDetail}</p>
<p className="meta-desc">
{t("common.labels.stage")}: {t(`progress.sync.${syncStage}`, { defaultValue: syncStage })}
</p>
<p className="meta-desc">
{t("common.labels.progress")}: {syncCurrent}/{syncTotal} · {t("common.labels.failed")}: {syncFailed}
</p>
{preferences.lastSyncedAt ? (
<p className="meta-desc">
{t("common.labels.lastSync")}: {lastSyncText}
</p>
) : null}
{failedListIds.length > 0 ? (
<button
className="button"
onClick={async () => {
if (!preferences.patToken) return;
setSyncStatus("retrying");
setSyncError("");
try {
const result = await retryListMembership(
{ token: preferences.patToken },
failedListIds,
(progress) => {
setSyncStage(progress.stage);
setSyncCurrent(progress.current);
setSyncTotal(progress.total);
setSyncFailed(progress.failed);
}
);
setFailedListIds(result.failedListIds);
setSyncStatus("completed");
setSyncMessage({
key:
result.failedListIds.length === 0
? "app.sync.detail.retryRecovered"
: "app.sync.detail.retryFinished",
values:
result.failedListIds.length === 0
? undefined
: { count: result.failedListIds.length },
});
} catch (error) {
setSyncStatus("failed");
setSyncError((error as Error).message || t("app.sync.detail.retryFailed"));
}
}}
>
{t("app.actions.retryFailedLists", { count: failedListIds.length })}
</button>
) : null}
</div>
<div className="meta-card">
<h3 className="meta-title">{t("app.sections.llmClassification")}</h3>
<p className="meta-desc">{t("app.sidebar.llmDesc")}</p>
<button className="button" onClick={() => setIsSettingsOpen(true)}>
{t("app.actions.configureLlm")}
</button>
<button className="button" onClick={() => setIsClassificationOpen(true)}>
{t("app.actions.runClassification")}
</button>
</div>
<div className="meta-card">
<h3 className="meta-title">{t("app.sections.batchActions")}</h3>
<p className="meta-desc">{t("app.sidebar.batchDesc")}</p>
<button className="button primary" onClick={() => setIsApplyUpdatesOpen(true)}>
{t("app.actions.applyUpdates")}
</button>
</div>
</section>
</main>
{!preferences.hasCompletedOnboarding ? (
<FirstRunPrompt
onConfirm={(value) => {
setReadmeOptIn(value);
markOnboarded();
}}
onSkip={() => {
setReadmeOptIn(false);
markOnboarded();
}}
/>
) : null}
<PatModal
isOpen={isPatModalOpen}
onClose={() => setIsPatModalOpen(false)}
onSave={async (token) => {
try {
const viewer = await validatePat(token);
setPatToken(token, viewer.login);
setSyncStatus("ready");
setSyncError("");
setSyncMessage({ key: "app.sync.detail.tokenOk", values: { login: viewer.login } });
setIsPatModalOpen(false);
} catch (error) {
setSyncStatus("failed");
setSyncError((error as Error).message || t("app.sync.detail.tokenValidationFailed"));
}
}}
/>
<SettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
<LlmClassificationModal
isOpen={isClassificationOpen}
onClose={() => setIsClassificationOpen(false)}
/>
<ApplyUpdatesModal
isOpen={isApplyUpdatesOpen}
onClose={() => setIsApplyUpdatesOpen(false)}
token={preferences.patToken}
onRequestToken={() => setIsPatModalOpen(true)}
/>
{assignRepo ? (
<AssignListModal
isOpen={Boolean(assignRepo)}
repoId={assignRepo.id}
repoName={assignRepo.name}
onClose={() => setAssignRepo(null)}
/>
) : null}
</div>
);
}

161
src/app/core/githubSync.ts Normal file
View File

@@ -0,0 +1,161 @@
import { db } from "../data/db";
import { fetchListMembership, fetchStarLists, fetchStarredRepos, detectStarListApi } from "../services/githubStarLists";
import type { GitHubConfig } from "../services/githubClient";
import { runWithConcurrency } from "./queues";
export type SyncProgress = {
stage: string;
current: number;
total: number;
failed: number;
};
type ListMembershipResult = {
listId: string;
repoIds: string[];
failed?: boolean;
};
export type SyncResult = {
lists: number;
repos: number;
failedListIds: string[];
};
type RetryResult = {
failedListIds: string[];
};
export async function syncFromGitHub(
config: GitHubConfig,
onProgress?: (progress: SyncProgress) => void
): Promise<SyncResult> {
const listApi = await detectStarListApi(config);
onProgress?.({ stage: "fetching_star_lists", current: 0, total: 1, failed: 0 });
const lists = await fetchStarLists(config, listApi);
onProgress?.({ stage: "fetching_star_lists", current: 1, total: 1, failed: 0 });
onProgress?.({ stage: "fetching_starred_repos", current: 0, total: 1, failed: 0 });
const repos = await fetchStarredRepos(config);
onProgress?.({ stage: "fetching_starred_repos", current: 1, total: 1, failed: 0 });
onProgress?.({ stage: "scanning_list_membership", current: 0, total: lists.length || 1, failed: 0 });
let processed = 0;
let failed = 0;
const membershipTasks = lists.map((list) => async (): Promise<ListMembershipResult> => {
try {
const membership = await fetchListMembership(config, list.id);
processed += 1;
onProgress?.({
stage: "scanning_list_membership",
current: processed,
total: lists.length || 1,
failed,
});
return { ...membership, failed: false };
} catch {
processed += 1;
failed += 1;
onProgress?.({
stage: "scanning_list_membership",
current: processed,
total: lists.length || 1,
failed,
});
return { listId: list.id, repoIds: [], failed: true };
}
});
const memberships = await runWithConcurrency(membershipTasks, 2);
const failedListIds = memberships.filter((membership) => membership.failed).map((m) => m.listId);
const repoListMap = new Map<string, Set<string>>();
for (const membership of memberships) {
for (const repoId of membership.repoIds) {
const existing = repoListMap.get(repoId) ?? new Set<string>();
existing.add(membership.listId);
repoListMap.set(repoId, existing);
}
}
await db.transaction("rw", db.lists, db.repos, db.repoLists, async () => {
await db.lists.clear();
await db.repos.clear();
await db.repoLists.clear();
await db.lists.bulkPut(
lists.map((list) => ({
id: list.id,
name: list.name,
description: list.description ?? "",
isPrivate: false,
}))
);
await db.repos.bulkPut(
repos.map((repo) => ({
id: repo.id,
fullName: repo.fullName,
description: repo.description ?? "",
repoUrl: repo.url,
topics: repo.topics,
language: repo.language,
updatedAt: repo.updatedAt,
readmeExcerpt: "",
stargazerCount: repo.stargazerCount,
}))
);
await db.repoLists.bulkPut(
Array.from(repoListMap.entries()).map(([repoId, listIds]) => ({
repoId,
listIds: Array.from(listIds),
}))
);
});
return { lists: lists.length, repos: repos.length, failedListIds };
}
export async function retryListMembership(
config: GitHubConfig,
listIds: string[],
onProgress?: (progress: SyncProgress) => void
): Promise<RetryResult> {
if (listIds.length === 0) return { failedListIds: [] };
let processed = 0;
let failed = 0;
onProgress?.({ stage: "retrying_list_membership", current: 0, total: listIds.length, failed: 0 });
const tasks = listIds.map((listId) => async () => {
try {
const membership = await fetchListMembership(config, listId);
processed += 1;
onProgress?.({ stage: "retrying_list_membership", current: processed, total: listIds.length, failed });
return { listId, repoIds: membership.repoIds, failed: false };
} catch {
processed += 1;
failed += 1;
onProgress?.({ stage: "retrying_list_membership", current: processed, total: listIds.length, failed });
return { listId, repoIds: [], failed: true };
}
});
const results = await runWithConcurrency(tasks, 2);
const failedListIds = results.filter((item) => item.failed).map((item) => item.listId);
await db.transaction("rw", db.repoLists, async () => {
for (const membership of results) {
for (const repoId of membership.repoIds) {
const existing = await db.repoLists.get(repoId);
const listIdsForRepo = existing?.listIds ?? [];
const next = Array.from(new Set([...listIdsForRepo, membership.listId]));
await db.repoLists.put({ repoId, listIds: next });
}
}
});
return { failedListIds };
}

View File

@@ -0,0 +1,607 @@
import { db } from "../data/db";
import type { GitHubConfig } from "../services/githubClient";
import {
addStar,
buildRepoMembershipIndex,
createUserList,
detectStarListApi,
fetchStarLists,
getRepositoryMeta,
type UserListRef,
updateUserListsForItem,
} from "../services/githubStarLists";
import { runWithConcurrency } from "./queues";
export type WritebackIssueReason =
| "empty_tags"
| "repo_not_found"
| "invalid_repo_name"
| "repo_meta_missing"
| "repo_meta_fetch_failed"
| "missing_list"
| "create_list_failed"
| "membership_scan_failed"
| "no_changes"
| "unstarred_requires_confirmation"
| "add_star_failed"
| "update_failed";
export type WritebackIssue = {
repoId?: string;
repoFullName?: string;
reason: WritebackIssueReason;
message: string;
};
export type WritebackCandidate = {
repoId: string;
repoFullName: string;
itemId: string;
viewerHasStarred: boolean;
targetTags: string[];
targetListIds: string[];
targetListNames: string[];
currentListIds: string[];
currentListNames: string[];
finalListIds: string[];
finalListNames: string[];
};
export type WritebackPlan = {
totalClassifications: number;
validTaggedRepos: number;
actionableRepos: number;
unchangedRepos: number;
scanFailures: number;
missingLists: string[];
createdLists: string[];
unstarredActionableRepos: number;
skipped: WritebackIssue[];
candidates: WritebackCandidate[];
};
export type WritebackResult = {
plan: WritebackPlan;
applied: number;
failed: WritebackIssue[];
skipped: WritebackIssue[];
createdLists: string[];
locallyUpdated: number;
};
export type WritebackProgress = {
stage: string;
current: number;
total: number;
failed: number;
skipped: number;
};
export type PlanWritebackOptions = {
createMissingLists?: boolean;
createMissingListsAsPrivate?: boolean;
};
export type ApplyWritebackOptions = {
autoStarForUnstarred?: boolean;
resolveAutoStarForUnstarred?: (unstarredCount: number) => boolean | Promise<boolean>;
createMissingListsAsPrivate?: boolean;
reusePlan?: WritebackPlan | null;
};
type PreparedWriteback = {
plan: WritebackPlan;
listIdToName: Map<string, string>;
createdListRefs: UserListRef[];
};
const REPO_META_CONCURRENCY = 3;
export async function planWriteback(
config: GitHubConfig,
options: PlanWritebackOptions = {},
onProgress?: (progress: WritebackProgress) => void
): Promise<WritebackPlan> {
const prepared = await prepareWriteback(
config,
options.createMissingLists ?? false,
options.createMissingListsAsPrivate,
onProgress
);
return prepared.plan;
}
export async function applyWriteback(
config: GitHubConfig,
options: ApplyWritebackOptions,
onProgress?: (progress: WritebackProgress) => void
): Promise<WritebackResult> {
const prepared =
options.reusePlan != null
? {
plan: options.reusePlan,
listIdToName: new Map<string, string>(),
createdListRefs: [],
}
: await prepareWriteback(config, true, options.createMissingListsAsPrivate, onProgress);
return applyPreparedWriteback(config, prepared, options, onProgress);
}
async function applyPreparedWriteback(
config: GitHubConfig,
prepared: PreparedWriteback,
options: ApplyWritebackOptions,
onProgress?: (progress: WritebackProgress) => void
): Promise<WritebackResult> {
const failed: WritebackIssue[] = [];
const skipped: WritebackIssue[] = [...prepared.plan.skipped];
const successfulCandidates: WritebackCandidate[] = [];
let applied = 0;
let current = 0;
const total = prepared.plan.candidates.length;
let autoStarForUnstarred = options.autoStarForUnstarred ?? false;
if (prepared.plan.unstarredActionableRepos > 0 && options.resolveAutoStarForUnstarred) {
autoStarForUnstarred = await options.resolveAutoStarForUnstarred(
prepared.plan.unstarredActionableRepos
);
}
emitProgress(onProgress, {
stage: "applying_updates",
current: 0,
total: total || 1,
failed: failed.length,
skipped: skipped.length,
});
for (const candidate of prepared.plan.candidates) {
if (!autoStarForUnstarred && !candidate.viewerHasStarred) {
skipped.push({
repoId: candidate.repoId,
repoFullName: candidate.repoFullName,
reason: "unstarred_requires_confirmation",
message: "Repo is not starred and auto-star was declined.",
});
current += 1;
emitProgress(onProgress, {
stage: "applying_updates",
current,
total: total || 1,
failed: failed.length,
skipped: skipped.length,
});
continue;
}
try {
if (autoStarForUnstarred && !candidate.viewerHasStarred) {
await addStar(config, candidate.itemId);
}
} catch (error) {
failed.push({
repoId: candidate.repoId,
repoFullName: candidate.repoFullName,
reason: "add_star_failed",
message: `Failed to star repo before writeback: ${(error as Error).message || "unknown"}`,
});
current += 1;
emitProgress(onProgress, {
stage: "applying_updates",
current,
total: total || 1,
failed: failed.length,
skipped: skipped.length,
});
continue;
}
try {
await updateUserListsForItem(config, candidate.itemId, candidate.finalListIds);
applied += 1;
successfulCandidates.push(candidate);
} catch (error) {
failed.push({
repoId: candidate.repoId,
repoFullName: candidate.repoFullName,
reason: "update_failed",
message: `Failed to update repo lists: ${(error as Error).message || "unknown"}`,
});
}
current += 1;
emitProgress(onProgress, {
stage: "applying_updates",
current,
total: total || 1,
failed: failed.length,
skipped: skipped.length,
});
}
const locallyUpdated = await syncLocalWritebackState(
prepared,
successfulCandidates,
options.createMissingListsAsPrivate ?? false
);
return {
plan: prepared.plan,
applied,
failed,
skipped,
createdLists: prepared.plan.createdLists,
locallyUpdated,
};
}
async function prepareWriteback(
config: GitHubConfig,
createMissingLists: boolean,
createMissingListsAsPrivate = false,
onProgress?: (progress: WritebackProgress) => void
): Promise<PreparedWriteback> {
const skipped: WritebackIssue[] = [];
const createdLists: string[] = [];
const createdListRefs: UserListRef[] = [];
emitProgress(onProgress, {
stage: "loading_local_classifications",
current: 0,
total: 1,
failed: 0,
skipped: 0,
});
const [classificationRows, repoRows] = await Promise.all([db.classifications.toArray(), db.repos.toArray()]);
emitProgress(onProgress, {
stage: "loading_local_classifications",
current: 1,
total: 1,
failed: 0,
skipped: skipped.length,
});
const repoById = new Map(repoRows.map((repo) => [repo.id, repo]));
const repoTargetTags = new Map<string, string[]>();
const allTargetTags = new Set<string>();
for (const row of classificationRows) {
const validTags = normalizeTags(row.tags);
if (validTags.length === 0) {
skipped.push({
repoId: row.repoId,
reason: "empty_tags",
message: "Classification has no valid tags.",
});
continue;
}
if (!repoById.has(row.repoId)) {
skipped.push({
repoId: row.repoId,
reason: "repo_not_found",
message: "Repo is not present in local database.",
});
continue;
}
repoTargetTags.set(row.repoId, validTags);
for (const tag of validTags) {
allTargetTags.add(tag);
}
}
emitProgress(onProgress, {
stage: "fetching_star_lists",
current: 0,
total: 1,
failed: 0,
skipped: skipped.length,
});
const listApi = await detectStarListApi(config);
const remoteLists = await fetchStarLists(config, listApi);
const listNameToId = new Map(remoteLists.map((list) => [list.name, list.id]));
const listIdToName = new Map(remoteLists.map((list) => [list.id, list.name]));
emitProgress(onProgress, {
stage: "fetching_star_lists",
current: 1,
total: 1,
failed: 0,
skipped: skipped.length,
});
const missingLists: string[] = [];
for (const tag of allTargetTags) {
if (!listNameToId.has(tag)) {
missingLists.push(tag);
}
}
if (createMissingLists && missingLists.length > 0) {
let current = 0;
emitProgress(onProgress, {
stage: "creating_missing_lists",
current: 0,
total: missingLists.length,
failed: 0,
skipped: skipped.length,
});
for (const listName of missingLists) {
try {
const created = await createUserList(config, listName, createMissingListsAsPrivate, "");
listNameToId.set(created.name, created.id);
listIdToName.set(created.id, created.name);
createdLists.push(created.name);
createdListRefs.push(created);
} catch (error) {
skipped.push({
reason: "create_list_failed",
message: `Failed to create list "${listName}": ${(error as Error).message || "unknown"}`,
});
}
current += 1;
emitProgress(onProgress, {
stage: "creating_missing_lists",
current,
total: missingLists.length,
failed: 0,
skipped: skipped.length,
});
}
}
const resolvableRepoIds = Array.from(repoTargetTags.keys());
const repoMetaByRepoId = new Map<string, { itemId: string; viewerHasStarred: boolean; repoFullName: string }>();
let repoMetaCurrent = 0;
emitProgress(onProgress, {
stage: "resolving_repositories",
current: 0,
total: resolvableRepoIds.length || 1,
failed: 0,
skipped: skipped.length,
});
const repoMetaTasks = resolvableRepoIds.map((repoId) => async () => {
const repo = repoById.get(repoId);
if (!repo) return;
const parsed = parseRepoFullName(repo.fullName);
if (!parsed) {
skipped.push({
repoId,
repoFullName: repo.fullName,
reason: "invalid_repo_name",
message: `Invalid fullName format: ${repo.fullName}`,
});
repoMetaCurrent += 1;
emitProgress(onProgress, {
stage: "resolving_repositories",
current: repoMetaCurrent,
total: resolvableRepoIds.length || 1,
failed: 0,
skipped: skipped.length,
});
return;
}
try {
const meta = await getRepositoryMeta(config, parsed.owner, parsed.name);
if (!meta) {
skipped.push({
repoId,
repoFullName: repo.fullName,
reason: "repo_meta_missing",
message: "Repository metadata cannot be loaded from GitHub.",
});
} else {
repoMetaByRepoId.set(repoId, {
itemId: meta.id,
viewerHasStarred: meta.viewerHasStarred,
repoFullName: repo.fullName,
});
}
} catch (error) {
skipped.push({
repoId,
repoFullName: repo.fullName,
reason: "repo_meta_fetch_failed",
message: `Repository metadata request failed: ${(error as Error).message || "unknown"}`,
});
}
repoMetaCurrent += 1;
emitProgress(onProgress, {
stage: "resolving_repositories",
current: repoMetaCurrent,
total: resolvableRepoIds.length || 1,
failed: 0,
skipped: skipped.length,
});
});
await runWithConcurrency(repoMetaTasks, REPO_META_CONCURRENCY);
const targetRepoIds = new Set<string>();
for (const meta of repoMetaByRepoId.values()) {
targetRepoIds.add(meta.itemId);
}
emitProgress(onProgress, {
stage: "scanning_existing_memberships",
current: 0,
total: listNameToId.size || 1,
failed: 0,
skipped: skipped.length,
});
const repoMembershipResult = await buildRepoMembershipIndex(
config,
Array.from(listNameToId.values()),
targetRepoIds,
(progress) => {
emitProgress(onProgress, {
stage: "scanning_existing_memberships",
current: progress.current,
total: progress.total || 1,
failed: 0,
skipped: skipped.length,
});
}
);
const repoMembershipIndex = repoMembershipResult.index;
const scanFailures = repoMembershipResult.failedListIds.length;
for (const failedListId of repoMembershipResult.failedListIds) {
skipped.push({
reason: "membership_scan_failed",
message: `Membership scan failed for list ${listIdToName.get(failedListId) || failedListId}.`,
});
}
const candidates: WritebackCandidate[] = [];
let unchangedRepos = 0;
let unstarredActionableRepos = 0;
for (const [repoId, tags] of repoTargetTags.entries()) {
const meta = repoMetaByRepoId.get(repoId);
if (!meta) continue;
const targetListIds: string[] = [];
let hasMissingList = false;
for (const tag of tags) {
const listId = listNameToId.get(tag);
if (!listId) {
hasMissingList = true;
break;
}
targetListIds.push(listId);
}
if (hasMissingList) {
skipped.push({
repoId,
repoFullName: meta.repoFullName,
reason: "missing_list",
message: "One or more target lists do not exist on GitHub.",
});
continue;
}
const currentSet = repoMembershipIndex.get(meta.itemId) ?? new Set<string>();
const finalSet = new Set([...currentSet, ...targetListIds]);
const changed = finalSet.size !== currentSet.size;
if (!changed) {
unchangedRepos += 1;
skipped.push({
repoId,
repoFullName: meta.repoFullName,
reason: "no_changes",
message: "Repo already contains all target lists.",
});
continue;
}
if (!meta.viewerHasStarred) {
unstarredActionableRepos += 1;
}
candidates.push({
repoId,
repoFullName: meta.repoFullName,
itemId: meta.itemId,
viewerHasStarred: meta.viewerHasStarred,
targetTags: tags,
targetListIds: uniqueArray(targetListIds),
targetListNames: uniqueArray(
targetListIds.map((id) => listIdToName.get(id)).filter((name): name is string => Boolean(name))
),
currentListIds: Array.from(currentSet),
currentListNames: Array.from(currentSet)
.map((id) => listIdToName.get(id))
.filter((name): name is string => Boolean(name)),
finalListIds: Array.from(finalSet),
finalListNames: Array.from(finalSet)
.map((id) => listIdToName.get(id))
.filter((name): name is string => Boolean(name)),
});
}
return {
plan: {
totalClassifications: classificationRows.length,
validTaggedRepos: repoTargetTags.size,
actionableRepos: candidates.length,
unchangedRepos,
scanFailures,
missingLists,
createdLists,
unstarredActionableRepos,
skipped,
candidates,
},
listIdToName,
createdListRefs,
};
}
async function syncLocalWritebackState(
prepared: PreparedWriteback,
successfulCandidates: WritebackCandidate[],
createMissingListsAsPrivate: boolean
): Promise<number> {
const updates = successfulCandidates.map((candidate) => ({
repoId: candidate.repoId,
listIds: candidate.finalListIds,
}));
await db.transaction("rw", db.lists, db.repoLists, async () => {
if (prepared.createdListRefs.length > 0) {
await db.lists.bulkPut(
prepared.createdListRefs.map((list) => ({
id: list.id,
name: list.name,
description: "",
isPrivate: createMissingListsAsPrivate,
}))
);
}
for (const update of updates) {
await db.repoLists.put(update);
}
});
return updates.length;
}
function normalizeTags(tags: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of tags) {
const tag = raw.trim();
if (!tag) continue;
if (tag.toLowerCase() === "unclassified") continue;
const key = tag.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(tag);
}
return out;
}
function parseRepoFullName(fullName: string): { owner: string; name: string } | null {
const parts = fullName.split("/");
if (parts.length !== 2) return null;
const owner = parts[0].trim();
const name = parts[1].trim();
if (!owner || !name) return null;
return { owner, name };
}
function uniqueArray(items: string[]): string[] {
return Array.from(new Set(items));
}
function emitProgress(
onProgress: ((progress: WritebackProgress) => void) | undefined,
progress: WritebackProgress
) {
onProgress?.(progress);
}

View File

@@ -0,0 +1,224 @@
import type { LLMConfig } from "../services/llmClient";
import { requestCompletion } from "../services/llmClient";
import { resolvePrompts } from "./llmPromptResolver";
import { runWithConcurrency } from "./queues";
export type RepoForClassification = {
id: string;
fullName: string;
description: string;
topics: string[];
language: string | null;
readmeExcerpt: string;
existingLists?: string[];
};
export type ClassificationProgress = {
stage: string;
current: number;
total: number;
};
export type ClassificationResult = {
repoTags: Record<string, string[]>;
compressedTags: string[];
tagMap: Record<string, string>;
runId: string;
};
export type ClassificationOptions = {
strictSingleTag?: boolean;
pass1Prompt?: string;
pass1StrictPrompt?: string;
pass2Prompt?: string;
useExistingLists?: boolean;
allowNewTagsWithExistingLists?: boolean;
language?: "zh" | "en";
};
const DEFAULT_PASS2_PROMPT =
"You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.";
export async function runTwoStageClassification(
config: LLMConfig,
repos: RepoForClassification[],
options: ClassificationOptions = {},
onProgress?: (progress: ClassificationProgress) => void
): Promise<ClassificationResult> {
const mode = {
strictSingleTag: options.strictSingleTag ?? false,
useExistingLists: options.useExistingLists ?? false,
allowNewTagsWithExistingLists: options.allowNewTagsWithExistingLists !== false,
language: options.language ?? "en",
} as const;
const prompts = resolvePrompts(config, mode);
const repoTags: Record<string, string[]> = {};
let completed = 0;
const runId = createRunId();
const tasks = repos.map((repo) => async () => {
const tags = await classifyRepo(config, repo, {
strictSingleTag: mode.strictSingleTag,
systemPrompt: prompts.selectedPass1,
useExistingLists: mode.useExistingLists,
allowNewTagsWithExistingLists: mode.allowNewTagsWithExistingLists,
});
completed += 1;
onProgress?.({ stage: "classifying_repos", current: completed, total: repos.length });
return { id: repo.id, tags };
});
const results = await runWithConcurrency(tasks, 3);
const allTags: string[] = [];
for (const result of results) {
repoTags[result.id] = result.tags;
allTags.push(...result.tags);
}
const uniqueTags = normalizeTags(allTags);
onProgress?.({ stage: "compressing_tags", current: 0, total: 1 });
const tagMap = await compressTags(config, uniqueTags, prompts.pass2);
const compressedTags = Array.from(new Set(Object.values(tagMap)));
for (const repoId of Object.keys(repoTags)) {
repoTags[repoId] = repoTags[repoId].map((tag) => tagMap[tag] ?? tag);
}
onProgress?.({ stage: "compressing_tags", current: 1, total: 1 });
return { repoTags, compressedTags, tagMap, runId };
}
async function classifyRepo(
config: LLMConfig,
repo: RepoForClassification,
options: {
strictSingleTag: boolean;
systemPrompt: string;
useExistingLists?: boolean;
allowNewTagsWithExistingLists?: boolean;
}
): Promise<string[]> {
const userContent = [
`Name: ${repo.fullName}`,
`Description: ${repo.description || ""}`,
`Topics: ${repo.topics.join(", ") || ""}`,
`Language: ${repo.language || ""}`,
`README: ${repo.readmeExcerpt.slice(0, 800)}`,
options.useExistingLists
? `Existing Lists: ${repo.existingLists && repo.existingLists.length > 0 ? repo.existingLists.join(", ") : "(none)"}`
: "",
]
.filter(Boolean)
.join("\n");
const response = await requestCompletion(config, [
{ role: "system", content: options.systemPrompt },
{ role: "user", content: userContent },
]);
const tags = parseTags(response, options.strictSingleTag);
if (options.useExistingLists && options.allowNewTagsWithExistingLists === false) {
const existing = new Set((repo.existingLists || []).map((tag) => tag.toLowerCase()));
return tags.filter((tag) => existing.has(tag.toLowerCase()));
}
if (tags.length === 0) {
return ["Unclassified"];
}
return tags;
}
async function compressTags(
config: LLMConfig,
tags: string[],
pass2Prompt?: string
): Promise<Record<string, string>> {
if (tags.length === 0) return {};
if (tags.length === 1) return { [tags[0]]: tags[0] };
const userContent = JSON.stringify({ tags });
try {
const response = await requestCompletion(config, [
{ role: "system", content: pass2Prompt || DEFAULT_PASS2_PROMPT },
{ role: "user", content: userContent },
]);
return parseTagMap(response, tags);
} catch {
return Object.fromEntries(tags.map((tag) => [tag, tag]));
}
}
function parseTags(response: string, strictSingleTag: boolean): string[] {
const trimmed = response.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed) as string[];
if (Array.isArray(parsed)) {
const normalized = normalizeTags(parsed);
return strictSingleTag ? normalized.slice(0, 1) : normalized;
}
} catch {
// fall through
}
}
const parts = trimmed
.split(/[,\n]/)
.map((part) => part.replace(/^[-*\d.\s]+/, "").trim())
.filter(Boolean);
const normalized = normalizeTags(parts);
return strictSingleTag ? normalized.slice(0, 1) : normalized;
}
function parseTagMap(response: string, originals: string[]): Record<string, string> {
const fallback = Object.fromEntries(originals.map((tag) => [tag, tag]));
const raw = extractJson(response);
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as Record<string, string> | Array<{ from: string; to: string }>;
const map: Record<string, string> = { ...fallback };
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (!entry?.from || !entry?.to) continue;
map[entry.from.trim()] = entry.to.trim();
}
return map;
}
for (const [key, value] of Object.entries(parsed)) {
if (!key || !value) continue;
map[key.trim()] = value.trim();
}
return map;
} catch {
return fallback;
}
}
function extractJson(text: string): string | null {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
return text.slice(start, end + 1);
}
function normalizeTags(tags: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const cleaned = tag.trim();
if (!cleaned) continue;
const key = cleaned.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(cleaned);
}
return result;
}
function createRunId(): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const random = Math.random().toString(36).slice(2, 8);
return `run-${stamp}-${random}`;
}

View File

@@ -0,0 +1,160 @@
import type { LLMConfig } from "../services/llmClient";
export type PromptLanguage = "zh" | "en";
export type PromptFieldKey =
| "pass1Prompt"
| "pass1StrictPrompt"
| "pass2Prompt"
| "pass1PromptWithExisting"
| "pass1PromptNoNewWithExisting"
| "pass1StrictPromptWithExisting"
| "pass1StrictNoNewWithExisting"
| "pass2PromptWithExisting"
| "pass1PromptZh"
| "pass1StrictPromptZh"
| "pass2PromptZh"
| "pass1PromptWithExistingZh"
| "pass1PromptNoNewWithExistingZh"
| "pass1StrictPromptWithExistingZh"
| "pass1StrictNoNewWithExistingZh"
| "pass2PromptWithExistingZh";
type PromptFieldSet = {
pass1: PromptFieldKey;
pass1Strict: PromptFieldKey;
pass2: PromptFieldKey;
};
export type PromptMode = {
useExistingLists: boolean;
allowNewTagsWithExistingLists: boolean;
strictSingleTag: boolean;
language: PromptLanguage;
};
export type ResolvedPrompts = {
pass1: string;
pass1Strict: string;
pass2: string;
selectedPass1: string;
activeKeys: PromptFieldSet;
inactiveKeys: PromptFieldKey[];
};
const DEFAULT_PASS1_PROMPT =
"You label GitHub repositories with 1-3 concise tags. Output only comma-separated tags.";
const DEFAULT_PASS1_STRICT_PROMPT =
"You label GitHub repositories with exactly 1 concise tag. Output only the tag text.";
const DEFAULT_PASS2_PROMPT =
"You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.";
const EN_PROMPT_FIELDS: PromptFieldKey[] = [
"pass1Prompt",
"pass1StrictPrompt",
"pass2Prompt",
"pass1PromptWithExisting",
"pass1PromptNoNewWithExisting",
"pass1StrictPromptWithExisting",
"pass1StrictNoNewWithExisting",
"pass2PromptWithExisting",
];
const ZH_PROMPT_FIELDS: PromptFieldKey[] = [
"pass1PromptZh",
"pass1StrictPromptZh",
"pass2PromptZh",
"pass1PromptWithExistingZh",
"pass1PromptNoNewWithExistingZh",
"pass1StrictPromptWithExistingZh",
"pass1StrictNoNewWithExistingZh",
"pass2PromptWithExistingZh",
];
export function resolvePrompts(config: LLMConfig, mode: PromptMode): ResolvedPrompts {
const activeKeys = resolvePromptKeys(mode);
const pass1 = readPrompt(config, activeKeys.pass1, DEFAULT_PASS1_PROMPT);
const pass1Strict = readPrompt(config, activeKeys.pass1Strict, DEFAULT_PASS1_STRICT_PROMPT);
const pass2 = readPrompt(config, activeKeys.pass2, DEFAULT_PASS2_PROMPT);
const selectedPass1 = mode.strictSingleTag ? pass1Strict : pass1;
const inactiveKeys = getPromptFields(mode.language).filter(
(key) =>
key !== activeKeys.pass1 && key !== activeKeys.pass1Strict && key !== activeKeys.pass2
);
return {
pass1,
pass1Strict,
pass2,
selectedPass1,
activeKeys,
inactiveKeys,
};
}
function resolvePromptKeys(mode: PromptMode): PromptFieldSet {
if (mode.language === "zh") {
return resolveZhPromptKeys(mode);
}
return resolveEnPromptKeys(mode);
}
function resolveEnPromptKeys(mode: PromptMode): PromptFieldSet {
if (!mode.useExistingLists) {
return {
pass1: "pass1Prompt",
pass1Strict: "pass1StrictPrompt",
pass2: "pass2Prompt",
};
}
if (mode.allowNewTagsWithExistingLists) {
return {
pass1: "pass1PromptWithExisting",
pass1Strict: "pass1StrictPromptWithExisting",
pass2: "pass2PromptWithExisting",
};
}
return {
pass1: "pass1PromptNoNewWithExisting",
pass1Strict: "pass1StrictNoNewWithExisting",
pass2: "pass2PromptWithExisting",
};
}
function resolveZhPromptKeys(mode: PromptMode): PromptFieldSet {
if (!mode.useExistingLists) {
return {
pass1: "pass1PromptZh",
pass1Strict: "pass1StrictPromptZh",
pass2: "pass2PromptZh",
};
}
if (mode.allowNewTagsWithExistingLists) {
return {
pass1: "pass1PromptWithExistingZh",
pass1Strict: "pass1StrictPromptWithExistingZh",
pass2: "pass2PromptWithExistingZh",
};
}
return {
pass1: "pass1PromptNoNewWithExistingZh",
pass1Strict: "pass1StrictNoNewWithExistingZh",
pass2: "pass2PromptWithExistingZh",
};
}
function getPromptFields(language: PromptLanguage): PromptFieldKey[] {
return language === "zh" ? ZH_PROMPT_FIELDS : EN_PROMPT_FIELDS;
}
function readPrompt(config: LLMConfig, key: PromptFieldKey, fallback: string): string {
const value = config[key];
if (typeof value === "string" && value.trim()) {
return value;
}
return fallback;
}

26
src/app/core/queues.ts Normal file
View File

@@ -0,0 +1,26 @@
export type QueueTask<T> = () => Promise<T>;
export async function runWithConcurrency<T>(tasks: QueueTask<T>[], concurrency: number): Promise<T[]> {
const results: T[] = [];
const executing = new Set<Promise<void>>();
let index = 0;
const runNext = async () => {
const current = index;
index += 1;
if (current >= tasks.length) return;
const value = await tasks[current]();
results[current] = value;
await runNext();
};
while (index < tasks.length && executing.size < concurrency) {
const runner = runNext().then(() => {
executing.delete(runner);
});
executing.add(runner);
}
await Promise.all(executing);
return results;
}

View File

@@ -0,0 +1,11 @@
import { db } from "../data/db";
export async function setRepoListMembership(repoId: string, listIds: string[]): Promise<void> {
const uniqueListIds = Array.from(new Set(listIds.map((id) => id.trim()).filter(Boolean)));
await db.transaction("rw", db.repoLists, async () => {
await db.repoLists.put({
repoId,
listIds: uniqueListIds,
});
});
}

23
src/app/core/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export type RepoInfo = {
id: string;
fullName: string;
description: string;
url: string;
topics: string[];
language: string | null;
updatedAt: string;
readmeExcerpt: string;
};
export type StarList = {
id: string;
name: string;
description: string;
isPrivate: boolean;
};
export type ClassificationResult = {
repoId: string;
listName: string;
confidence: number;
};

109
src/app/data/db.ts Normal file
View File

@@ -0,0 +1,109 @@
import Dexie, { type Table } from "dexie";
export type RepoRecord = {
id: string;
fullName: string;
description: string;
repoUrl: string;
topics: string[];
language: string | null;
updatedAt: string;
readmeExcerpt: string;
stargazerCount: number;
};
export type ClassificationRecord = {
repoId: string;
tags: string[];
lastRunAt: string;
};
export type ClassificationRunRecord = {
id: string;
createdAt: string;
repoCount: number;
strictSingleTag: boolean;
testMode: boolean;
pass1Prompt: string;
pass1StrictPrompt: string;
pass2Prompt: string;
};
export type ClassificationTagRecord = {
id: string;
runId: string;
repoId: string;
tags: string[];
};
export type TagCompressionRecord = {
tag: string;
compressedTag: string;
};
export type ListRecord = {
id: string;
name: string;
description: string;
isPrivate: boolean;
};
export type RepoListRecord = {
repoId: string;
listIds: string[];
};
export type JobRecord = {
id: string;
type: string;
status: "idle" | "running" | "failed" | "completed";
progress: number;
total: number;
message: string;
updatedAt: string;
};
export type CacheRecord = {
key: string;
etag: string | null;
lastFetchedAt: string;
hash: string;
};
export class StarManagerDB extends Dexie {
repos!: Table<RepoRecord, string>;
lists!: Table<ListRecord, string>;
repoLists!: Table<RepoListRecord, string>;
classifications!: Table<ClassificationRecord, string>;
classificationRuns!: Table<ClassificationRunRecord, string>;
classificationTags!: Table<ClassificationTagRecord, string>;
tagCompression!: Table<TagCompressionRecord, string>;
jobs!: Table<JobRecord, string>;
cache!: Table<CacheRecord, string>;
constructor() {
super("star-manager");
this.version(1).stores({
repos: "id, fullName",
lists: "id, name",
repoLists: "repoId",
classifications: "repoId",
tagCompression: "tag",
jobs: "id, type, status",
cache: "key",
});
this.version(2).stores({
repos: "id, fullName",
lists: "id, name",
repoLists: "repoId",
classifications: "repoId",
classificationRuns: "id, createdAt",
classificationTags: "id, runId, repoId",
tagCompression: "tag",
jobs: "id, type, status",
cache: "key",
});
}
}
export const db = new StarManagerDB();

View File

@@ -0,0 +1,25 @@
import { liveQuery } from "dexie";
import { useEffect, useState } from "react";
export function useLiveQuery<T>(
querier: () => Promise<T> | T,
deps: unknown[],
defaultValue: T
): T {
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => {
const subscription = liveQuery(querier).subscribe({
next: (result) => setValue(result),
error: (error) => {
console.error("Dexie liveQuery error", error);
},
});
return () => {
subscription.unsubscribe();
};
}, deps);
return value;
}

18
src/app/i18n/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { detectBrowserLanguage } from "./language";
import { resources } from "./resources";
if (!i18n.isInitialized) {
void i18n.use(initReactI18next).init({
resources,
lng: detectBrowserLanguage(),
fallbackLng: "en",
supportedLngs: ["en", "zh-CN"],
interpolation: {
escapeValue: false,
},
});
}
export { i18n };

24
src/app/i18n/language.ts Normal file
View File

@@ -0,0 +1,24 @@
export type AppLanguage = "en" | "zh-CN";
export type UiLanguagePreference = "auto" | AppLanguage;
export type PromptLanguage = "en" | "zh";
export function normalizeAppLanguage(language?: string | null): AppLanguage {
if (!language) return "en";
return language.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
}
export function detectBrowserLanguage(): AppLanguage {
if (typeof navigator === "undefined") return "en";
return normalizeAppLanguage(navigator.language);
}
export function resolveEffectiveLanguage(
preference: UiLanguagePreference,
browserLanguage: AppLanguage
): AppLanguage {
return preference === "auto" ? browserLanguage : preference;
}
export function toPromptLanguage(language: AppLanguage): PromptLanguage {
return language === "zh-CN" ? "zh" : "en";
}

701
src/app/i18n/resources.ts Normal file
View File

@@ -0,0 +1,701 @@
export const resources = {
en: {
translation: {
common: {
appName: "Star Manager",
actions: {
cancel: "Cancel",
clear: "Clear",
close: "Close",
save: "Save",
settings: "Settings",
},
labels: {
error: "Error",
failed: "Failed",
language: "Language",
lastSync: "Last sync",
progress: "Progress",
search: "Search",
stage: "Stage",
status: "Status",
unknown: "Unknown",
},
values: {
all: "All",
allStarred: "All Starred",
none: "(none)",
unclassified: "Unclassified",
},
},
app: {
subtitle: "Organize GitHub Star Lists with confidence.",
actions: {
applyUpdates: "Apply Updates",
assignList: "Assign List",
configureLlm: "Configure LLM",
connectPat: "Connect PAT",
retryFailedLists: "Retry failed lists ({{count}})",
runClassification: "Run Classification",
syncStarLists: "Sync Star Lists",
},
sections: {
batchActions: "Batch Actions",
llmClassification: "LLM Classification",
repositories: "Repositories",
starLists: "Star Lists",
syncStatus: "Sync Status",
},
sync: {
detail: {
connectPat: "Connect PAT to start importing your starred repos.",
preparing: "Preparing sync...",
progress: "{{stage}} ({{current}}/{{total}})",
loaded: "Loaded {{repos}} repos across {{lists}} lists.",
retryRecovered: "All failed lists recovered.",
retryFinished: "Retry finished. {{count}} lists still failed.",
tokenOk: "Token OK. Logged in as {{login}}.",
syncFailed: "Sync failed",
retryFailed: "Retry failed",
tokenValidationFailed: "Token validation failed",
},
status: {
completed: "Completed",
failed: "Failed",
idle: "Idle",
ready: "Ready",
retrying: "Retrying",
running: "Running",
},
},
filters: {
noList: "No list",
searchPlaceholder: "Search name or description",
updatedLastSixMonths: "Updated last 6 months",
},
empty: {
noListsTitle: "No Star Lists loaded yet.",
noListsHint: "Run a sync to import your existing lists.",
noDataTitle: "No data yet.",
noDataHint: "Connect your PAT and run a sync to load your starred repos.",
noFilteredTitle: "No repositories match the current filters.",
noFilteredHint: "Try clearing filters or sync your Star Lists first.",
},
sidebar: {
llmDesc: "Configure your OpenAI-compatible endpoint to auto-label repos.",
batchDesc: "Apply optimized Star Lists back to GitHub with one click.",
},
values: {
noDescription: "No description",
},
},
onboarding: {
title: "README Fetching",
description1:
"We can fetch README snippets to improve classification quality. This may increase GitHub API usage and takes longer to sync.",
description2:
"Would you like to enable README fetching now? You can change this later in Settings.",
skip: "Skip for now",
enable: "Enable README fetch",
},
patModal: {
title: "Connect GitHub PAT",
description:
"We store your token locally to access your Star Lists. Recommended scopes: repo, read:user, read:star.",
tokenLabel: "Personal access token",
savePat: "Save PAT",
},
settings: {
closeAria: "Close settings",
title: "Settings",
description: "Manage tokens, LLM configuration, README fetching, and local cache.",
sections: {
language: "Language",
localCache: "Local Cache",
llmConfiguration: "LLM Configuration",
patManagement: "PAT Management",
readmeFetching: "README Fetching",
writeback: "Writeback",
},
language: {
appLanguage: "App language",
auto: "Auto (Browser)",
english: "English",
chineseSimplified: "Chinese (Simplified)",
promptLanguage: "Prompt language: {{language}}. Prompts follow the app language.",
promptLanguageEnglish: "English",
promptLanguageChinese: "中文",
},
pat: {
githubPat: "GitHub PAT",
githubLoginOptional: "GitHub Login (optional)",
savePat: "Save PAT",
clearPat: "Clear PAT",
required: "PAT is required.",
validating: "Validating PAT...",
validated: "PAT validated. Logged in as {{login}}.",
validationFailed: "PAT validation failed ({{name}}): {{message}}",
cleared: "PAT cleared.",
},
llm: {
useExistingLists: "Use existing Star Lists as context",
allowNewTags: "Allow new tags when using existing lists",
currentMode: "Current mode",
modeBase: "Base mode: classify without existing Star Lists context.",
modeExisting:
"Existing lists mode: prefer existing lists and allow new tags.",
modeExistingOnly:
"Existing-only mode: classify with existing lists only, no new tags.",
baseUrl: "Base URL",
apiKey: "API Key",
model: "Model",
temperature: "Temperature",
maxTokens: "Max Tokens",
activePass1: "Pass 1 System Prompt (active mode)",
activePass1Strict: "Pass 1 Strict System Prompt (active mode)",
activePass2: "Pass 2 System Prompt (active mode)",
advancedSettings: "Advanced prompt settings ({{count}})",
advancedHelp:
"Edit prompts for non-active modes. These values are kept but hidden from the main view.",
testLlm: "Test LLM",
testing: "Testing...",
resetLlm: "Reset LLM",
missingBaseUrl: "Missing base URL.",
missingApiKey: "Missing API key.",
testOk: "OK in {{elapsed}}ms · {{response}}",
testError: "Error ({{name}}) in {{elapsed}}ms: {{message}}",
cleared: "LLM config cleared.",
promptLabels: {
pass1Prompt: "Pass 1 Base Prompt",
pass1StrictPrompt: "Pass 1 Strict Base Prompt",
pass2Prompt: "Pass 2 Base Prompt",
pass1PromptWithExisting: "Pass 1 Existing Context Prompt",
pass1PromptNoNewWithExisting: "Pass 1 Existing-Only Prompt",
pass1StrictPromptWithExisting:
"Pass 1 Strict Existing Context Prompt",
pass1StrictNoNewWithExisting: "Pass 1 Strict Existing-Only Prompt",
pass2PromptWithExisting: "Pass 2 Existing Context Prompt",
pass1PromptZh: "Pass 1 Base Prompt (ZH)",
pass1StrictPromptZh: "Pass 1 Strict Base Prompt (ZH)",
pass2PromptZh: "Pass 2 Base Prompt (ZH)",
pass1PromptWithExistingZh: "Pass 1 Existing Context Prompt (ZH)",
pass1PromptNoNewWithExistingZh:
"Pass 1 Existing-Only Prompt (ZH)",
pass1StrictPromptWithExistingZh:
"Pass 1 Strict Existing Context Prompt (ZH)",
pass1StrictNoNewWithExistingZh:
"Pass 1 Strict Existing-Only Prompt (ZH)",
pass2PromptWithExistingZh: "Pass 2 Existing Context Prompt (ZH)",
},
},
readme: {
enable: "Enable README snippets during sync",
},
writeback: {
refreshBeforeApply: "Refresh GitHub data before apply",
refreshHelp:
"When enabled, Apply Updates recalculates the writeback plan using latest GitHub state.",
},
cache: {
help: "Clear IndexedDB data to reset your local cache.",
clearButton: "Clear Local Cache",
cleared: "Local cache cleared. Reloading...",
},
},
classification: {
closeAria: "Close classification",
title: "LLM Classification",
description:
"Run a two-stage classification. Stage 2 only compresses tags to save tokens.",
sections: {
diffView: "Diff View",
preview: "Preview",
runStatus: "Run Status",
},
actions: {
run: "Run Classification",
running: "Running...",
},
labels: {
compareAgainst: "Compare against",
current: "Current",
existing: "Existing",
previous: "Previous",
selectRun: "Select run",
},
options: {
existingStarLists: "Existing Star Lists",
previousRun: "Previous run",
selectRunPlaceholder: "Select a run",
reposSuffix: "repos",
testSuffix: "test",
singleSuffix: "single",
},
toggles: {
strictSingleTag: "Strict single tag (exactly 1 per repo)",
testMode: "Test mode (first 5 repos)",
},
status: {
noResults: "No classification results yet.",
noSavedRuns: "No saved runs yet.",
noTagDiff: "No tag differences found.",
noDiffFromExisting: "No differences from existing lists.",
},
errors: {
missingConfig: "LLM config is missing.",
noRepos: "No repos available. Sync your Star Lists first.",
classificationFailed: "Classification failed",
},
stage: {
idle: "Idle",
preparing: "Preparing",
classifying_repos: "Classifying repos",
compressing_tags: "Compressing tags",
completed: "Completed",
failed: "Failed",
},
},
assignList: {
closeAria: "Close assign list",
title: "Assign List",
description: "Choose local Star Lists for {{repoName}}.",
sections: {
lists: "Lists",
},
status: {
noLists: "No lists available yet. Run sync first.",
saved: "Saved.",
saveFailed: "Save failed.",
saving: "Saving...",
},
},
apply: {
closeAria: "Close apply updates",
title: "Apply Updates",
description: "Write current classification results back to GitHub Star Lists.",
sections: {
applyResult: "Apply Result",
issues: "Issues",
planSummary: "Plan Summary",
plannedRepoChanges: "Planned Repo Changes",
progress: "Progress",
runControls: "Run Controls",
},
controls: {
refreshBeforeApply: "Refresh GitHub data before apply",
refreshOn:
"Apply will re-plan against latest GitHub state.",
refreshOff:
"Apply will use the current preview plan without re-planning.",
applyToGithub: "Apply to GitHub",
applying: "Applying...",
},
labels: {
actionable: "Actionable",
after: "After",
applied: "Applied",
createdLists: "Created Lists",
current: "Current",
failed: "Failed",
locallyUpdated: "Local updated",
missingLists: "Missing Lists",
skipped: "Skipped",
unchanged: "Unchanged",
unstarred: "Unstarred",
validTagged: "Valid Tagged",
},
status: {
noChangesDetected: "No repo list changes detected.",
scanFailures:
"Membership scan partial failures: {{count}}. Preview may be partially incomplete.",
},
errors: {
missingPat: "GitHub PAT is missing. Please connect PAT first.",
previewNotReady:
"Preview is not ready yet. Please wait for plan generation to finish.",
previewFailed: "Preview failed.",
applyFailed: "Apply failed.",
},
confirmUnstarred:
"Found {{count}} unstarred repos that require list updates.\n\nClick OK to auto-star and continue.\nClick Cancel to skip those repos and continue.",
stage: {
idle: "Idle",
preparing_preview: "Preparing preview",
preparing_writeback: "Preparing writeback",
loading_local_classifications: "Loading local classifications",
fetching_star_lists: "Fetching Star Lists",
creating_missing_lists: "Creating missing lists",
resolving_repositories: "Resolving repositories",
scanning_existing_memberships: "Scanning existing memberships",
applying_updates: "Applying updates",
},
issueReasons: {
empty_tags: "Empty tags",
repo_not_found: "Repo not found",
invalid_repo_name: "Invalid repo name",
repo_meta_missing: "Repo metadata missing",
repo_meta_fetch_failed: "Repo metadata fetch failed",
missing_list: "Missing list",
create_list_failed: "Create list failed",
membership_scan_failed: "Membership scan failed",
no_changes: "No changes",
unstarred_requires_confirmation: "Unstarred requires confirmation",
add_star_failed: "Add star failed",
update_failed: "Update failed",
},
},
progress: {
sync: {
idle: "Idle",
fetching_star_lists: "Fetching Star Lists",
fetching_starred_repos: "Fetching Starred Repos",
scanning_list_membership: "Scanning List Membership",
retrying_list_membership: "Retrying List Membership",
},
},
},
},
"zh-CN": {
translation: {
common: {
appName: "Star Manager",
actions: {
cancel: "取消",
clear: "清空",
close: "关闭",
save: "保存",
settings: "设置",
},
labels: {
error: "错误",
failed: "失败",
language: "语言",
lastSync: "上次同步",
progress: "进度",
search: "搜索",
stage: "阶段",
status: "状态",
unknown: "未知",
},
values: {
all: "全部",
allStarred: "全部收藏",
none: "(无)",
unclassified: "未分类",
},
},
app: {
subtitle: "更安心地管理 GitHub Star Lists。",
actions: {
applyUpdates: "应用更新",
assignList: "分配列表",
configureLlm: "配置 LLM",
connectPat: "连接 PAT",
retryFailedLists: "重试失败列表({{count}}",
runClassification: "运行分类",
syncStarLists: "同步 Star Lists",
},
sections: {
batchActions: "批量操作",
llmClassification: "LLM 分类",
repositories: "仓库",
starLists: "Star 列表",
syncStatus: "同步状态",
},
sync: {
detail: {
connectPat: "请先连接 PAT再导入你收藏的仓库。",
preparing: "正在准备同步...",
progress: "{{stage}}{{current}}/{{total}}",
loaded: "已加载 {{repos}} 个仓库,覆盖 {{lists}} 个列表。",
retryRecovered: "所有失败列表均已恢复。",
retryFinished: "重试完成,仍有 {{count}} 个列表失败。",
tokenOk: "Token 校验通过,当前登录为 {{login}}。",
syncFailed: "同步失败",
retryFailed: "重试失败",
tokenValidationFailed: "Token 校验失败",
},
status: {
completed: "已完成",
failed: "失败",
idle: "空闲",
ready: "就绪",
retrying: "重试中",
running: "运行中",
},
},
filters: {
noList: "无列表",
searchPlaceholder: "搜索名称或描述",
updatedLastSixMonths: "近 6 个月有更新",
},
empty: {
noListsTitle: "尚未加载 Star 列表。",
noListsHint: "执行一次同步即可导入你已有的列表。",
noDataTitle: "暂无数据。",
noDataHint: "连接 PAT 并执行同步后即可加载已收藏仓库。",
noFilteredTitle: "当前筛选条件下没有匹配仓库。",
noFilteredHint: "尝试清空筛选,或先同步 Star 列表。",
},
sidebar: {
llmDesc: "配置兼容 OpenAI 的接口,自动为仓库打标签。",
batchDesc: "一键将优化后的 Star Lists 写回 GitHub。",
},
values: {
noDescription: "暂无描述",
},
},
onboarding: {
title: "README 抓取",
description1:
"我们可以抓取 README 片段来提升分类质量,但会增加 GitHub API 调用并延长同步时间。",
description2: "是否现在启用 README 抓取?你也可以稍后在设置中修改。",
skip: "暂不启用",
enable: "启用 README 抓取",
},
patModal: {
title: "连接 GitHub PAT",
description:
"Token 仅保存在本地,用于访问你的 Star Lists。建议权限repo、read:user、read:star。",
tokenLabel: "个人访问令牌",
savePat: "保存 PAT",
},
settings: {
closeAria: "关闭设置",
title: "设置",
description: "管理 Token、LLM 配置、README 抓取和本地缓存。",
sections: {
language: "语言",
localCache: "本地缓存",
llmConfiguration: "LLM 配置",
patManagement: "PAT 管理",
readmeFetching: "README 抓取",
writeback: "写回",
},
language: {
appLanguage: "界面语言",
auto: "自动(浏览器)",
english: "English",
chineseSimplified: "简体中文",
promptLanguage: "Prompt 语言:{{language}}。Prompt 会跟随界面语言。",
promptLanguageEnglish: "English",
promptLanguageChinese: "中文",
},
pat: {
githubPat: "GitHub PAT",
githubLoginOptional: "GitHub 登录名(可选)",
savePat: "保存 PAT",
clearPat: "清除 PAT",
required: "PAT 不能为空。",
validating: "正在校验 PAT...",
validated: "PAT 校验通过,当前登录为 {{login}}。",
validationFailed: "PAT 校验失败({{name}}{{message}}",
cleared: "PAT 已清除。",
},
llm: {
useExistingLists: "使用已有 Star Lists 作为上下文",
allowNewTags: "使用已有列表时允许新增标签",
currentMode: "当前模式",
modeBase: "基础模式:不参考已有 Star Lists 进行分类。",
modeExisting: "已有列表模式:优先使用已有列表,并允许新增标签。",
modeExistingOnly: "仅已有列表模式:仅使用已有列表,不新增标签。",
baseUrl: "Base URL",
apiKey: "API Key",
model: "模型",
temperature: "温度",
maxTokens: "最大 Tokens",
activePass1: "Pass 1 系统 Prompt当前模式",
activePass1Strict: "Pass 1 严格系统 Prompt当前模式",
activePass2: "Pass 2 系统 Prompt当前模式",
advancedSettings: "高级 Prompt 设置({{count}}",
advancedHelp: "可编辑非当前模式的 Prompt这些值会保留但不在主视图展示。",
testLlm: "测试 LLM",
testing: "测试中...",
resetLlm: "重置 LLM",
missingBaseUrl: "缺少 Base URL。",
missingApiKey: "缺少 API Key。",
testOk: "{{elapsed}}ms 内成功 · {{response}}",
testError: "错误({{name}}),耗时 {{elapsed}}ms{{message}}",
cleared: "LLM 配置已清除。",
promptLabels: {
pass1Prompt: "Pass 1 基础 Prompt",
pass1StrictPrompt: "Pass 1 严格基础 Prompt",
pass2Prompt: "Pass 2 基础 Prompt",
pass1PromptWithExisting: "Pass 1 含已有上下文 Prompt",
pass1PromptNoNewWithExisting: "Pass 1 仅已有列表 Prompt",
pass1StrictPromptWithExisting: "Pass 1 严格含已有上下文 Prompt",
pass1StrictNoNewWithExisting: "Pass 1 严格仅已有列表 Prompt",
pass2PromptWithExisting: "Pass 2 含已有上下文 Prompt",
pass1PromptZh: "Pass 1 基础 Prompt中文",
pass1StrictPromptZh: "Pass 1 严格基础 Prompt中文",
pass2PromptZh: "Pass 2 基础 Prompt中文",
pass1PromptWithExistingZh: "Pass 1 含已有上下文 Prompt中文",
pass1PromptNoNewWithExistingZh: "Pass 1 仅已有列表 Prompt中文",
pass1StrictPromptWithExistingZh:
"Pass 1 严格含已有上下文 Prompt中文",
pass1StrictNoNewWithExistingZh: "Pass 1 严格仅已有列表 Prompt中文",
pass2PromptWithExistingZh: "Pass 2 含已有上下文 Prompt中文",
},
},
readme: {
enable: "同步时启用 README 片段",
},
writeback: {
refreshBeforeApply: "应用前刷新 GitHub 数据",
refreshHelp: "启用后Apply Updates 会基于最新 GitHub 状态重新生成写回计划。",
},
cache: {
help: "清空 IndexedDB 数据以重置本地缓存。",
clearButton: "清空本地缓存",
cleared: "本地缓存已清空,正在刷新...",
},
},
classification: {
closeAria: "关闭分类",
title: "LLM 分类",
description: "执行两阶段分类。第 2 阶段仅压缩标签以节省 token。",
sections: {
diffView: "差异视图",
preview: "预览",
runStatus: "运行状态",
},
actions: {
run: "运行分类",
running: "运行中...",
},
labels: {
compareAgainst: "对比基准",
current: "当前",
existing: "已有",
previous: "上次",
selectRun: "选择运行记录",
},
options: {
existingStarLists: "已有 Star Lists",
previousRun: "历史运行",
selectRunPlaceholder: "选择一条运行记录",
reposSuffix: "个仓库",
testSuffix: "测试",
singleSuffix: "单标签",
},
toggles: {
strictSingleTag: "严格单标签(每仓库仅 1 个)",
testMode: "测试模式(仅前 5 个仓库)",
},
status: {
noResults: "暂无分类结果。",
noSavedRuns: "暂无保存的运行记录。",
noTagDiff: "未发现标签差异。",
noDiffFromExisting: "与已有列表无差异。",
},
errors: {
missingConfig: "LLM 配置不完整。",
noRepos: "没有可分类仓库,请先同步 Star Lists。",
classificationFailed: "分类失败",
},
stage: {
idle: "空闲",
preparing: "准备中",
classifying_repos: "正在分类仓库",
compressing_tags: "正在压缩标签",
completed: "已完成",
failed: "失败",
},
},
assignList: {
closeAria: "关闭分配列表",
title: "分配列表",
description: "为 {{repoName}} 选择本地 Star 列表。",
sections: {
lists: "列表",
},
status: {
noLists: "暂无列表,请先同步。",
saved: "已保存。",
saveFailed: "保存失败。",
saving: "保存中...",
},
},
apply: {
closeAria: "关闭应用更新",
title: "应用更新",
description: "将当前分类结果写回到 GitHub Star Lists。",
sections: {
applyResult: "应用结果",
issues: "问题",
planSummary: "计划摘要",
plannedRepoChanges: "计划中的仓库变更",
progress: "进度",
runControls: "运行控制",
},
controls: {
refreshBeforeApply: "应用前刷新 GitHub 数据",
refreshOn: "应用时会基于最新 GitHub 状态重新规划。",
refreshOff: "应用时会直接使用当前预览计划,不再重新规划。",
applyToGithub: "应用到 GitHub",
applying: "应用中...",
},
labels: {
actionable: "可执行",
after: "变更后",
applied: "已应用",
createdLists: "新建列表",
current: "当前",
failed: "失败",
locallyUpdated: "本地已更新",
missingLists: "缺失列表",
skipped: "跳过",
unchanged: "未变化",
unstarred: "未收藏",
validTagged: "有效标签仓库",
},
status: {
noChangesDetected: "未检测到仓库列表变更。",
scanFailures: "列表成员扫描部分失败:{{count}}。预览可能不完整。",
},
errors: {
missingPat: "缺少 GitHub PAT请先连接 PAT。",
previewNotReady: "预览尚未就绪,请等待计划生成完成。",
previewFailed: "预览失败。",
applyFailed: "应用失败。",
},
confirmUnstarred:
"发现 {{count}} 个未收藏仓库需要更新列表。\n\n点击“确定”将自动收藏并继续。\n点击“取消”将跳过这些仓库并继续。",
stage: {
idle: "空闲",
preparing_preview: "正在准备预览",
preparing_writeback: "正在准备写回",
loading_local_classifications: "正在加载本地分类",
fetching_star_lists: "正在拉取 Star 列表",
creating_missing_lists: "正在创建缺失列表",
resolving_repositories: "正在解析仓库",
scanning_existing_memberships: "正在扫描已有成员关系",
applying_updates: "正在应用更新",
},
issueReasons: {
empty_tags: "标签为空",
repo_not_found: "仓库不存在",
invalid_repo_name: "仓库名格式无效",
repo_meta_missing: "仓库元数据缺失",
repo_meta_fetch_failed: "获取仓库元数据失败",
missing_list: "目标列表缺失",
create_list_failed: "创建列表失败",
membership_scan_failed: "成员扫描失败",
no_changes: "无变更",
unstarred_requires_confirmation: "未收藏需确认",
add_star_failed: "自动收藏失败",
update_failed: "更新失败",
},
},
progress: {
sync: {
idle: "空闲",
fetching_star_lists: "正在拉取 Star 列表",
fetching_starred_repos: "正在拉取已收藏仓库",
scanning_list_membership: "正在扫描列表成员关系",
retrying_list_membership: "正在重试列表成员关系",
},
},
},
},
} as const;

View File

@@ -0,0 +1,22 @@
import { ghGraphql } from "./githubClient";
export type ViewerProfile = {
login: string;
name: string | null;
avatarUrl: string;
};
export async function validatePat(token: string): Promise<ViewerProfile> {
const query = `
query {
viewer {
login
name
avatarUrl
}
}
`;
const data = await ghGraphql<{ viewer: ViewerProfile }>({ token }, query);
return data.viewer;
}

View File

@@ -0,0 +1,85 @@
export type GitHubConfig = {
token: string;
};
export type GitHubRequestOptions = {
signal?: AbortSignal;
retries?: number;
};
const API_URL = "https://api.github.com";
const GRAPHQL_URL = "https://api.github.com/graphql";
async function requestJson<T>(
url: string,
init: RequestInit,
options: GitHubRequestOptions
): Promise<T> {
const retries = options.retries ?? 2;
let attempt = 0;
while (true) {
try {
const response = await fetch(url, init);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed: ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
if (attempt >= retries) {
throw error;
}
attempt += 1;
await new Promise((resolve) => setTimeout(resolve, 800 * attempt));
}
}
}
export async function ghGraphql<T>(
config: GitHubConfig,
query: string,
variables: Record<string, unknown> = {},
options: GitHubRequestOptions = {}
): Promise<T> {
const body = JSON.stringify({ query, variables });
const response = await requestJson<{ data?: T; errors?: { message: string }[] }>(
GRAPHQL_URL,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/vnd.github+json",
Authorization: `bearer ${config.token}`,
},
body,
signal: options.signal,
},
options
);
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message || "GitHub GraphQL error");
}
if (!response.data) {
throw new Error("GitHub GraphQL response missing data");
}
return response.data;
}
export async function ghRest<T>(
config: GitHubConfig,
path: string,
options: GitHubRequestOptions = {}
): Promise<T> {
return requestJson<T>(
`${API_URL}${path}`,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `bearer ${config.token}`,
},
signal: options.signal,
},
options
);
}

View File

@@ -0,0 +1,391 @@
import { ghGraphql, ghRest, type GitHubConfig } from "./githubClient";
export type StarList = {
id: string;
name: string;
description: string;
};
export type StarredRepo = {
id: string;
name: string;
fullName: string;
description: string | null;
url: string;
topics: string[];
language: string | null;
updatedAt: string;
stargazerCount: number;
};
export type StarListMembership = {
listId: string;
repoIds: string[];
};
export type UserListRef = {
id: string;
name: string;
};
export type RepositoryMeta = {
id: string;
viewerHasStarred: boolean;
};
export type RepoMembershipIndexProgress = {
current: number;
total: number;
};
export type RepoMembershipIndexResult = {
index: Map<string, Set<string>>;
failedListIds: string[];
};
type ListApiField = "lists" | "userLists";
type GraphqlPageInfo = {
hasNextPage: boolean;
endCursor: string | null;
};
type StarListConnection = {
nodes: StarList[];
pageInfo: GraphqlPageInfo;
};
type FetchStarListsResponse = {
viewer: Record<ListApiField, StarListConnection>;
};
type StarredRepoNode = {
id: string;
name: string;
nameWithOwner: string;
description: string | null;
url: string;
repositoryTopics: { nodes: { topic: { name: string } }[] };
primaryLanguage: { name: string } | null;
updatedAt: string;
stargazerCount: number;
};
type FetchStarredReposResponse = {
viewer: {
starredRepositories: {
nodes: StarredRepoNode[];
pageInfo: GraphqlPageInfo;
};
};
};
type FetchListMembershipResponse = {
node: {
items: {
nodes: { id: string }[];
pageInfo: GraphqlPageInfo;
};
} | null;
};
type CreateUserListResponse = {
createUserList: {
list: UserListRef | null;
} | null;
};
type RepositoryMetaResponse = {
repository: RepositoryMeta | null;
};
export async function detectStarListApi(config: GitHubConfig): Promise<ListApiField> {
const candidates: ListApiField[] = ["lists", "userLists"];
for (const field of candidates) {
try {
const query = `query { viewer { ${field}(first: 1) { totalCount } } }`;
await ghGraphql(config, query);
return field;
} catch {
continue;
}
}
throw new Error("Star Lists API not available for this token/account");
}
export async function fetchStarLists(
config: GitHubConfig,
field: ListApiField
): Promise<StarList[]> {
const lists: StarList[] = [];
let after: string | null = null;
while (true) {
const query = `
query ($first: Int!, $after: String) {
viewer {
${field}(first: $first, after: $after) {
nodes {
id
name
description
}
pageInfo { hasNextPage endCursor }
}
}
}
`;
const data: FetchStarListsResponse = await ghGraphql(
config,
query,
{ first: 100, after }
);
const connection: StarListConnection = data.viewer[field];
lists.push(...(connection.nodes ?? []));
if (!connection.pageInfo.hasNextPage) break;
after = connection.pageInfo.endCursor;
}
return lists;
}
export async function fetchStarredRepos(config: GitHubConfig): Promise<StarredRepo[]> {
const repos: StarredRepo[] = [];
let after: string | null = null;
while (true) {
const query = `
query ($first: Int!, $after: String) {
viewer {
starredRepositories(first: $first, after: $after, orderBy: { field: STARRED_AT, direction: DESC }) {
nodes {
id
name
nameWithOwner
description
url
repositoryTopics(first: 10) {
nodes { topic { name } }
}
primaryLanguage { name }
updatedAt
stargazerCount
}
pageInfo { hasNextPage endCursor }
}
}
}
`;
const data: FetchStarredReposResponse = await ghGraphql(config, query, { first: 100, after });
const connection: FetchStarredReposResponse["viewer"]["starredRepositories"] =
data.viewer.starredRepositories;
repos.push(
...connection.nodes.map((node: StarredRepoNode) => ({
id: node.id,
name: node.name,
fullName: node.nameWithOwner,
description: node.description,
url: node.url,
topics: node.repositoryTopics.nodes.map(
(topicNode: { topic: { name: string } }) => topicNode.topic.name
),
language: node.primaryLanguage?.name ?? null,
updatedAt: node.updatedAt,
stargazerCount: node.stargazerCount,
}))
);
if (!connection.pageInfo.hasNextPage) break;
after = connection.pageInfo.endCursor;
}
return repos;
}
export async function fetchReadmeExcerpt(
config: GitHubConfig,
owner: string,
repo: string
): Promise<string> {
const data = await ghRest<{ content?: string; encoding?: string }>(
config,
`/repos/${owner}/${repo}/readme`
);
const content = data.content ?? "";
const encoding = data.encoding ?? "base64";
if (encoding !== "base64") {
return "";
}
const decoded = atob(content.replace(/\n/g, ""));
return decoded.slice(0, 3000);
}
export async function fetchListMembership(
config: GitHubConfig,
listId: string
): Promise<StarListMembership> {
const repoIds: string[] = [];
let after: string | null = null;
while (true) {
const query = `
query ($id: ID!, $first: Int!, $after: String) {
node(id: $id) {
... on UserList {
items(first: $first, after: $after) {
nodes { ... on Repository { id } }
pageInfo { hasNextPage endCursor }
}
}
}
}
`;
const data: FetchListMembershipResponse = await ghGraphql(config, query, {
id: listId,
first: 100,
after,
});
if (!data.node?.items) break;
repoIds.push(...data.node.items.nodes.map((node: { id: string }) => node.id));
if (!data.node.items.pageInfo.hasNextPage) break;
after = data.node.items.pageInfo.endCursor;
}
return { listId, repoIds };
}
export async function createUserList(
config: GitHubConfig,
name: string,
isPrivate: boolean,
description = ""
): Promise<UserListRef> {
const query = `
mutation($input: CreateUserListInput!) {
createUserList(input: $input) {
list { id name }
}
}
`;
const input: { name: string; isPrivate: boolean; description?: string } = { name, isPrivate };
if (description.trim()) {
input.description = description.trim();
}
const data: CreateUserListResponse = await ghGraphql(config, query, { input });
const created = data.createUserList?.list;
if (!created) {
throw new Error(`Failed to create list: ${name}`);
}
return created;
}
export async function getRepositoryMeta(
config: GitHubConfig,
owner: string,
name: string
): Promise<RepositoryMeta | null> {
const query = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
viewerHasStarred
}
}
`;
try {
const data: RepositoryMetaResponse = await ghGraphql(config, query, { owner, name });
return data.repository;
} catch (error) {
const message = (error as Error).message || "";
if (message.includes("Could not resolve to a Repository")) {
return null;
}
throw error;
}
}
export async function addStar(config: GitHubConfig, starrableId: string): Promise<void> {
const query = `
mutation($input: AddStarInput!) {
addStar(input: $input) {
starrable { id }
}
}
`;
await ghGraphql(config, query, { input: { starrableId } });
}
export async function updateUserListsForItem(
config: GitHubConfig,
itemId: string,
listIds: string[]
): Promise<void> {
const query = `
mutation($input: UpdateUserListsForItemInput!) {
updateUserListsForItem(input: $input) { clientMutationId }
}
`;
await ghGraphql(config, query, { input: { itemId, listIds } });
}
export async function buildRepoMembershipIndex(
config: GitHubConfig,
listIds: string[],
targetRepoIds: Set<string>,
onProgress?: (progress: RepoMembershipIndexProgress) => void
): Promise<RepoMembershipIndexResult> {
const index = new Map<string, Set<string>>();
const failedListIds: string[] = [];
const total = listIds.length;
let current = 0;
for (const listId of listIds) {
try {
let after: string | null = null;
while (true) {
const query = `
query($id: ID!, $first: Int!, $after: String) {
node(id: $id) {
... on UserList {
items(first: $first, after: $after) {
nodes { ... on Repository { id } }
pageInfo { hasNextPage endCursor }
}
}
}
}
`;
const data: FetchListMembershipResponse = await ghGraphql(config, query, {
id: listId,
first: 100,
after,
});
const items = data.node?.items;
if (!items) break;
for (const node of items.nodes) {
if (!targetRepoIds.has(node.id)) continue;
const existing = index.get(node.id) ?? new Set<string>();
existing.add(listId);
index.set(node.id, existing);
}
if (!items.pageInfo.hasNextPage) break;
after = items.pageInfo.endCursor;
}
} catch {
failedListIds.push(listId);
}
current += 1;
onProgress?.({ current, total });
}
return { index, failedListIds };
}

View File

@@ -0,0 +1,65 @@
export type LLMConfig = {
baseUrl: string;
apiKey: string;
model: string;
temperature: number;
maxTokens: number;
pass1Prompt?: string;
pass1StrictPrompt?: string;
pass2Prompt?: string;
pass1PromptZh?: string;
pass1StrictPromptZh?: string;
pass2PromptZh?: string;
pass1PromptWithExisting?: string;
pass1PromptNoNewWithExisting?: string;
pass1StrictPromptWithExisting?: string;
pass1StrictNoNewWithExisting?: string;
pass1PromptWithExistingZh?: string;
pass1PromptNoNewWithExistingZh?: string;
pass1StrictPromptWithExistingZh?: string;
pass1StrictNoNewWithExistingZh?: string;
pass2PromptWithExisting?: string;
pass2PromptWithExistingZh?: string;
};
export type LLMMessage = {
role: "system" | "user" | "assistant";
content: string;
};
export async function requestCompletion(
config: LLMConfig,
messages: LLMMessage[],
signal?: AbortSignal
): Promise<string> {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
max_tokens: config.maxTokens,
messages,
}),
signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `LLM request failed: ${response.status}`);
}
const data = (await response.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) {
throw new Error("LLM response missing content");
}
return content;
}

111
src/app/store/llmConfig.ts Normal file
View File

@@ -0,0 +1,111 @@
import { useCallback, useSyncExternalStore } from "react";
import type { LLMConfig } from "../services/llmClient";
type LlmConfigState = {
config: LLMConfig;
};
const STORAGE_KEY = "star-manager.llm-config";
const defaultConfig: LLMConfig = {
baseUrl: "https://api.openai.com/v1",
apiKey: "",
model: "gpt-4o-mini",
temperature: 0.3,
maxTokens: 120,
pass1Prompt: "You label GitHub repositories with 1-3 concise tags. Output only comma-separated tags.",
pass1StrictPrompt: "You label GitHub repositories with exactly 1 concise tag. Output only the tag text.",
pass2Prompt:
"You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.",
pass1PromptZh:
"你将 GitHub 仓库标注为 1-3 个简洁标签,仅输出逗号分隔的标签。",
pass1StrictPromptZh: "你将 GitHub 仓库标注为且仅标注 1 个简洁标签,仅输出标签文本。",
pass2PromptZh:
"你需要合并标签中的同义词或近似词,输出仅包含原始标签到压缩标签的 JSON 映射。不要创造列表外的新标签。",
pass1PromptWithExisting:
"You label GitHub repositories with 1-3 concise tags. Prefer existing Star Lists when they fit, but you may add a new tag if needed. Output only comma-separated tags.",
pass1PromptNoNewWithExisting:
"You label GitHub repositories with 1-3 concise tags. You must select from the existing Star Lists only. If none fit, output nothing. Output only comma-separated tags.",
pass1StrictPromptWithExisting:
"You label GitHub repositories with exactly 1 concise tag. Prefer existing Star Lists when they fit. Output only the tag text.",
pass1StrictNoNewWithExisting:
"You must select exactly 1 tag from the existing Star Lists. If none fit, output nothing. Output only the tag text.",
pass1PromptWithExistingZh:
"你将 GitHub 仓库标注为 1-3 个简洁标签,优先使用已有 Star Lists如有必要可新增标签仅输出逗号分隔的标签。",
pass1PromptNoNewWithExistingZh:
"你将 GitHub 仓库标注为 1-3 个简洁标签,只能从已有 Star Lists 中选择。如果没有合适标签,请输出空内容,仅输出逗号分隔的标签。",
pass1StrictPromptWithExistingZh:
"你将 GitHub 仓库标注为且仅标注 1 个简洁标签,优先使用已有 Star Lists仅输出标签文本。",
pass1StrictNoNewWithExistingZh:
"你必须从已有 Star Lists 中选择且仅选择 1 个标签。如果没有合适的标签,请输出空内容,仅输出标签文本。",
pass2PromptWithExisting:
"You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.",
pass2PromptWithExistingZh:
"你需要合并标签中的同义词或近似词,输出仅包含原始标签到压缩标签的 JSON 映射。不要创造列表外的新标签。",
};
let cached = readFromStorage();
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) {
listener();
}
}
function readFromStorage(): LlmConfigState {
if (typeof window === "undefined") {
return { config: { ...defaultConfig } };
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { config: { ...defaultConfig } };
const parsed = JSON.parse(raw) as Partial<LLMConfig>;
return {
config: {
...defaultConfig,
...parsed,
},
};
} catch {
return { config: { ...defaultConfig } };
}
}
function persist(next: LlmConfigState) {
cached = next;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next.config));
}
emitChange();
}
export function useLlmConfigStore() {
const state = useSyncExternalStore(
(onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
},
() => cached,
() => cached
);
const setConfig = useCallback((config: LLMConfig) => {
persist({ config });
}, []);
const updateConfig = useCallback((partial: Partial<LLMConfig>) => {
persist({ config: { ...cached.config, ...partial } });
}, []);
const clearConfig = useCallback(() => {
persist({ config: { ...defaultConfig, apiKey: "" } });
}, []);
return {
config: state.config,
setConfig,
updateConfig,
clearConfig,
};
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useSyncExternalStore } from "react";
import type { UiLanguagePreference } from "../i18n/language";
type Preferences = {
hasCompletedOnboarding: boolean;
readmeOptIn: boolean;
patToken: string;
viewerLogin: string;
lastSyncedAt: string;
useExistingListsForClassification: boolean;
allowNewTagsWithExistingLists: boolean;
refreshBeforeApply: boolean;
uiLanguage: UiLanguagePreference;
};
const STORAGE_KEY = "star-manager.preferences";
const defaultPreferences: Preferences = {
hasCompletedOnboarding: false,
readmeOptIn: false,
patToken: "",
viewerLogin: "",
lastSyncedAt: "",
useExistingListsForClassification: false,
allowNewTagsWithExistingLists: true,
refreshBeforeApply: true,
uiLanguage: "auto",
};
let cached = readFromStorage();
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) {
listener();
}
}
function readFromStorage(): Preferences {
if (typeof window === "undefined") {
return { ...defaultPreferences };
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...defaultPreferences };
const parsed = JSON.parse(raw) as Partial<Preferences>;
return {
...defaultPreferences,
...parsed,
};
} catch {
return { ...defaultPreferences };
}
}
function persist(next: Preferences) {
cached = next;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
emitChange();
}
export function usePreferenceStore() {
const state = useSyncExternalStore(
(onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
},
() => cached,
() => cached
);
const setReadmeOptIn = useCallback((value: boolean) => {
persist({ ...cached, readmeOptIn: value });
}, []);
const markOnboarded = useCallback(() => {
persist({ ...cached, hasCompletedOnboarding: true });
}, []);
const setPatToken = useCallback((token: string, viewerLogin: string) => {
persist({ ...cached, patToken: token, viewerLogin });
}, []);
const setLastSyncedAt = useCallback((timestamp: string) => {
persist({ ...cached, lastSyncedAt: timestamp });
}, []);
const setUseExistingListsForClassification = useCallback((value: boolean) => {
persist({ ...cached, useExistingListsForClassification: value });
}, []);
const setAllowNewTagsWithExistingLists = useCallback((value: boolean) => {
persist({ ...cached, allowNewTagsWithExistingLists: value });
}, []);
const setRefreshBeforeApply = useCallback((value: boolean) => {
persist({ ...cached, refreshBeforeApply: value });
}, []);
const setUiLanguage = useCallback((value: UiLanguagePreference) => {
persist({ ...cached, uiLanguage: value });
}, []);
return {
preferences: state,
setReadmeOptIn,
markOnboarded,
setPatToken,
setLastSyncedAt,
setUseExistingListsForClassification,
setAllowNewTagsWithExistingLists,
setRefreshBeforeApply,
setUiLanguage,
};
}
export function getPreferenceSnapshot(): Preferences {
return cached;
}

631
src/app/styles/app.css Normal file
View File

@@ -0,0 +1,631 @@
:root {
color-scheme: light;
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif;
line-height: 1.4;
font-weight: 400;
background-color: #f5f1e8;
color: #1e1b16;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 20% 10%, #f8efe0 0%, #f5f1e8 45%, #efe7db 100%);
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif;
}
#root {
min-height: 100vh;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 40px;
border-bottom: 1px solid rgba(30, 27, 22, 0.08);
}
.brand {
display: flex;
align-items: center;
gap: 16px;
}
.brand-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: #f0b429;
display: grid;
place-items: center;
font-weight: 600;
color: #1e1b16;
box-shadow: 0 12px 24px rgba(240, 180, 41, 0.2);
}
.brand-title {
font-size: 22px;
letter-spacing: 0.4px;
margin: 0;
}
.brand-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: rgba(30, 27, 22, 0.6);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.button {
border: 1px solid rgba(30, 27, 22, 0.2);
background: #ffffff;
padding: 10px 16px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.button.primary {
background: #1e1b16;
color: #f5f1e8;
border-color: transparent;
box-shadow: 0 8px 20px rgba(30, 27, 22, 0.25);
}
.button:active {
transform: scale(0.98);
}
.app-main {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 24px;
padding: 32px 40px 48px;
flex: 1;
}
.panel {
background: rgba(255, 255, 255, 0.8);
border-radius: 24px;
padding: 20px;
box-shadow: 0 12px 40px rgba(30, 27, 22, 0.08);
backdrop-filter: blur(12px);
}
.panel h2 {
font-size: 15px;
margin: 0 0 12px;
color: rgba(30, 27, 22, 0.7);
text-transform: uppercase;
letter-spacing: 1.2px;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
margin-bottom: 8px;
background: #fffdf8;
border: 1px solid rgba(30, 27, 22, 0.08);
font-size: 14px;
}
.list-item.active {
border-color: #f0b429;
box-shadow: 0 6px 16px rgba(240, 180, 41, 0.2);
}
.repo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
}
.filter-group select {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
background: #fffdf8;
font-size: 12px;
}
.filter-group.search {
margin-left: auto;
}
.filter-group.search input {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
background: #fffdf8;
font-size: 12px;
min-width: 220px;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
}
.repo-card {
background: #fffdf8;
border-radius: 16px;
padding: 16px;
border: 1px solid rgba(30, 27, 22, 0.08);
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.repo-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(30, 27, 22, 0.65);
}
.repo-title {
font-weight: 600;
font-size: 15px;
margin: 0;
}
.repo-desc {
font-size: 13px;
color: rgba(30, 27, 22, 0.65);
margin: 0;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(240, 180, 41, 0.18);
color: #1e1b16;
margin-right: 6px;
}
.sidebar-meta {
display: flex;
flex-direction: column;
gap: 12px;
}
.meta-card {
background: #fffdf8;
border-radius: 16px;
padding: 16px;
border: 1px solid rgba(30, 27, 22, 0.08);
}
.meta-card .button {
margin-top: 12px;
}
.meta-title {
margin: 0 0 8px;
font-size: 14px;
}
.meta-desc {
margin: 0;
font-size: 12px;
color: rgba(30, 27, 22, 0.6);
}
.drawer {
position: fixed;
inset: 0;
background: rgba(30, 27, 22, 0.4);
display: grid;
place-items: center;
z-index: 40;
}
.drawer-panel {
width: min(560px, 92vw);
background: #fffdf8;
border-radius: 20px;
padding: 24px;
box-shadow: 0 18px 50px rgba(30, 27, 22, 0.2);
position: relative;
}
.drawer-panel.settings {
max-height: 85vh;
overflow: visible;
display: flex;
flex-direction: column;
}
.settings-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 8px;
padding-bottom: 16px;
border-radius: 16px;
}
.drawer-panel h3 {
margin: 0 0 8px;
}
.drawer-panel p {
margin: 0 0 16px;
font-size: 14px;
color: rgba(30, 27, 22, 0.7);
}
.drawer-panel p + p {
margin-top: -6px;
}
.settings-section {
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid rgba(30, 27, 22, 0.08);
}
.settings-section h4 {
margin: 0 0 10px;
font-size: 14px;
}
.settings-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.settings-actions .button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input-row.inline {
display: flex;
gap: 12px;
}
.input-row.inline > div {
flex: 1;
}
.helper-text {
font-size: 12px;
color: rgba(30, 27, 22, 0.6);
margin-top: 8px;
}
.prompt-mode-card {
border: 1px solid rgba(240, 180, 41, 0.35);
background: rgba(240, 180, 41, 0.12);
border-radius: 12px;
padding: 10px 12px;
margin: 10px 0 12px;
}
.prompt-mode-title {
margin: 0;
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
font-weight: 600;
}
.prompt-mode-value {
margin: 4px 0 0;
font-size: 12px;
color: rgba(30, 27, 22, 0.72);
}
.advanced-prompts {
margin-top: 10px;
padding: 10px 12px;
border: 1px dashed rgba(30, 27, 22, 0.2);
border-radius: 12px;
background: rgba(255, 255, 255, 0.6);
}
.advanced-prompts summary {
cursor: pointer;
font-size: 12px;
color: rgba(30, 27, 22, 0.75);
font-weight: 600;
list-style: none;
}
.advanced-prompts[open] summary {
margin-bottom: 8px;
}
.writeback-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.writeback-summary-item {
border: 1px solid rgba(30, 27, 22, 0.12);
border-radius: 10px;
background: #fffdf8;
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.writeback-summary-label {
font-size: 11px;
color: rgba(30, 27, 22, 0.62);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.writeback-summary-value {
font-size: 16px;
font-weight: 600;
color: #1e1b16;
}
.writeback-issues {
display: grid;
gap: 8px;
max-height: 220px;
overflow-y: auto;
padding-right: 4px;
}
.writeback-issue {
border: 1px solid rgba(30, 27, 22, 0.12);
border-radius: 10px;
background: #fffdf8;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.writeback-issue-reason {
font-size: 11px;
color: rgba(30, 27, 22, 0.6);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.writeback-issue-msg {
font-size: 12px;
color: rgba(30, 27, 22, 0.76);
}
.assign-list-grid {
display: grid;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.assign-list-item {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(30, 27, 22, 0.12);
border-radius: 10px;
background: #fffdf8;
padding: 8px 10px;
font-size: 13px;
color: rgba(30, 27, 22, 0.78);
}
.writeback-preview-list {
display: grid;
gap: 10px;
max-height: 320px;
overflow-y: auto;
padding-right: 4px;
}
.writeback-preview-item {
border: 1px solid rgba(30, 27, 22, 0.12);
border-radius: 10px;
background: #fffdf8;
padding: 10px;
}
.writeback-preview-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.writeback-preview-row {
display: flex;
gap: 8px;
font-size: 12px;
color: rgba(30, 27, 22, 0.72);
margin-top: 4px;
}
.writeback-preview-label {
min-width: 52px;
font-weight: 600;
}
.preview-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.preview-card {
border: 1px solid rgba(30, 27, 22, 0.1);
border-radius: 12px;
padding: 12px;
background: #fffdf8;
}
.preview-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.preview-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
}
.diff-grid {
display: grid;
gap: 12px;
margin-top: 12px;
}
.diff-card {
border: 1px solid rgba(30, 27, 22, 0.1);
border-radius: 12px;
padding: 12px;
background: #fffdf8;
}
.diff-row {
display: flex;
gap: 8px;
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
margin-top: 6px;
}
.diff-label {
font-weight: 600;
min-width: 70px;
}
.empty-state {
padding: 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
border: 1px dashed rgba(30, 27, 22, 0.2);
font-size: 13px;
color: rgba(30, 27, 22, 0.7);
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.input-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.input-row input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
font-size: 13px;
}
.input-row textarea {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
resize: vertical;
}
.input-row select {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
background: #fffdf8;
font-size: 12px;
}
.input-row label {
font-size: 12px;
color: rgba(30, 27, 22, 0.7);
}
@media (max-width: 1100px) {
.app-main {
grid-template-columns: 1fr;
}
.writeback-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.panel-close {
position: absolute;
top: -22px;
right: -22px;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(30, 27, 22, 0.2);
background: #fffdf8;
cursor: pointer;
font-size: 16px;
display: grid;
place-items: center;
box-shadow: 0 10px 24px rgba(30, 27, 22, 0.12);
z-index: 5;
}
.panel-close:hover {
transform: translateY(-1px);
}

1
src/app/styles/fonts.css Normal file
View File

@@ -0,0 +1 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap");

View File

@@ -0,0 +1,313 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
applyWriteback,
planWriteback,
type WritebackPlan,
type WritebackProgress,
type WritebackResult,
} from "../core/githubWriteback";
import { usePreferenceStore } from "../store/preferences";
type ApplyUpdatesModalProps = {
isOpen: boolean;
onClose: () => void;
token: string;
onRequestToken: () => void;
};
const EMPTY_PROGRESS: WritebackProgress = {
stage: "idle",
current: 0,
total: 0,
failed: 0,
skipped: 0,
};
export function ApplyUpdatesModal({
isOpen,
onClose,
token,
onRequestToken,
}: ApplyUpdatesModalProps) {
const { t } = useTranslation();
const { preferences, setRefreshBeforeApply } = usePreferenceStore();
const [plan, setPlan] = useState<WritebackPlan | null>(null);
const [result, setResult] = useState<WritebackResult | null>(null);
const [progress, setProgress] = useState<WritebackProgress>(EMPTY_PROGRESS);
const [isPlanning, setIsPlanning] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [error, setError] = useState("");
const requestSeqRef = useRef(0);
const summary = useMemo(() => {
const activePlan = result?.plan ?? plan;
if (!activePlan) return null;
return {
actionableRepos: activePlan.actionableRepos,
unchangedRepos: activePlan.unchangedRepos,
validTaggedRepos: activePlan.validTaggedRepos,
unstarredRepos: activePlan.unstarredActionableRepos,
missingLists: activePlan.missingLists.length,
createdLists: activePlan.createdLists.length,
};
}, [plan, result]);
useEffect(() => {
if (!isOpen) {
requestSeqRef.current += 1;
setPlan(null);
setResult(null);
setProgress(EMPTY_PROGRESS);
setIsPlanning(false);
setIsApplying(false);
setError("");
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const trimmedToken = token.trim();
if (!trimmedToken) {
setPlan(null);
setResult(null);
setProgress(EMPTY_PROGRESS);
setIsPlanning(false);
setError(t("apply.errors.missingPat"));
return;
}
const requestId = ++requestSeqRef.current;
setIsPlanning(true);
setError("");
setResult(null);
setProgress({
stage: "preparing_preview",
current: 0,
total: 1,
failed: 0,
skipped: 0,
});
planWriteback({ token: trimmedToken }, {}, setProgress)
.then((nextPlan) => {
if (requestId !== requestSeqRef.current) return;
setPlan(nextPlan);
})
.catch((err) => {
if (requestId !== requestSeqRef.current) return;
setError((err as Error).message || t("apply.errors.previewFailed"));
})
.finally(() => {
if (requestId !== requestSeqRef.current) return;
setIsPlanning(false);
});
}, [isOpen, token, t]);
if (!isOpen) return null;
const ensureToken = (): boolean => {
if (token.trim()) return true;
setError(t("apply.errors.missingPat"));
onRequestToken();
return false;
};
const handleApply = async () => {
if (!ensureToken()) return;
if (!preferences.refreshBeforeApply && !plan) {
setError(t("apply.errors.previewNotReady"));
return;
}
const requestId = ++requestSeqRef.current;
setIsApplying(true);
setError("");
setResult(null);
setProgress({
stage: "preparing_writeback",
current: 0,
total: 1,
failed: 0,
skipped: 0,
});
try {
const nextResult = await applyWriteback(
{ token: token.trim() },
{
createMissingListsAsPrivate: false,
reusePlan: preferences.refreshBeforeApply ? null : plan,
resolveAutoStarForUnstarred: (unstarredCount) =>
window.confirm(t("apply.confirmUnstarred", { count: unstarredCount })),
},
setProgress
);
if (requestId !== requestSeqRef.current) return;
setResult(nextResult);
setPlan(nextResult.plan);
} catch (err) {
if (requestId !== requestSeqRef.current) return;
setError((err as Error).message || t("apply.errors.applyFailed"));
} finally {
if (requestId !== requestSeqRef.current) return;
setIsApplying(false);
}
};
const disabled = isPlanning || isApplying;
const issues = result ? [...result.failed, ...result.skipped] : (plan?.skipped ?? []);
const changePreviewRows = (result?.plan.candidates ?? plan?.candidates ?? []).slice(0, 80);
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel settings">
<button className="panel-close" onClick={onClose} aria-label={t("apply.closeAria")}>
</button>
<div className="settings-scroll">
<h3>{t("apply.title")}</h3>
<p>{t("apply.description")}</p>
<div className="settings-section">
<h4>{t("apply.sections.runControls")}</h4>
<label className="checkbox">
<input
type="checkbox"
checked={preferences.refreshBeforeApply}
onChange={(event) => setRefreshBeforeApply(event.target.checked)}
disabled={disabled}
/>
{t("apply.controls.refreshBeforeApply")}
</label>
<p className="helper-text">
{preferences.refreshBeforeApply
? t("apply.controls.refreshOn")
: t("apply.controls.refreshOff")}
</p>
<div className="settings-actions">
<button className="button primary" onClick={handleApply} disabled={disabled}>
{isApplying ? t("apply.controls.applying") : t("apply.controls.applyToGithub")}
</button>
</div>
</div>
<div className="settings-section">
<h4>{t("apply.sections.progress")}</h4>
<p className="helper-text">
{t("common.labels.stage")}: {t(`apply.stage.${progress.stage}`, { defaultValue: progress.stage })}
</p>
<p className="helper-text">
{t("common.labels.progress")}: {progress.current}/{progress.total} · {t("apply.labels.failed")}: {progress.failed} · {t("apply.labels.skipped")}: {progress.skipped}
</p>
</div>
{summary ? (
<div className="settings-section">
<h4>{t("apply.sections.planSummary")}</h4>
<div className="writeback-summary-grid">
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.actionable")}</span>
<span className="writeback-summary-value">{summary.actionableRepos}</span>
</div>
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.unchanged")}</span>
<span className="writeback-summary-value">{summary.unchangedRepos}</span>
</div>
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.validTagged")}</span>
<span className="writeback-summary-value">{summary.validTaggedRepos}</span>
</div>
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.unstarred")}</span>
<span className="writeback-summary-value">{summary.unstarredRepos}</span>
</div>
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.missingLists")}</span>
<span className="writeback-summary-value">{summary.missingLists}</span>
</div>
<div className="writeback-summary-item">
<span className="writeback-summary-label">{t("apply.labels.createdLists")}</span>
<span className="writeback-summary-value">{summary.createdLists}</span>
</div>
</div>
{plan?.scanFailures ? (
<p className="helper-text">
{t("apply.status.scanFailures", { count: plan.scanFailures })}
</p>
) : null}
</div>
) : null}
{changePreviewRows.length > 0 ? (
<div className="settings-section">
<h4>{t("apply.sections.plannedRepoChanges")}</h4>
<div className="writeback-preview-list">
{changePreviewRows.map((row) => (
<div className="writeback-preview-item" key={row.repoId}>
<div className="writeback-preview-title">{row.repoFullName}</div>
<div className="writeback-preview-row">
<span className="writeback-preview-label">{t("apply.labels.current")}</span>
<span>{row.currentListNames.join(", ") || t("common.values.none")}</span>
</div>
<div className="writeback-preview-row">
<span className="writeback-preview-label">{t("apply.labels.after")}</span>
<span>{row.finalListNames.join(", ") || t("common.values.none")}</span>
</div>
</div>
))}
</div>
</div>
) : plan ? (
<div className="settings-section">
<h4>{t("apply.sections.plannedRepoChanges")}</h4>
<p className="helper-text">{t("apply.status.noChangesDetected")}</p>
</div>
) : null}
{result ? (
<div className="settings-section">
<h4>{t("apply.sections.applyResult")}</h4>
<p className="helper-text">
{t("apply.labels.applied")}: {result.applied}
</p>
<p className="helper-text">
{t("apply.labels.locallyUpdated")}: {result.locallyUpdated}
</p>
<p className="helper-text">
{t("apply.labels.failed")}: {result.failed.length}
</p>
<p className="helper-text">
{t("apply.labels.skipped")}: {result.skipped.length}
</p>
</div>
) : null}
{issues.length > 0 ? (
<div className="settings-section">
<h4>{t("apply.sections.issues")}</h4>
<div className="writeback-issues">
{issues.slice(0, 60).map((issue, index) => (
<div key={`${issue.reason}-${issue.repoId || "global"}-${index}`} className="writeback-issue">
<span className="writeback-issue-reason">
{t(`apply.issueReasons.${issue.reason}`, { defaultValue: issue.reason })}
</span>
<span className="writeback-issue-msg">
{issue.repoFullName ? `${issue.repoFullName}: ` : ""}
{issue.message}
</span>
</div>
))}
</div>
</div>
) : null}
{error ? (
<p className="helper-text">
{t("common.labels.error")}: {error}
</p>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { setRepoListMembership } from "../core/repoListAssignments";
import { db } from "../data/db";
import { useLiveQuery } from "../data/useLiveQuery";
type AssignListModalProps = {
isOpen: boolean;
repoId: string;
repoName: string;
onClose: () => void;
};
export function AssignListModal({ isOpen, repoId, repoName, onClose }: AssignListModalProps) {
const { t } = useTranslation();
const [selectedListIds, setSelectedListIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [status, setStatus] = useState("");
const lists = useLiveQuery(
async () => db.lists.orderBy("name").toArray(),
[],
[]
);
const repoList = useLiveQuery(
async () => db.repoLists.get(repoId),
[repoId],
undefined
);
useEffect(() => {
if (!isOpen) return;
setSelectedListIds(repoList?.listIds ?? []);
setStatus("");
}, [isOpen, repoId, repoList?.listIds]);
const selectedSet = useMemo(() => new Set(selectedListIds), [selectedListIds]);
if (!isOpen) return null;
const toggleList = (listId: string) => {
setSelectedListIds((prev) => {
const has = prev.includes(listId);
if (has) return prev.filter((id) => id !== listId);
return [...prev, listId];
});
};
const handleSave = async () => {
setIsSaving(true);
setStatus("");
try {
await setRepoListMembership(repoId, selectedListIds);
setStatus(t("assignList.status.saved"));
} catch (error) {
setStatus((error as Error).message || t("assignList.status.saveFailed"));
} finally {
setIsSaving(false);
}
};
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel settings">
<button className="panel-close" onClick={onClose} aria-label={t("assignList.closeAria")}>
</button>
<div className="settings-scroll">
<h3>{t("assignList.title")}</h3>
<p>{t("assignList.description", { repoName })}</p>
<div className="settings-section">
<h4>{t("assignList.sections.lists")}</h4>
{lists.length === 0 ? (
<p className="helper-text">{t("assignList.status.noLists")}</p>
) : (
<div className="assign-list-grid">
{lists.map((list) => (
<label className="assign-list-item" key={list.id}>
<input
type="checkbox"
checked={selectedSet.has(list.id)}
onChange={() => toggleList(list.id)}
/>
<span>{list.name}</span>
</label>
))}
</div>
)}
<div className="settings-actions">
<button className="button" onClick={() => setSelectedListIds([])} disabled={isSaving}>
{t("common.actions.clear")}
</button>
<button className="button primary" onClick={handleSave} disabled={isSaving}>
{isSaving ? t("assignList.status.saving") : t("common.actions.save")}
</button>
</div>
{status ? <p className="helper-text">{status}</p> : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
type FirstRunPromptProps = {
onConfirm: (enableReadme: boolean) => void;
onSkip: () => void;
};
export function FirstRunPrompt({ onConfirm, onSkip }: FirstRunPromptProps) {
const { t } = useTranslation();
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel">
<h3>{t("onboarding.title")}</h3>
<p>{t("onboarding.description1")}</p>
<p>{t("onboarding.description2")}</p>
<div className="drawer-actions">
<button className="button" onClick={onSkip}>
{t("onboarding.skip")}
</button>
<button className="button primary" onClick={() => onConfirm(true)}>
{t("onboarding.enable")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,331 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { runTwoStageClassification } from "../core/llmClassification";
import { db } from "../data/db";
import { useLiveQuery } from "../data/useLiveQuery";
import { normalizeAppLanguage, toPromptLanguage } from "../i18n/language";
import { useLlmConfigStore } from "../store/llmConfig";
import { usePreferenceStore } from "../store/preferences";
type LlmClassificationModalProps = {
isOpen: boolean;
onClose: () => void;
};
export function LlmClassificationModal({ isOpen, onClose }: LlmClassificationModalProps) {
const { t, i18n } = useTranslation();
const { config } = useLlmConfigStore();
const { preferences } = usePreferenceStore();
const [stage, setStage] = useState("idle");
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState("");
const [isTestMode, setIsTestMode] = useState(false);
const [strictSingleTag, setStrictSingleTag] = useState(false);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [compareMode, setCompareMode] = useState<"existing" | "run">("existing");
const repos = useLiveQuery(
async () => db.repos.toArray(),
[],
[]
);
const classifications = useLiveQuery(async () => db.classifications.toArray(), [], []);
const lists = useLiveQuery(async () => db.lists.toArray(), [], []);
const repoLists = useLiveQuery(async () => db.repoLists.toArray(), [], []);
const runs = useLiveQuery(
async () => db.classificationRuns.orderBy("createdAt").reverse().toArray(),
[],
[]
);
const selectedRunTags = useLiveQuery(
async () => {
if (!selectedRunId) return [];
return db.classificationTags.where("runId").equals(selectedRunId).toArray();
},
[selectedRunId],
[]
);
const previewRows = useMemo(() => {
const map = new Map(classifications.map((item) => [item.repoId, item.tags]));
return repos.slice(0, 12).map((repo) => ({
id: repo.id,
name: repo.fullName,
tags: map.get(repo.id) ?? [],
}));
}, [repos, classifications]);
const existingListMap = useMemo(() => {
const listMap = new Map(lists.map((list) => [list.id, list.name]));
const out = new Map<string, string[]>();
for (const row of repoLists) {
const names = row.listIds.map((listId) => listMap.get(listId)).filter(Boolean) as string[];
out.set(row.repoId, names);
}
return out;
}, [lists, repoLists]);
const diffRows = useMemo(() => {
if (compareMode === "run" && !selectedRunId) return [];
const runMap = new Map(selectedRunTags.map((item) => [item.repoId, item.tags]));
const currentMap = new Map(classifications.map((item) => [item.repoId, item.tags]));
const rows = repos.map((repo) => {
const current = currentMap.get(repo.id) ?? [];
const existing = existingListMap.get(repo.id) ?? [];
const previous = compareMode === "run" ? runMap.get(repo.id) ?? [] : existing;
const changed = previous.join("|") !== current.join("|");
return {
id: repo.id,
name: repo.fullName,
previous,
current,
existing,
changed,
};
});
return rows.filter((row) => row.changed).slice(0, 30);
}, [repos, classifications, selectedRunTags, existingListMap, compareMode, selectedRunId]);
if (!isOpen) return null;
const handleRun = async () => {
if (!config.apiKey || !config.baseUrl) {
setError(t("classification.errors.missingConfig"));
return;
}
if (repos.length === 0) {
setError(t("classification.errors.noRepos"));
return;
}
const repoBatch = isTestMode ? repos.slice(0, 5) : repos;
const promptLanguage = toPromptLanguage(
normalizeAppLanguage(i18n.resolvedLanguage || i18n.language)
);
setError("");
setIsRunning(true);
setStage("preparing");
setProgress({ current: 0, total: repoBatch.length });
try {
const result = await runTwoStageClassification(
config,
repoBatch.map((repo) => ({
id: repo.id,
fullName: repo.fullName,
description: repo.description,
topics: repo.topics,
language: repo.language,
readmeExcerpt: repo.readmeExcerpt,
existingLists: existingListMap.get(repo.id) ?? [],
})),
{
strictSingleTag,
pass1Prompt: config.pass1Prompt,
pass1StrictPrompt: config.pass1StrictPrompt,
pass2Prompt: config.pass2Prompt,
useExistingLists: preferences.useExistingListsForClassification,
allowNewTagsWithExistingLists: preferences.allowNewTagsWithExistingLists,
language: promptLanguage,
},
(p) => {
setStage(p.stage);
setProgress({ current: p.current, total: p.total });
}
);
await db.transaction(
"rw",
db.classifications,
db.tagCompression,
db.classificationRuns,
db.classificationTags,
async () => {
await db.classifications.clear();
await db.tagCompression.clear();
const now = new Date().toISOString();
const rows = Object.entries(result.repoTags).map(([repoId, tags]) => ({
repoId,
tags,
lastRunAt: now,
}));
await db.classifications.bulkPut(rows);
await db.tagCompression.bulkPut(
Object.entries(result.tagMap).map(([tag, compressedTag]) => ({
tag,
compressedTag,
}))
);
await db.classificationRuns.add({
id: result.runId,
createdAt: now,
repoCount: repoBatch.length,
strictSingleTag,
testMode: isTestMode,
pass1Prompt: config.pass1Prompt || "",
pass1StrictPrompt: config.pass1StrictPrompt || "",
pass2Prompt: config.pass2Prompt || "",
});
await db.classificationTags.bulkPut(
Object.entries(result.repoTags).map(([repoId, tags]) => ({
id: `${result.runId}:${repoId}`,
runId: result.runId,
repoId,
tags,
}))
);
}
);
setStage("completed");
setSelectedRunId(result.runId);
} catch (err) {
setError((err as Error).message || t("classification.errors.classificationFailed"));
setStage("failed");
} finally {
setIsRunning(false);
}
};
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel settings">
<button className="panel-close" onClick={onClose} aria-label={t("classification.closeAria")}>
</button>
<div className="settings-scroll">
<h3>{t("classification.title")}</h3>
<p>{t("classification.description")}</p>
<div className="settings-section">
<h4>{t("classification.sections.runStatus")}</h4>
<p className="helper-text">
{t("common.labels.stage")}: {t(`classification.stage.${stage}`, { defaultValue: stage })}
</p>
<p className="helper-text">
{t("common.labels.progress")}: {progress.current}/{progress.total}
</p>
<label className="checkbox">
<input
type="checkbox"
checked={isTestMode}
onChange={(event) => setIsTestMode(event.target.checked)}
/>
{t("classification.toggles.testMode")}
</label>
<label className="checkbox">
<input
type="checkbox"
checked={strictSingleTag}
onChange={(event) => setStrictSingleTag(event.target.checked)}
/>
{t("classification.toggles.strictSingleTag")}
</label>
{error ? (
<p className="helper-text">
{t("common.labels.error")}: {error}
</p>
) : null}
<div className="settings-actions">
<button className="button primary" onClick={handleRun} disabled={isRunning}>
{isRunning ? t("classification.actions.running") : t("classification.actions.run")}
</button>
</div>
</div>
<div className="settings-section">
<h4>{t("classification.sections.preview")}</h4>
{previewRows.length === 0 ? (
<p className="helper-text">{t("classification.status.noResults")}</p>
) : (
<div className="preview-grid">
{previewRows.map((row) => (
<div key={row.id} className="preview-card">
<div className="preview-title">{row.name}</div>
<div className="preview-tags">
{row.tags.length === 0
? t("common.values.unclassified")
: row.tags.map((tag) => (
<span className="tag" key={tag}>
{tag}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
<div className="settings-section">
<h4>{t("classification.sections.diffView")}</h4>
<div className="input-row">
<label htmlFor="diff-mode">{t("classification.labels.compareAgainst")}</label>
<select
id="diff-mode"
value={compareMode}
onChange={(event) => setCompareMode(event.target.value as "existing" | "run")}
>
<option value="existing">{t("classification.options.existingStarLists")}</option>
<option value="run">{t("classification.options.previousRun")}</option>
</select>
</div>
{compareMode === "run" ? (
runs.length === 0 ? (
<p className="helper-text">{t("classification.status.noSavedRuns")}</p>
) : (
<div className="input-row">
<label htmlFor="run-select">{t("classification.labels.selectRun")}</label>
<select
id="run-select"
value={selectedRunId ?? ""}
onChange={(event) => setSelectedRunId(event.target.value || null)}
>
<option value="">{t("classification.options.selectRunPlaceholder")}</option>
{runs.map((run) => (
<option key={run.id} value={run.id}>
{new Date(run.createdAt).toLocaleString(i18n.language)} · {run.repoCount} {t("classification.options.reposSuffix")}
{run.testMode ? ` · ${t("classification.options.testSuffix")}` : ""}
{run.strictSingleTag ? ` · ${t("classification.options.singleSuffix")}` : ""}
</option>
))}
</select>
</div>
)
) : null}
{compareMode === "run" && selectedRunId && diffRows.length === 0 ? (
<p className="helper-text">{t("classification.status.noTagDiff")}</p>
) : null}
{compareMode === "existing" && diffRows.length === 0 ? (
<p className="helper-text">{t("classification.status.noDiffFromExisting")}</p>
) : null}
{diffRows.length > 0 ? (
<div className="diff-grid">
{diffRows.map((row) => (
<div key={row.id} className="diff-card">
<div className="preview-title">{row.name}</div>
<div className="diff-row">
<span className="diff-label">{t("classification.labels.existing")}</span>
<span>{row.existing.join(", ") || t("common.values.none")}</span>
</div>
{compareMode === "run" ? (
<div className="diff-row">
<span className="diff-label">{t("classification.labels.previous")}</span>
<span>{row.previous.join(", ") || t("common.values.unclassified")}</span>
</div>
) : null}
<div className="diff-row">
<span className="diff-label">{t("classification.labels.current")}</span>
<span>{row.current.join(", ") || t("common.values.unclassified")}</span>
</div>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

49
src/app/ui/PatModal.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
type PatModalProps = {
isOpen: boolean;
onClose: () => void;
onSave: (token: string) => void;
};
export function PatModal({ isOpen, onClose, onSave }: PatModalProps) {
const { t } = useTranslation();
const [token, setToken] = useState("");
if (!isOpen) return null;
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel">
<h3>{t("patModal.title")}</h3>
<p>{t("patModal.description")}</p>
<div className="input-row">
<label htmlFor="pat-input">{t("patModal.tokenLabel")}</label>
<input
id="pat-input"
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="ghp_..."
/>
</div>
<div className="drawer-actions">
<button className="button" onClick={onClose}>
{t("common.actions.cancel")}
</button>
<button
className="button primary"
onClick={() => {
if (!token.trim()) return;
onSave(token.trim());
setToken("");
}}
>
{t("patModal.savePat")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,478 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { resolvePrompts, type PromptFieldKey } from "../core/llmPromptResolver";
import { normalizeAppLanguage, toPromptLanguage, type UiLanguagePreference } from "../i18n/language";
import { requestCompletion } from "../services/llmClient";
import type { LLMConfig } from "../services/llmClient";
import { validatePat } from "../services/githubAuth";
import { useLlmConfigStore } from "../store/llmConfig";
import { usePreferenceStore } from "../store/preferences";
type SettingsModalProps = {
isOpen: boolean;
onClose: () => void;
};
function getModeDescriptionKey(useExisting: boolean, allowNewTags: boolean): string {
if (!useExisting) {
return "settings.llm.modeBase";
}
if (allowNewTags) {
return "settings.llm.modeExisting";
}
return "settings.llm.modeExistingOnly";
}
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { t, i18n } = useTranslation();
const {
preferences,
setReadmeOptIn,
setPatToken,
setUseExistingListsForClassification,
setAllowNewTagsWithExistingLists,
setRefreshBeforeApply,
setUiLanguage,
} = usePreferenceStore();
const { config, updateConfig, clearConfig } = useLlmConfigStore();
const [patTokenInput, setPatTokenInput] = useState(preferences.patToken);
const [patLoginInput, setPatLoginInput] = useState(preferences.viewerLogin);
const [status, setStatus] = useState("");
const [testStatus, setTestStatus] = useState("");
const [isSavingPat, setIsSavingPat] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const promptLanguage = toPromptLanguage(normalizeAppLanguage(i18n.resolvedLanguage || i18n.language));
const promptFieldMeta: Record<PromptFieldKey, { label: string; rows: number }> = useMemo(
() => ({
pass1Prompt: { label: t("settings.llm.promptLabels.pass1Prompt"), rows: 3 },
pass1StrictPrompt: { label: t("settings.llm.promptLabels.pass1StrictPrompt"), rows: 2 },
pass2Prompt: { label: t("settings.llm.promptLabels.pass2Prompt"), rows: 3 },
pass1PromptWithExisting: {
label: t("settings.llm.promptLabels.pass1PromptWithExisting"),
rows: 3,
},
pass1PromptNoNewWithExisting: {
label: t("settings.llm.promptLabels.pass1PromptNoNewWithExisting"),
rows: 3,
},
pass1StrictPromptWithExisting: {
label: t("settings.llm.promptLabels.pass1StrictPromptWithExisting"),
rows: 2,
},
pass1StrictNoNewWithExisting: {
label: t("settings.llm.promptLabels.pass1StrictNoNewWithExisting"),
rows: 2,
},
pass2PromptWithExisting: {
label: t("settings.llm.promptLabels.pass2PromptWithExisting"),
rows: 3,
},
pass1PromptZh: { label: t("settings.llm.promptLabels.pass1PromptZh"), rows: 3 },
pass1StrictPromptZh: {
label: t("settings.llm.promptLabels.pass1StrictPromptZh"),
rows: 2,
},
pass2PromptZh: { label: t("settings.llm.promptLabels.pass2PromptZh"), rows: 3 },
pass1PromptWithExistingZh: {
label: t("settings.llm.promptLabels.pass1PromptWithExistingZh"),
rows: 3,
},
pass1PromptNoNewWithExistingZh: {
label: t("settings.llm.promptLabels.pass1PromptNoNewWithExistingZh"),
rows: 3,
},
pass1StrictPromptWithExistingZh: {
label: t("settings.llm.promptLabels.pass1StrictPromptWithExistingZh"),
rows: 2,
},
pass1StrictNoNewWithExistingZh: {
label: t("settings.llm.promptLabels.pass1StrictNoNewWithExistingZh"),
rows: 2,
},
pass2PromptWithExistingZh: {
label: t("settings.llm.promptLabels.pass2PromptWithExistingZh"),
rows: 3,
},
}),
[t]
);
const resolvedPrompts = useMemo(
() =>
resolvePrompts(config, {
strictSingleTag: false,
useExistingLists: preferences.useExistingListsForClassification,
allowNewTagsWithExistingLists: preferences.allowNewTagsWithExistingLists,
language: promptLanguage,
}),
[
config,
preferences.useExistingListsForClassification,
preferences.allowNewTagsWithExistingLists,
promptLanguage,
]
);
const activePromptFields = useMemo(
() => [
{
id: "llm-pass1-active",
key: resolvedPrompts.activeKeys.pass1,
label: t("settings.llm.activePass1"),
rows: promptFieldMeta[resolvedPrompts.activeKeys.pass1].rows,
value: resolvedPrompts.pass1,
},
{
id: "llm-pass1-strict-active",
key: resolvedPrompts.activeKeys.pass1Strict,
label: t("settings.llm.activePass1Strict"),
rows: promptFieldMeta[resolvedPrompts.activeKeys.pass1Strict].rows,
value: resolvedPrompts.pass1Strict,
},
{
id: "llm-pass2-active",
key: resolvedPrompts.activeKeys.pass2,
label: t("settings.llm.activePass2"),
rows: promptFieldMeta[resolvedPrompts.activeKeys.pass2].rows,
value: resolvedPrompts.pass2,
},
],
[resolvedPrompts, promptFieldMeta, t]
);
const inactivePromptFields = useMemo(
() =>
resolvedPrompts.inactiveKeys.map((key) => ({
key,
id: `llm-${key}`,
label: promptFieldMeta[key].label,
rows: promptFieldMeta[key].rows,
value: config[key] || "",
})),
[config, promptFieldMeta, resolvedPrompts]
);
const modeDescription = useMemo(
() =>
t(
getModeDescriptionKey(
preferences.useExistingListsForClassification,
preferences.allowNewTagsWithExistingLists
)
),
[
preferences.useExistingListsForClassification,
preferences.allowNewTagsWithExistingLists,
t,
]
);
useEffect(() => {
if (!isOpen) return;
setPatTokenInput(preferences.patToken);
setPatLoginInput(preferences.viewerLogin);
}, [isOpen, preferences.patToken, preferences.viewerLogin]);
if (!isOpen) return null;
const handleTest = async () => {
if (!config.baseUrl.trim()) {
setTestStatus(t("settings.llm.missingBaseUrl"));
return;
}
if (!config.apiKey.trim()) {
setTestStatus(t("settings.llm.missingApiKey"));
return;
}
setIsTesting(true);
setTestStatus(t("settings.llm.testing"));
const startedAt = performance.now();
try {
const response = await requestCompletion(
config,
[
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Reply with OK" },
]
);
const elapsed = Math.round(performance.now() - startedAt);
setTestStatus(t("settings.llm.testOk", { elapsed, response }));
} catch (error) {
const elapsed = Math.round(performance.now() - startedAt);
const err = error as Error;
const name = err?.name || "Error";
const message = err?.message || "Test failed";
setTestStatus(t("settings.llm.testError", { elapsed, name, message }));
} finally {
setIsTesting(false);
}
};
const handleSavePat = async () => {
const token = patTokenInput.trim();
if (!token) {
setStatus(t("settings.pat.required"));
return;
}
setIsSavingPat(true);
setStatus(t("settings.pat.validating"));
try {
const viewer = await validatePat(token);
setPatToken(token, viewer.login);
setPatLoginInput(viewer.login);
setStatus(t("settings.pat.validated", { login: viewer.login }));
} catch (error) {
const err = error as Error;
const name = err?.name || "Error";
const message = err?.message || "Validation failed";
setStatus(t("settings.pat.validationFailed", { name, message }));
} finally {
setIsSavingPat(false);
}
};
const updatePromptField = (key: PromptFieldKey, value: string) => {
updateConfig({ [key]: value } as Partial<LLMConfig>);
};
const promptLanguageLabel =
promptLanguage === "zh"
? t("settings.language.promptLanguageChinese")
: t("settings.language.promptLanguageEnglish");
return (
<div className="drawer" role="dialog" aria-modal="true">
<div className="drawer-panel settings">
<button className="panel-close" onClick={onClose} aria-label={t("settings.closeAria")}>
</button>
<div className="settings-scroll">
<h3>{t("settings.title")}</h3>
<p>{t("settings.description")}</p>
<div className="settings-section">
<h4>{t("settings.sections.language")}</h4>
<div className="input-row">
<label htmlFor="ui-language-select">{t("settings.language.appLanguage")}</label>
<select
id="ui-language-select"
value={preferences.uiLanguage}
onChange={(event) => setUiLanguage(event.target.value as UiLanguagePreference)}
>
<option value="auto">{t("settings.language.auto")}</option>
<option value="en">{t("settings.language.english")}</option>
<option value="zh-CN">{t("settings.language.chineseSimplified")}</option>
</select>
</div>
</div>
<div className="settings-section">
<h4>{t("settings.sections.patManagement")}</h4>
<div className="input-row">
<label htmlFor="pat-token">{t("settings.pat.githubPat")}</label>
<input
id="pat-token"
type="password"
value={patTokenInput}
onChange={(event) => setPatTokenInput(event.target.value)}
placeholder="ghp_..."
/>
</div>
<div className="input-row">
<label htmlFor="pat-login">{t("settings.pat.githubLoginOptional")}</label>
<input
id="pat-login"
type="text"
value={patLoginInput}
onChange={(event) => setPatLoginInput(event.target.value)}
placeholder="octocat"
/>
</div>
<div className="settings-actions">
<button className="button" onClick={handleSavePat} disabled={isSavingPat}>
{isSavingPat ? t("settings.pat.validating") : t("settings.pat.savePat")}
</button>
<button
className="button"
onClick={() => {
setPatToken("", "");
setPatTokenInput("");
setPatLoginInput("");
setStatus(t("settings.pat.cleared"));
}}
>
{t("settings.pat.clearPat")}
</button>
</div>
</div>
<div className="settings-section">
<h4>{t("settings.sections.llmConfiguration")}</h4>
<label className="checkbox">
<input
type="checkbox"
checked={preferences.useExistingListsForClassification}
onChange={(event) =>
setUseExistingListsForClassification(event.target.checked)
}
/>
{t("settings.llm.useExistingLists")}
</label>
<label className="checkbox">
<input
type="checkbox"
checked={preferences.allowNewTagsWithExistingLists}
onChange={(event) =>
setAllowNewTagsWithExistingLists(event.target.checked)
}
disabled={!preferences.useExistingListsForClassification}
/>
{t("settings.llm.allowNewTags")}
</label>
<p className="helper-text">
{t("settings.language.promptLanguage", { language: promptLanguageLabel })}
</p>
<div className="prompt-mode-card">
<p className="prompt-mode-title">{t("settings.llm.currentMode")}</p>
<p className="prompt-mode-value">{modeDescription}</p>
</div>
<div className="input-row">
<label htmlFor="llm-base">{t("settings.llm.baseUrl")}</label>
<input
id="llm-base"
type="text"
value={config.baseUrl}
onChange={(event) => updateConfig({ baseUrl: event.target.value })}
/>
</div>
<div className="input-row">
<label htmlFor="llm-key">{t("settings.llm.apiKey")}</label>
<input
id="llm-key"
type="password"
value={config.apiKey}
onChange={(event) => updateConfig({ apiKey: event.target.value })}
/>
</div>
<div className="input-row">
<label htmlFor="llm-model">{t("settings.llm.model")}</label>
<input
id="llm-model"
type="text"
value={config.model}
onChange={(event) => updateConfig({ model: event.target.value })}
/>
</div>
<div className="input-row inline">
<div>
<label htmlFor="llm-temp">{t("settings.llm.temperature")}</label>
<input
id="llm-temp"
type="number"
step="0.1"
value={config.temperature}
onChange={(event) => updateConfig({ temperature: Number(event.target.value) })}
/>
</div>
<div>
<label htmlFor="llm-max">{t("settings.llm.maxTokens")}</label>
<input
id="llm-max"
type="number"
step="1"
value={config.maxTokens}
onChange={(event) => updateConfig({ maxTokens: Number(event.target.value) })}
/>
</div>
</div>
{activePromptFields.map((field) => (
<div className="input-row" key={field.id}>
<label htmlFor={field.id}>{field.label}</label>
<textarea
id={field.id}
rows={field.rows}
value={field.value}
onChange={(event) => updatePromptField(field.key, event.target.value)}
/>
</div>
))}
<details className="advanced-prompts">
<summary>{t("settings.llm.advancedSettings", { count: inactivePromptFields.length })}</summary>
<p className="helper-text">{t("settings.llm.advancedHelp")}</p>
{inactivePromptFields.map((field) => (
<div className="input-row" key={field.id}>
<label htmlFor={field.id}>{field.label}</label>
<textarea
id={field.id}
rows={field.rows}
value={field.value}
onChange={(event) => updatePromptField(field.key, event.target.value)}
/>
</div>
))}
</details>
<div className="settings-actions">
<button className="button" onClick={handleTest} disabled={isTesting}>
{isTesting ? t("settings.llm.testing") : t("settings.llm.testLlm")}
</button>
<button
className="button"
onClick={() => {
clearConfig();
setTestStatus(t("settings.llm.cleared"));
}}
>
{t("settings.llm.resetLlm")}
</button>
</div>
{testStatus ? <p className="helper-text">{testStatus}</p> : null}
</div>
<div className="settings-section">
<h4>{t("settings.sections.readmeFetching")}</h4>
<label className="checkbox">
<input
type="checkbox"
checked={preferences.readmeOptIn}
onChange={(event) => setReadmeOptIn(event.target.checked)}
/>
{t("settings.readme.enable")}
</label>
</div>
<div className="settings-section">
<h4>{t("settings.sections.writeback")}</h4>
<label className="checkbox">
<input
type="checkbox"
checked={preferences.refreshBeforeApply}
onChange={(event) => setRefreshBeforeApply(event.target.checked)}
/>
{t("settings.writeback.refreshBeforeApply")}
</label>
<p className="helper-text">{t("settings.writeback.refreshHelp")}</p>
</div>
<div className="settings-section">
<h4>{t("settings.sections.localCache")}</h4>
<p className="helper-text">{t("settings.cache.help")}</p>
<button
className="button"
onClick={() => {
indexedDB.deleteDatabase("star-manager");
setStatus(t("settings.cache.cleared"));
setTimeout(() => window.location.reload(), 300);
}}
>
{t("settings.cache.clearButton")}
</button>
</div>
{status ? <p className="helper-text">{status}</p> : null}
</div>
</div>
</div>
);
}

19
src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./app/App";
import "./app/i18n";
import "./app/styles/fonts.css";
import "./app/styles/app.css";
const container = document.querySelector<HTMLDivElement>("#app");
if (!container) {
throw new Error("Root container #app not found");
}
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />