Merge branch 'ia2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
#	docs/plan/00-first-version-plan.md
#	packages/agent/src/system-prompt.ts
This commit is contained in:
root committed 2026-09-30 00:30:42 +08:00
commit 7a24d23bbf
46 files changed
+1845 -1503

No files matched your search

@@ -111,7 +111,8 @@ export function FeedFormDialog({
const raw = {
...(d.name.trim() ? { name: d.name.trim() } : {}),
purpose: d.purpose,
siteId: d.siteId || null,
// RSS 属于站点(IA-2):不能改成"不关联";没选站点时由服务端归入同主机的自定义 RSS 源
...(d.siteId ? { siteId: d.siteId } : {}),
siteAccountId: d.siteAccountId || null,
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
enabled: d.enabled,
@@ -242,12 +243,14 @@ export function FeedFormDialog({
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={NONE}>不关联站点</SelectItem>
{sites.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
{!d.siteId ? <SelectItem value={NONE}>按地址归入自定义 RSS 源</SelectItem> : null}
{sites
.filter((s) => s.capabilities.includes("rss"))
.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
@@ -1,16 +1,18 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts";
import { EventType, type FeedHealth, type FeedSource, type Observation } from "@umt/contracts";
import { RotateCcw, Rss, Zap } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
import { feedGapHint, feedKeys, useFeedAction, useFeedObservations, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
import { useSiteAccounts, useSites } from "~/api/sites.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx";
import { hasLogin } from "~/components/sites/site-fields.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -78,8 +80,42 @@ function FeedActions({ f }: { f: FeedSource }) {
);
}
const OBS_STATUS: Record<Observation["status"], { label: string; tone: StatusTone } | null> = {
new: { label: "待识别", tone: "neutral" },
linked: null,
ignored: { label: "已忽略", tone: "neutral" },
error: { label: "出错", tone: "warning" },
};
/** RSS 详情里的最近条目:标题 + 发布时间;已关联资源的正常态不加徽章 */
function RecentItems({ feedId }: { feedId: string }) {
const obs = useFeedObservations(feedId);
if (obs.isPending) return <LoadingState rows={3} />;
if (obs.error) return <ErrorState plain title="读取条目失败" error={obs.error} onRetry={() => void obs.refetch()} />;
// 按发布时间(没有时用首次发现)新的在前;接口按首次发现排序,同一次抓取的条目发现时间相同
const at = (o: Observation) => o.sourcePublishedAt ?? o.firstSeenAt;
const items = [...(obs.data?.items ?? [])].sort((a, b) => at(b).localeCompare(at(a)));
if (!items.length) return <p className="text-caption text-muted-foreground">还没有抓到条目。</p>;
return (
<ul className="divide-y divide-border/60">
{items.map((o) => {
const st = OBS_STATUS[o.status];
return (
<li key={o.id} className="flex min-w-0 items-start justify-between gap-3 py-2">
<p className="min-w-0 text-caption break-words text-foreground">{o.title}</p>
<span className="flex shrink-0 items-center gap-1.5 text-meta whitespace-nowrap text-muted-foreground">
{st ? <StatusBadge tone={st.tone}>{st.label}</StatusBadge> : null}
{relTime(o.sourcePublishedAt ?? o.firstSeenAt)}
</span>
</li>
);
})}
</ul>
);
}
/**
* Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。
* RSS 列表 + 详情抽屉 + 新建/编辑对话框。站点详情的 RSS Tab 按 siteId 过滤(IA-2 起没有跨站点的 RSS 页)。
*/
/** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */
function useLiveFeeds() {
@@ -106,7 +142,10 @@ export function FeedTable({
creating,
onCreatingChange,
filtered = false,
openFeedId,
}: {
/** 深链打开的 RSS(?feed=<id>) */
openFeedId?: string | null;
siteId?: string;
health?: FeedHealth;
creating: boolean;
@@ -119,7 +158,10 @@ export function FeedTable({
const sites = useSites();
const accounts = useSiteAccounts();
const [editingFeed, setEditingFeed] = useState<FeedSource | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(openFeedId ?? null);
useEffect(() => {
if (openFeedId) setOpenId(openFeedId);
}, [openFeedId]);
const action = useFeedAction();
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
@@ -167,17 +209,25 @@ export function FeedTable({
hideBelow: "md",
cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]),
},
{
key: "site",
header: "站点 / 账号",
hideBelow: "lg",
cell: (f) => (
<span className="text-muted-foreground">
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
</span>
),
},
// 站点详情里只列账号(站点已知);没有登录的站点不显示这一列
...(scopedSite && !hasLogin(scopedSite)
? []
: [
{
key: "site",
header: siteId ? "账号" : "站点 / 账号",
hideBelow: "lg" as const,
cell: (f: FeedSource) => (
<span className="text-muted-foreground">
{siteId
? f.siteAccountId
? (accountName.get(f.siteAccountId) ?? "账号")
: "—"
: `${f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}${f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}`}
</span>
),
},
]),
{
key: "poll",
header: "轮询",
@@ -287,6 +337,9 @@ export function FeedTable({
]}
/>
</DetailSection>
<DetailSection title="最近条目">
<RecentItems feedId={open.id} />
</DetailSection>
<JsonView label="过滤条件" value={open.filter} />
<JsonView label="游标原文" value={open.cursor} />
</>