This commit is contained in:
董宏凯 committed 2026-05-15 23:27:48 +08:00
commit e08e7be24c
404 files changed
+113007 -921

No files matched your search

@@ -0,0 +1,293 @@
"use client";
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { TagChip } from "@/components/workspace/tags/tag-chip";
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateTag,
useDeleteTag,
useTags,
useUpdateTag,
type Tag,
type TagType,
} from "@/core/tags";
import { cn } from "@/lib/utils";
function TagFormDialog({
open,
onOpenChange,
tagType,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagType: TagType;
editing: Tag | null;
}) {
const { t } = useI18n();
const createTag = useCreateTag();
const updateTag = useUpdateTag();
const [name, setName] = useState("");
const [color, setColor] = useState("gray");
const [description, setDescription] = useState("");
// Sync form state whenever the dialog (re)opens.
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setColor(editing?.color ?? "gray");
setDescription(editing?.description ?? "");
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createTag.isPending || updateTag.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updateTag.mutateAsync({
id: editing.id,
input: { name: trimmed, color, description },
});
} else {
await createTag.mutateAsync({
name: trimmed,
tag_type: tagType,
color,
description,
});
}
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.name}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.adminTags.namePlaceholder}
maxLength={256}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.color}</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setColor(c.name)}
aria-label={c.name}
className={cn(
"size-6 rounded-full border-2",
c.swatch,
color === c.name
? "border-foreground"
: "border-transparent",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{t.adminTags.tagDescription}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.adminTags.descriptionPlaceholder}
maxLength={1024}
rows={2}
/>
</div>
{name.trim() && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Preview:</span>
<TagChip tag={{ name: name.trim(), color }} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.adminTags.cancel}
</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
{t.adminTags.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TagManagementPage() {
const { t } = useI18n();
const [activeType, setActiveType] = useState<TagType>("agent");
const { data: tags, isLoading, error } = useTags(activeType);
const deleteTag = useDeleteTag();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tag | null>(null);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (tag: Tag) => {
setEditing(tag);
setDialogOpen(true);
};
const handleDelete = async (tag: Tag) => {
if (!window.confirm(t.adminTags.deleteConfirm)) return;
try {
await deleteTag.mutateAsync(tag.id);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminTags.title}
description={t.adminTags.description}
>
<div className="flex items-center justify-between gap-4">
<Tabs
value={activeType}
onValueChange={(v) => setActiveType(v as TagType)}
>
<TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
<TabsTrigger value="scheduled_task">
{t.adminTags.typeScheduledTask}
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
{t.adminTags.createButton}
</Button>
</div>
<div className="mt-4">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive py-4 text-sm">{String(error)}</div>
)}
{!isLoading && !error && tags && tags.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t.adminTags.empty}
</div>
)}
{!isLoading && !error && tags && tags.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.actions}
</th>
</tr>
</thead>
<tbody>
{tags.map((tag, i) => (
<tr
key={tag.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">
<TagChip tag={tag} />
</td>
<td className="text-muted-foreground px-4 py-3">
{tag.description || "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{tag.usage_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(tag)}
aria-label={t.adminTags.edit}
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tag)}
aria-label={t.adminTags.delete}
>
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SettingsSection>
<TagFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tagType={activeType}
editing={editing}
/>
</div>
);
}
@@ -6,6 +6,7 @@ import {
EyeOffIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -33,6 +34,8 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || canDelete || isOwned) && (
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
<Button
size="icon"
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
</>
);
}
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -83,10 +87,19 @@ export function AgentGallery() {
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
</TabsList>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
@@ -2,17 +2,16 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,16 +33,16 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -118,33 +117,19 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const queryClient = useQueryClient();
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string | null>(null);
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
// always reflects the original tool-call output, not the on-disk file after save).
const [savedContent, setSavedContent] = useState<string | null>(null);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
const activeContent = editedContent ?? displayContent;
const isDirty = editedContent !== null && editedContent !== displayContent;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML";
@@ -154,12 +139,34 @@ export function ArtifactFileDetail({
return "FILE";
}, [isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
@@ -322,41 +329,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: activeContent }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail ?? "保存失败");
}
if (isWriteFile) {
setSavedContent(activeContent);
} else {
await queryClient.invalidateQueries({
queryKey: ["artifact", filepathFromProps, threadId],
});
}
setEditedContent(null);
toast.success("已保存");
} catch (error) {
console.error("Save artifact failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -376,7 +349,33 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
return (
<Artifact className={cn(className)}>
@@ -453,30 +452,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
{canEdit && viewMode === "code" && !isDirty && (
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
<PencilIcon className="size-3" />
可编辑
</span>
)}
{canEdit && viewMode === "code" && isDirty && (
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
未保存
</span>
)}
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEdit && viewMode === "code" && (
<ArtifactAction
icon={isSaving ? LoaderIcon : SaveIcon}
label="保存"
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
disabled={isSaving || !isDirty}
onClick={handleSave}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -562,13 +550,19 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{showResumeAutoScroll && (
<button
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
onClick={handleResumeAutoScroll}
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
@@ -580,30 +574,31 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={activeContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div
className="size-full"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
}}
>
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={activeContent}
readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/>
</div>
)}
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -618,15 +613,17 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
}: {
content: string;
language: string;
animated?: boolean;
}) {
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => {
@@ -657,12 +654,15 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
</div>
);
}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
if (language === "html") {
return (
@@ -38,20 +38,22 @@ export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
onChange,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
@@ -79,7 +81,7 @@ export function CodeEditor({
className,
)}
>
{isLoading ? (
{isLoading && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@@ -3,6 +3,7 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -117,6 +118,7 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -128,6 +130,7 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -140,11 +143,22 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
* Optional toggle rendered next to the model selector. Used for the
* admin-configurable prompt-prefix switch on the chat input. Omitting the
* prop hides the button entirely.
*/
promptPrefix?: {
label: string;
on: boolean;
onToggle: () => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -252,6 +266,13 @@ export function InputBox({
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -530,6 +551,26 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none",
context.writing_mode
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
)}
onClick={handleWritingModeToggle}
>
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -819,6 +860,34 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{promptPrefix && (
<Tooltip
content={
promptPrefix.on
? `已开启:${promptPrefix.label}`
: `点击开启:${promptPrefix.label}`
}
>
<PromptInputButton
type="button"
onClick={promptPrefix.onToggle}
aria-pressed={promptPrefix.on}
className={cn(
"rounded-full border px-3 shadow-none transition-colors",
promptPrefix.on
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
)}
>
<span className="max-w-[160px] truncate text-xs font-normal">
{promptPrefix.label}
</span>
{promptPrefix.on && (
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
)}
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
@@ -329,41 +329,43 @@ function ToolCall({
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
const url = new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString();
if (selectedArtifact === url) {
return;
}
select(url, true);
setOpen(true);
}, 100);
}
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || selectedArtifact === artifactPath) {
return;
}
select(artifactPath, true);
setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath) {
return;
}
select(artifactPath);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageTokenUsageList } from "./message-token-usage";
import {
MessageMetricsProvider,
MessageTokenUsageList,
} from "./message-token-usage";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@@ -69,6 +72,7 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
@@ -265,6 +269,7 @@ export function MessageList({
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
</Conversation>
);
@@ -353,9 +358,9 @@ function SetupAgentApprovalCard({
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建专家审批
创建智能体审批
</div>
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
</div>
{approvalPayload.name ? (
@@ -1,28 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
import { CoinsIcon } from "lucide-react";
import { CoinsIcon, GaugeIcon } from "lucide-react";
import { useI18n } from "@/core/i18n/hooks";
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
MessageMetricsContext,
useMessageMetric,
useMessageMetrics,
type MessageMetric,
} from "./use-message-metrics";
const FULL_SCALE_TOKENS_PER_SEC = 80;
function classifySpeed(tps: number): { color: string; label: string } {
if (tps >= 50) return { color: "#16a34a", label: "极快" };
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
if (tps >= 15) return { color: "#eab308", label: "一般" };
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
metric.tokensPerSec >= 100
? Math.round(metric.tokensPerSec).toString()
: metric.tokensPerSec.toFixed(1);
const prefix = metric.fromUsage ? "" : "~";
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
{prefix}
{display}
</span>
<span className="text-muted-foreground">tok/s</span>
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
<span
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
style={{ width: `${fill * 100}%`, backgroundColor: color }}
/>
</span>
</span>
);
}
export function MessageTokenUsage({
className,
enabled = false,
isLoading = false,
message,
metric,
}: {
className?: string;
enabled?: boolean;
isLoading?: boolean;
message: Message;
metric?: MessageMetric;
}) {
const { t } = useI18n();
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
const contextMetric = useMessageMetric(message.id as string | undefined);
const resolvedMetric = metric ?? contextMetric;
if (!enabled || isLoading || message.type !== "ai") {
if (message.type !== "ai") {
return null;
}
// Show speed-only line while streaming (no usage yet) if the user opted in,
// otherwise wait until usage arrives so the bar doesn't render on its own.
const usage = getUsageMetadata(message);
const showUsage = enabled && !isLoading && Boolean(usage);
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
if (!showUsage && !showSpeed) {
return null;
}
return (
<div
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
className,
)}
>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
{usage ? (
{showUsage && usage ? (
<>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
<span>
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
</span>
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
</span>
</>
) : (
<span>{t.tokenUsage.unavailableShort}</span>
)}
) : null}
{showSpeed && resolvedMetric ? (
<>
<SpeedPill metric={resolvedMetric} />
{resolvedMetric.durationMs > 0 && (
<span className="font-mono tabular-nums">
{formatDuration(resolvedMetric.durationMs)}
</span>
)}
</>
) : null}
</div>
);
}
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
isLoading?: boolean;
messages: Message[];
}) {
if (!enabled || isLoading) {
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
if (!enabled && !speedEnabled) {
return null;
}
const aiMessages = messages.filter((message) => message.type === "ai");
if (aiMessages.length === 0) {
return null;
}
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
</>
);
}
/**
* Hoist the metrics map computation to the top of the message list so
* descendant {@link MessageTokenUsage} components can read it via context
* instead of each running its own hook.
*/
export function MessageMetricsProvider({
messages,
isLoading,
children,
}: {
messages: Message[];
isLoading: boolean;
children: React.ReactNode;
}) {
const metrics = useMessageMetrics(messages, isLoading);
return (
<MessageMetricsContext.Provider value={metrics}>
{children}
</MessageMetricsContext.Provider>
);
}
@@ -0,0 +1,235 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
*/
durationMs: number;
/** True while this is the currently streaming message. */
isActive: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
* only to add a "~" prefix when the value is approximate.
*/
fromUsage: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
return message.type === "ai";
}
function getMessageId(message: Message, fallbackIndex: number): string {
const id = (message.id as string | undefined) ?? undefined;
return id ?? `idx-${fallbackIndex}`;
}
function messageText(message: Message): string {
// Count both the visible answer text *and* the reasoning trace so the speed
// indicator stays meaningful during the thinking phase — for reasoning
// models the answer body can be empty for tens of seconds while the model
// streams reasoning tokens, and ignoring those would make the meter sit at
// zero. We treat the two as one continuous stream of generated characters.
const body = textOfMessage(message);
const reasoning = extractReasoningContentFromMessage(message) ?? "";
const bodyText = typeof body === "string" ? body : "";
return bodyText + reasoning;
}
/**
* Returns a Map keyed by AI message id → live metric snapshot.
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
*/
export function useMessageMetrics(
messages: Message[],
isLoading: boolean,
): Map<string, MessageMetric> {
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// Bump a counter to force recomputation when samples push but message refs
// didn't change — needed because the timing map mutation alone wouldn't
// re-render anything.
const [, setTick] = useState(0);
const lastActiveIdRef = useRef<string | null>(null);
// Walk messages and update timings. We intentionally read/write the ref
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const text = messageText(message);
if (!text) continue;
let entry = timingRef.current.get(id);
if (!entry) {
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
}
}
lastActiveIdRef.current = activeId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [isLoading]);
// Build the snapshot map. We compute on every render so consumers see the
// freshest duration; the underlying timing storage is the source of truth.
const out = new Map<string, MessageMetric>();
const now = performance.now();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
}
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
fromUsage,
});
}
return out;
}
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
/**
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
* MessageList down to per-message renderers like MessageListItem, without
* threading a prop through every grouping branch.
*/
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
EMPTY_METRICS,
);
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
const metrics = useContext(MessageMetricsContext);
return messageId ? metrics.get(messageId) : undefined;
}
@@ -1,212 +0,0 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,329 @@
"use client";
import {
BellIcon,
CheckCheckIcon,
ChevronLeftIcon,
Loader2,
SettingsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { useLocalSettings } from "@/core/settings";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
type NotificationView = "list" | "settings";
export function NotificationDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const [view, setView] = useState<NotificationView>("list");
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const { data: unreadCount = 0 } = useUnreadCount();
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
function handleOpenChange(next: boolean) {
onOpenChange(next);
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
if (!next) setView("list");
}
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
{view === "list" ? (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<DialogTitle className="text-base font-semibold">
{t.notifications.title}
</DialogTitle>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.settingsAria}
onClick={() => setView("settings")}
>
<SettingsIcon className="size-4" />
</Button>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-3 py-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
<BellIcon className="text-muted-foreground size-6" />
</div>
<p className="text-muted-foreground text-sm">
{t.notifications.empty}
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
!notification.read
? "border-blue-500/30 bg-blue-500/5"
: "border-border",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.read ? "bg-transparent" : "bg-blue-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p
className={cn(
"truncate text-sm",
notification.read
? "font-medium"
: "font-semibold",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-xs">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
</>
) : (
<NotificationSettingsView onBack={() => setView("list")} />
)}
</DialogContent>
</Dialog>
);
}
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = (enabled: boolean) => {
setSettings("notification", { enabled });
};
return (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.backAria}
onClick={onBack}
>
<ChevronLeftIcon className="size-4" />
</Button>
<DialogTitle className="text-base font-semibold">
{t.notifications.settingsTitle}
</DialogTitle>
</div>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
{!isSupported ? (
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
) : (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t.settings.notification.description}
</p>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useMemo } from "react";
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
if (role === "user") return "普通用户";
return role || "未知角色";
}
function copy(text: string, hint = "已复制") {
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => toast.success(hint))
.catch(() => toast.error("复制失败"));
}
export function AccountSettingsPage() {
const auth = useMemo(() => getStoredAuth(), []);
if (!auth) {
return (
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
没有可显示的账号信息。
</div>
</SettingsSection>
);
}
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
const displayName = usernameFromEmail || auth.user_id;
const handleLogout = () => {
clearStoredAuth();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
});
};
return (
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
<UserIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="text-muted-foreground truncate text-xs">
{roleLabel(auth.system_role)}
</div>
</div>
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
{roleLabel(auth.system_role)}
</Badge>
</div>
<InfoRow
label="用户名"
value={displayName}
onCopy={() => copy(displayName, "用户名已复制")}
/>
<InfoRow
label="用户 ID"
value={auth.user_id}
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
/>
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
<InfoRow
label="账号状态"
value={auth.needs_setup ? "待初始化" : "已就绪"}
/>
<div className="flex justify-end pt-2">
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
退出登录
</Button>
</div>
</div>
</SettingsSection>
);
}
function InfoRow({
label,
value,
onCopy,
}: {
label: string;
value: string;
onCopy?: () => void;
}) {
return (
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
{onCopy && value && (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={onCopy}
aria-label={`复制${label}`}
>
<CopyIcon className="size-3.5" />
</Button>
)}
</div>
);
}
@@ -12,8 +12,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
</SelectContent>
</Select>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。
</p>
</div>
<Switch
checked={settings.display.streamSpeedMeterEnabled}
onCheckedChange={(checked) =>
setSettings("display", { streamSpeedMeterEnabled: checked })
}
aria-label="回答速度指示器"
/>
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useUpsertMCPServer } from "@/core/mcp/hooks";
import type {
MCPServerConfig,
MCPServerTransport,
} from "@/core/mcp/types";
type EditMode = "create" | "edit";
export interface MCPServerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: EditMode;
originalName?: string;
initialServer?: MCPServerConfig;
existingNames: string[];
}
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
{ value: "stdio", label: "stdio(本地进程)" },
{ value: "http", label: "http(远端 HTTP)" },
{ value: "sse", label: "sse(远端 SSE)" },
];
const DEFAULT_SERVER: MCPServerConfig = {
enabled: true,
type: "stdio",
command: "",
args: [],
env: {},
url: "",
headers: {},
description: "",
disabled_tools: [],
};
function argsToText(args?: string[]): string {
return (args ?? []).join("\n");
}
function textToArgs(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function kvToText(map?: Record<string, string>): string {
if (!map) return "";
return Object.entries(map)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function textToKV(text: string): { value: Record<string, string>; error?: string } {
const result: Record<string, string> = {};
const lines = text.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const eq = line.indexOf("=");
if (eq <= 0) {
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
}
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1);
if (!key) {
return { value: {}, error: `键名不能为空:${line}` };
}
result[key] = value;
}
return { value: result };
}
export function MCPServerEditDialog({
open,
onOpenChange,
mode,
originalName,
initialServer,
existingNames,
}: MCPServerEditDialogProps) {
const upsert = useUpsertMCPServer();
const [name, setName] = useState("");
const [type, setType] = useState<MCPServerTransport>("stdio");
const [enabled, setEnabled] = useState(true);
const [description, setDescription] = useState("");
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [envText, setEnvText] = useState("");
const [url, setUrl] = useState("");
const [headersText, setHeadersText] = useState("");
const [disabledToolsText, setDisabledToolsText] = useState("");
useEffect(() => {
if (!open) return;
const base = initialServer ?? DEFAULT_SERVER;
setName(originalName ?? "");
setType((base.type ?? "stdio") as MCPServerTransport);
setEnabled(base.enabled);
setDescription(base.description ?? "");
setCommand(base.command ?? "");
setArgsText(argsToText(base.args));
setEnvText(kvToText(base.env));
setUrl(base.url ?? "");
setHeadersText(kvToText(base.headers));
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
}, [open, initialServer, originalName]);
const nameConflict = useMemo(() => {
const trimmed = name.trim();
if (!trimmed) return false;
if (mode === "edit" && trimmed === originalName) return false;
return existingNames.includes(trimmed);
}, [name, existingNames, mode, originalName]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("请填写 MCP 服务器名称");
return;
}
if (nameConflict) {
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
return;
}
const env = textToKV(envText);
if (env.error) {
toast.error(`环境变量格式错误:${env.error}`);
return;
}
const headers = textToKV(headersText);
if (headers.error) {
toast.error(`Headers 格式错误:${headers.error}`);
return;
}
const args = textToArgs(argsText);
const disabledTools = textToArgs(disabledToolsText);
if (type === "stdio" && !command.trim()) {
toast.error("stdio 传输需要填写启动命令");
return;
}
if ((type === "http" || type === "sse") && !url.trim()) {
toast.error(`${type} 传输需要填写服务器 URL`);
return;
}
const server: MCPServerConfig = {
enabled,
type,
description: description.trim(),
command: type === "stdio" ? command.trim() : null,
args: type === "stdio" ? args : [],
env: type === "stdio" ? env.value : {},
url: type === "stdio" ? null : url.trim(),
headers: type === "stdio" ? {} : headers.value,
disabled_tools: disabledTools,
};
try {
await upsert.mutateAsync({
serverName: trimmedName,
server,
originalName: mode === "edit" ? originalName : undefined,
});
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 github、filesystem"
aria-invalid={nameConflict || undefined}
/>
{nameConflict && (
<div className="text-destructive text-xs">已存在同名服务器</div>
)}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="传输类型">
<Select
value={type}
onValueChange={(v) => setType(v as MCPServerTransport)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="启用">
<div className="flex h-9 items-center">
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-muted-foreground ml-2 text-xs">
{enabled ? "已启用" : "已停用"}
</span>
</div>
</Field>
</div>
<Field label="描述">
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
/>
</Field>
{type === "stdio" && (
<>
<Field
label="启动命令"
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
/>
</Field>
<Field label="参数" hint="每行一个参数">
<Textarea
rows={3}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
</Field>
<Field
label="环境变量"
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
>
<Textarea
rows={3}
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
/>
</Field>
</>
)}
{(type === "http" || type === "sse") && (
<>
<Field label="服务器 URL">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/mcp"
/>
</Field>
<Field label="Headers" hint="每行 KEY=VALUE">
<Textarea
rows={3}
value={headersText}
onChange={(e) => setHeadersText(e.target.value)}
placeholder={"Authorization=Bearer xxx"}
/>
</Field>
</>
)}
<Field
label="禁用的工具"
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
>
<Textarea
rows={2}
value={disabledToolsText}
onChange={(e) => setDisabledToolsText(e.target.value)}
placeholder={"some_dangerous_tool"}
/>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={upsert.isPending}>
{upsert.isPending
? "保存中..."
: mode === "create"
? "创建"
: "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<div className="text-sm font-medium">{label}</div>
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
{children}
</div>
);
}
@@ -1,92 +0,0 @@
"use client";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { SettingsSection } from "./settings-section";
export function NotificationSettingsPage() {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = async (enabled: boolean) => {
setSettings("notification", {
enabled,
});
};
if (!isSupported) {
return (
<SettingsSection
title={t.settings.notification.title}
description={t.settings.notification.description}
>
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
</SettingsSection>
);
}
return (
<SettingsSection
title={t.settings.notification.title}
description={
<div className="flex items-center gap-2">
<div>{t.settings.notification.description}</div>
<div>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<div className="flex flex-col gap-4">
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,12 +1,6 @@
"use client";
import {
BellIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react";
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -17,20 +11,18 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "skills"
| "notification"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
@@ -58,26 +50,23 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.appearance,
icon: PaletteIcon,
},
{
id: "notification",
label: t.settings.sections.notification,
icon: BellIcon,
},
{
id: "memory",
label: t.settings.sections.memory,
icon: BrainIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{
id: "account",
label: "个人信息",
icon: UserIcon,
},
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
t.settings.sections.notification,
t.settings.sections.about,
],
);
@@ -123,11 +112,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
</nav>
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -12,6 +12,7 @@ import {
PinIcon,
PinOffIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
@@ -46,6 +47,9 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -54,6 +58,7 @@ import {
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkillContent,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -66,44 +71,35 @@ import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
<SkillSettingsList onClose={onClose} />
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("all");
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
@@ -115,8 +111,6 @@ function SkillSettingsList({
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
@@ -127,7 +121,7 @@ function SkillSettingsList({
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
if (isBuiltin || !!skill.published) {
all.push(skill);
}
if (isOwnedByMe) {
@@ -156,8 +150,8 @@ function SkillSettingsList({
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
</TabsList>
</Tabs>
<Button
@@ -211,10 +205,22 @@ function SkillSettingsList({
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="max-w-xl"
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<SkillRow
key={skill.name}
@@ -258,6 +264,8 @@ function SkillRow({
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -308,7 +316,10 @@ function SkillRow({
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
@@ -392,8 +403,22 @@ function SkillRow({
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
@@ -473,10 +498,64 @@ function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
<SkillDetailDialog
skill={skill}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</>
);
}
function SkillDetailDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -1,5 +1,18 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Item,
ItemActions,
@@ -8,16 +21,54 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
import {
useDeleteMCPServer,
useEnableMCPServer,
useMCPConfig,
} from "@/core/mcp/hooks";
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
const [editMode, setEditMode] = useState<"create" | "edit">("create");
const [editingName, setEditingName] = useState<string | undefined>(undefined);
const [editingServer, setEditingServer] = useState<
MCPServerConfig | undefined
>(undefined);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const servers = useMemo(() => {
if (!config) return [] as Array<[string, MCPServerConfig]>;
return Object.entries(config.mcp_servers);
}, [config]);
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
const handleCreate = () => {
setEditMode("create");
setEditingName(undefined);
setEditingServer(undefined);
setEditOpen(true);
};
const handleEdit = (name: string, server: MCPServerConfig) => {
setEditMode("edit");
setEditingName(name);
setEditingServer(server);
setEditOpen(true);
};
return (
<SettingsSection
title={t.settings.tools.title}
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
<div className="text-destructive text-sm">Error: {error.message}</div>
) : (
config && <MCPServerList servers={config.mcp_servers} />
<div className="flex w-full flex-col gap-3">
{isAdmin && (
<div className="flex justify-end">
<Button
size="sm"
onClick={handleCreate}
disabled={isStaticMode}
>
<Plus className="size-4" />
新增 MCP 服务器
</Button>
</div>
)}
<MCPServerList
servers={servers}
isAdmin={isAdmin}
isStaticMode={isStaticMode}
onEdit={handleEdit}
onDelete={(name) => setDeleteTarget(name)}
/>
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</SettingsSection>
);
}
function MCPServerList({
servers,
isAdmin,
isStaticMode,
onEdit,
onDelete,
}: {
servers: Record<string, MCPServerConfig>;
servers: Array<[string, MCPServerConfig]>;
isAdmin: boolean;
isStaticMode: boolean;
onEdit: (name: string, server: MCPServerConfig) => void;
onDelete: (name: string) => void;
}) {
const { mutate: enableMCPServer } = useEnableMCPServer();
if (servers.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
{isAdmin
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
: "管理员还没有配置任何 MCP 服务器。"}
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{Object.entries(servers).map(([name, config]) => (
{servers.map(([name, config]) => (
<Item className="w-full" variant="outline" key={name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">
<div>{name}</div>
<div className="flex flex-wrap items-center gap-2">
<span>{name}</span>
<Badge variant="outline" className="text-xs">
{config.type ?? "stdio"}
</Badge>
{config.disabled_tools &&
config.disabled_tools.length > 0 && (
<Badge variant="secondary" className="text-xs">
已禁用 {config.disabled_tools.length} 个工具
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{config.description}
{config.description ||
(config.type === "stdio"
? config.command
: config.url) ||
"暂无描述"}
</ItemDescription>
</ItemContent>
<ItemActions>
<ItemActions className="flex items-center gap-2">
{isAdmin && (
<>
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={() => onEdit(name, config)}
disabled={isStaticMode}
aria-label="编辑"
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8"
onClick={() => onDelete(name)}
disabled={isStaticMode}
aria-label="删除"
>
<Trash2 className="size-4" />
</Button>
</>
)}
<Switch
checked={config.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
disabled={isStaticMode || !isAdmin}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
@@ -68,3 +209,49 @@ function MCPServerList({
</div>
);
}
function DeleteConfirmDialog({
target,
onOpenChange,
}: {
target: string | null;
onOpenChange: (open: boolean) => void;
}) {
const deleteMcp = useDeleteMCPServer();
const handleConfirm = async () => {
if (!target) return;
try {
await deleteMcp.mutateAsync({ serverName: target });
toast.success(`已删除 MCP 服务器:${target}`);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>删除 MCP 服务器</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm">
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={deleteMcp.isPending}
>
{deleteMcp.isPending ? "删除中..." : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -154,6 +154,73 @@ function StatusCard() {
</span>
</span>
</StatRow>
<StatRow label={tt.lastActions} className="sm:col-span-2">
<div className="flex flex-col gap-2 text-xs">
<div>
<div className="font-medium">{tt.llmSuggested}</div>
{status.last_consolidation_suggestions.consolidations.length ===
0 &&
status.last_consolidation_suggestions.prunings.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
{status.last_consolidation_suggestions.consolidations.map(
(c, i) => (
<li key={`c${i}`}>
{(c.from ?? "?") + " → " + (c.into ?? "?")}
{c.reason ? `(${c.reason})` : ""}
</li>
),
)}
{status.last_consolidation_suggestions.prunings.map(
(p, i) => (
<li key={`p${i}`}>
{p.name ?? "?"}
{p.reason ? `(${p.reason})` : ""}
</li>
),
)}
</ul>
)}
</div>
<div>
<div className="font-medium">{tt.actuallyDid}</div>
{status.last_actual_actions.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="mt-0.5 flex flex-col gap-1">
{status.last_actual_actions.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5"
>
<span className="font-mono">{a.tool ?? "?"}</span>
{typeof a.args?.name === "string" && (
<span className="text-muted-foreground">
{String(a.args.name)}
</span>
)}
<Badge
variant={
a.status === "error"
? "destructive"
: "secondary"
}
className="px-1 text-[10px]"
>
{a.status}
</Badge>
</li>
))}
</ul>
)}
</div>
</div>
</StatRow>
</div>
) : null}
</CardContent>
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
import { resolveTagColor } from "./tag-colors";
/** A read-only (or removable) coloured chip for a single tag. */
export function TagChip({
tag,
onRemove,
className,
}: {
tag: Pick<Tag, "name" | "color">;
onRemove?: () => void;
className?: string;
}) {
const color = resolveTagColor(tag.color);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
}
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
export function TagChipList({
tags,
className,
}: {
tags: Pick<Tag, "name" | "color">[];
className?: string;
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
</div>
);
}
@@ -0,0 +1,30 @@
// Fixed tag color palette. The backend only stores the color *name*; the
// Tailwind classes are resolved here so the class strings stay static and
// survive the production purge.
export interface TagColorDef {
name: string;
/** Classes for a rendered chip (background + text + border). */
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
];
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
}
@@ -0,0 +1,140 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
onOpenChange,
targetType,
targetId,
currentTags,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
currentTags: Tag[];
}) {
const { t } = useI18n();
const { data: allTags, isLoading } = useTags(targetType);
const setResourceTags = useSetResourceTags();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}`;
if (open && syncedFor !== syncKey) {
setSelected(new Set(currentTags.map((tg) => tg.id)));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
// Clicking a tag mutates immediately. Local state updates optimistically and
// rolls back if the request fails.
const toggle = (tagId: string) => {
const prev = selected;
const next = new Set(prev);
if (next.has(tagId)) next.delete(tagId);
else next.add(tagId);
setSelected(next);
setResourceTags.mutate(
{ targetType, targetId, tagIds: [...next] },
{
onError: (e) => {
setSelected(prev);
toast.error(String(e instanceof Error ? e.message : e));
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
</DialogHeader>
<div className="min-h-[120px]">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{!isLoading && (!allTags || allTags.length === 0) && (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.adminTags.pickerEmpty}
</div>
)}
{!isLoading && allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const checked = selected.has(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
color.chip,
checked
? "ring-2 ring-offset-1 ring-foreground/40"
: "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3.5" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-muted-foreground text-xs">
{setResourceTags.isPending ? (
<span className="flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t.adminTags.saving}
</span>
) : (
t.adminTags.pickerSelectedCount.replace(
"{count}",
String(selected.size),
)
)}
</span>
<Button onClick={() => onOpenChange(false)}>
{t.adminTags.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,80 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
const ALL = "__all__";
/**
* Reusable name-search + tag-filter toolbar for resource list pages.
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke. Filtering is open to all users.
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagId,
onTagIdChange,
className,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagId: string | null;
onTagIdChange: (value: string | null) => void;
className?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
const [input, setInput] = useState("");
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
}, [input]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
</div>
<Select
value={tagId ?? ALL}
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t.adminTags.filterByTag} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
{(tags ?? []).map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -1,6 +1,15 @@
"use client";
import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
import {
BellIcon,
ChevronsUpDown,
GaugeIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
TagsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -19,8 +28,9 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import { NotificationBell } from "./notification-bell";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -50,11 +60,13 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "notification" | "about"
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
@@ -67,10 +79,11 @@ export function WorkspaceNavMenu() {
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
@@ -88,6 +101,15 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
@@ -107,6 +129,34 @@ export function WorkspaceNavMenu() {
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+13 -2
View File
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
export interface ListAgentsParams {
search?: string;
tagId?: string | null;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createAgent,
@@ -9,10 +14,13 @@ import {
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
queryKey: ["agents", search, tagId],
queryFn: () => listAgents({ search, tagId }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
+3
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export interface Agent {
id: string;
name: string;
@@ -11,6 +13,7 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
}
export interface CreateAgentRequest {
+40
View File
@@ -0,0 +1,40 @@
import { apiFetch } from "@/core/api";
import { urlOfArtifact } from "./utils";
async function errorMessageFromResponse(res: Response): Promise<string> {
try {
const data: unknown = await res.json();
if (data && typeof data === "object" && "detail" in data) {
const detail = (data as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
}
} catch {
// ignore non-json errors
}
return res.statusText || "Request failed";
}
export async function updateArtifactContent({
filepath,
threadId,
content,
isMock,
}: {
filepath: string;
threadId: string;
content: string;
isMock?: boolean;
}) {
const res = await apiFetch(urlOfArtifact({ filepath, threadId, isMock }), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
return res.text();
}
+101 -16
View File
@@ -1,9 +1,65 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { updateArtifactContent } from "./api";
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
function applyPendingTextEdit({
content,
filepath,
thread,
}: {
content: string | undefined;
filepath: string;
thread: ReturnType<typeof useThread>["thread"];
}) {
if (!content || !thread.isLoading) {
return null;
}
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as {
path?: unknown;
old_str?: unknown;
new_str?: unknown;
replace_all?: unknown;
};
if (args.path !== filepath) {
continue;
}
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
continue;
}
if (!content.includes(args.old_str)) {
return { content, kind: "editing" as const };
}
const nextContent =
args.replace_all === true
? content.split(args.old_str).join(args.new_str)
: content.replace(args.old_str, args.new_str);
return { content: nextContent, kind: "editing" as const };
}
}
return null;
}
export function useArtifactContent({
filepath,
@@ -25,19 +81,48 @@ export function useArtifactContent({
return null;
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
},
enabled,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
isLoading,
error,
};
}
enabled,
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
const pendingEdit = useMemo(() => {
if (isWriteFile) {
return null;
}
return applyPendingTextEdit({
content: data?.content,
filepath,
thread,
});
}, [data?.content, filepath, isWriteFile, thread]);
return {
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
url: isWriteFile ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
};
}
export function useUpdateArtifactContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateArtifactContent,
onSuccess: (content, variables) => {
queryClient.setQueryData(
["artifact", variables.filepath, variables.threadId, variables.isMock],
{
content,
url: undefined,
},
);
},
});
}
+3 -2
View File
@@ -1,2 +1,3 @@
export * from "./download";
export * from "./loader";
export * from "./api";
export * from "./download";
export * from "./loader";
+27
View File
@@ -11,6 +11,29 @@ export interface CuratorConfig {
curator: CuratorSubConfig;
}
export interface CuratorAction {
tool: string | null;
args: Record<string, unknown>;
status: string;
result: string;
}
export interface CuratorConsolidation {
from?: string;
into?: string;
reason?: string;
}
export interface CuratorPruning {
name?: string;
reason?: string;
}
export interface CuratorSuggestions {
consolidations: CuratorConsolidation[];
prunings: CuratorPruning[];
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
@@ -23,6 +46,10 @@ export interface CuratorStatus {
stale: number;
archived: number;
};
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
last_consolidation_suggestions: CuratorSuggestions;
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
last_actual_actions: CuratorAction[];
}
export interface CuratorLogRecord {
+42 -1
View File
@@ -456,7 +456,7 @@ export const enUS: Translations = {
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "All",
tabAll: "Square",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
@@ -557,6 +557,10 @@ export const enUS: Translations = {
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
lastActions: "Last run",
llmSuggested: "LLM suggested (self-report)",
actuallyDid: "Actually done (tool calls)",
noActions: "None",
},
config: {
title: "Configuration",
@@ -625,6 +629,40 @@ export const enUS: Translations = {
empty: "No users found",
},
adminTags: {
title: "Tag Management",
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
typeAgent: "Agent Tags",
typeSkill: "Skill Tags",
typeScheduledTask: "Scheduled Task Tags",
name: "Name",
color: "Color",
tagDescription: "Description",
usageCount: "Usage",
actions: "Actions",
empty: "No tags yet",
createButton: "New Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
namePlaceholder: "Tag name",
descriptionPlaceholder: "Optional description",
save: "Save",
cancel: "Cancel",
edit: "Edit",
delete: "Delete",
pickerTitle: "Edit Tags",
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
pickerSelectedCount: "{count} selected",
saving: "Saving…",
done: "Done",
manageTags: "Tags",
searchPlaceholder: "Search by name",
filterByTag: "Filter by tag",
allTags: "All tags",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
@@ -632,5 +670,8 @@ export const enUS: Translations = {
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
settingsAria: "Notification settings",
backAria: "Back",
settingsTitle: "Notification settings",
},
};
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
markAllRead: string;
empty: string;
deleteAria: string;
settingsAria: string;
backAria: string;
settingsTitle: string;
}
export interface Translations {
@@ -479,6 +482,10 @@ export interface Translations {
total: string;
runNow: string;
runStarted: string;
lastActions: string;
llmSuggested: string;
actuallyDid: string;
noActions: string;
};
config: {
title: string;
@@ -538,6 +545,43 @@ export interface Translations {
empty: string;
};
// Admin tag management page
adminTags: {
title: string;
description: string;
typeAgent: string;
typeSkill: string;
typeScheduledTask: string;
name: string;
color: string;
tagDescription: string;
usageCount: string;
actions: string;
empty: string;
createButton: string;
createTitle: string;
editTitle: string;
deleteConfirm: string;
namePlaceholder: string;
descriptionPlaceholder: string;
save: string;
cancel: string;
edit: string;
delete: string;
// tag picker dialog
pickerTitle: string;
pickerHint: string;
pickerEmpty: string;
pickerSelectedCount: string;
saving: string;
done: string;
manageTags: string;
// search controls on resource lists
searchPlaceholder: string;
filterByTag: string;
allTags: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+44 -3
View File
@@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "全部",
tabAll: "广场",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
@@ -452,7 +452,7 @@ export const zhCN: Translations = {
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
@@ -533,6 +533,10 @@ export const zhCN: Translations = {
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
lastActions: "最近一次运行",
llmSuggested: "LLM 建议(自述)",
actuallyDid: "实际执行(工具调用)",
noActions: "无",
},
config: {
title: "配置",
@@ -594,6 +598,40 @@ export const zhCN: Translations = {
empty: "暂无用户",
},
adminTags: {
title: "标签管理",
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
typeAgent: "智能体标签",
typeSkill: "技能标签",
typeScheduledTask: "定时任务标签",
name: "名称",
color: "颜色",
tagDescription: "描述",
usageCount: "使用数",
actions: "操作",
empty: "暂无标签",
createButton: "新建标签",
createTitle: "创建标签",
editTitle: "编辑标签",
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
namePlaceholder: "标签名称",
descriptionPlaceholder: "描述(可选)",
save: "保存",
cancel: "取消",
edit: "编辑",
delete: "删除",
pickerTitle: "编辑标签",
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
pickerSelectedCount: "已选 {count} 个",
saving: "保存中…",
done: "完成",
manageTags: "标签",
searchPlaceholder: "按名称搜索",
filterByTag: "按标签筛选",
allTags: "全部标签",
},
notifications: {
title: "通知",
bellAria: "打开通知",
@@ -601,5 +639,8 @@ export const zhCN: Translations = {
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
settingsAria: "通知设置",
backAria: "返回",
settingsTitle: "通知设置",
},
};
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface LlmMetric {
id: string;
created_at?: string | null;
user_id?: string | null;
user_name?: string | null;
thread_id?: string | null;
run_id?: string | null;
agent_name?: string | null;
model_name?: string | null;
duration_ms: number;
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
tokens_per_sec?: number | null;
status: "success" | "error" | string;
error_type?: string | null;
error_message?: string | null;
}
export interface LlmMetricsListResponse {
items: LlmMetric[];
total: number;
limit: number;
offset: number;
}
export interface LlmMetricsFilters {
user_id?: string;
user_name?: string;
model_name?: string;
status?: "success" | "error";
since?: string;
until?: string;
}
export interface LlmMetricsListQuery extends LlmMetricsFilters {
limit?: number;
offset?: number;
}
function buildQuery(filters: LlmMetricsListQuery): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "") continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export async function loadLlmMetrics(
query: LlmMetricsListQuery,
): Promise<LlmMetricsListResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to load LLM metrics");
}
return (await response.json()) as LlmMetricsListResponse;
}
/**
* Trigger the Excel download. Returns the blob URL used so callers can revoke
* it if they want; the function itself handles the click and cleanup.
*/
export async function downloadLlmMetricsExcel(
filters: LlmMetricsFilters,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to export LLM metrics");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = (() => {
const header = response.headers.get("content-disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain?.[1] ?? `llm_call_metrics.xlsx`;
})();
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
export function useLlmMetrics(query: LlmMetricsListQuery) {
return useQuery({
queryKey: ["llmMetrics", query],
queryFn: () => loadLlmMetrics(query),
refetchOnWindowFocus: false,
placeholderData: (previous) => previous,
});
}
+42
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
},
});
}
export function useUpsertMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({
serverName,
server,
originalName,
}: {
serverName: string;
server: MCPServerConfig;
originalName?: string;
}) => {
const next = { ...(config?.mcp_servers ?? {}) };
if (originalName && originalName !== serverName) {
delete next[originalName];
}
next[serverName] = server;
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
export function useDeleteMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({ serverName }: { serverName: string }) => {
const next = { ...(config?.mcp_servers ?? {}) };
delete next[serverName];
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
+28 -1
View File
@@ -1,6 +1,33 @@
export interface MCPServerConfig extends Record<string, unknown> {
export type MCPServerTransport = "stdio" | "http" | "sse";
export interface MCPOAuthConfig {
enabled?: boolean;
token_url?: string;
grant_type?: "client_credentials" | "refresh_token";
client_id?: string | null;
client_secret?: string | null;
refresh_token?: string | null;
scope?: string | null;
audience?: string | null;
token_field?: string;
token_type_field?: string;
expires_in_field?: string;
default_token_type?: string;
refresh_skew_seconds?: number;
extra_token_params?: Record<string, string>;
}
export interface MCPServerConfig {
enabled: boolean;
type?: MCPServerTransport;
command?: string | null;
args?: string[];
env?: Record<string, string>;
url?: string | null;
headers?: Record<string, string>;
oauth?: MCPOAuthConfig | null;
description: string;
disabled_tools?: string[];
}
export interface MCPConfig {
+13 -2
View File
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
export interface ListScheduledTasksParams {
search?: string;
tagId?: string | null;
}
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
);
}
export async function createScheduledTask(data: {
+14 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createScheduledTask,
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
});
}
export function useScheduledTasks() {
export function useScheduledTasks(
params: { search?: string; tagId?: string | null } = {},
) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
queryKey: ["scheduled-tasks", "list", search, tagId],
queryFn: () => listScheduledTasks({ search, tagId }),
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
@@ -1,104 +1,181 @@
/**
* Convert markdown text to a Word (.docx) Blob in the browser.
*
* Implementation notes:
* - Uses the `docx` npm package to construct an OOXML document client-side.
* - Parser is intentionally lightweight: it covers the markdown features that
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
* - We deliberately do NOT reach for a full markdown AST library (remark/
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
* degrade to plain text rather than failing loudly.
* Designed for scheduled-task reports that often contain Chinese text,
* markdown tables, and emojis. Compared to Word's stock heading styles we
* deliberately:
* - Force every run color to pure black (`#000000`) so headings, links,
* and inline code never render as Word's default blue / hyperlink theme.
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
* instead of falling back to plain text rows that overflow the page.
* - Strip variation selectors, zero-width joiners, and other invisible
* characters that often break Word renderers when emojis leak into the
* report.
*
* Block grammar handled line-by-line:
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Pipe table: contiguous lines starting with `|` plus a separator row
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
*
* Inline grammar handled:
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
*/
import {
AlignmentType,
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
type IRunOptions,
} from "docx";
const HEADING_LEVELS = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
];
const BLACK = "000000";
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
const HEADING_SIZES_HALF_PT: Record<number, number> = {
// Word "size" property is half-points. 32 = 16pt.
1: 36,
2: 32,
3: 28,
4: 26,
5: 24,
6: 22,
};
const BODY_SIZE_HALF_PT = 22; // 11pt
type InlineSegment = {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
};
/**
* Drop characters that Word renders inconsistently or that frequently smuggle
* emoji glitches into corporate reports.
*
* Removes:
* - Variation selectors (FE00–FE0F, E0100–E01EF)
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
* - Soft hyphen (00AD)
* - Object replacement character (FFFC)
*/
function sanitizeText(text: string): string {
return text
.replace(/[︀-️]/g, "")
.replace(/[​-‍]/g, "")
.replace(/­/g, "")
.replace(//g, "")
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
}
/**
* Tokenise a single line of markdown into formatted segments.
*
* Recognised inline syntax:
* `code`, **bold**, __bold__, *italic*, _italic_
* Order matters: code spans win over emphasis so backticks don't get parsed
* as italic delimiters.
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
* Markdown links are flattened to their text content — we deliberately do not
* emit hyperlink runs because they pick up Word's blue theme color.
*/
function parseInlineSegments(text: string): InlineSegment[] {
const cleaned = sanitizeText(text);
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
const tokens: InlineSegment[] = [];
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
const pattern =
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
while ((match = pattern.exec(withoutLinks)) !== null) {
if (match.index > cursor) {
tokens.push({ text: text.slice(cursor, match.index) });
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
}
if (match[2] !== undefined) {
tokens.push({ text: match[2], code: true });
} else if (match[3] !== undefined) {
tokens.push({ text: match[3], bold: true });
} else if (match[4] !== undefined) {
tokens.push({ text: match[4], bold: true });
} else if (match[5] !== undefined) {
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
else if (match[5] !== undefined)
tokens.push({ text: match[5], italic: true });
} else if (match[6] !== undefined) {
else if (match[6] !== undefined)
tokens.push({ text: match[6], italic: true });
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
tokens.push({ text: text.slice(cursor) });
if (cursor < withoutLinks.length) {
tokens.push({ text: withoutLinks.slice(cursor) });
}
return tokens.length > 0 ? tokens : [{ text }];
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
}
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
function segmentsToRuns(
segments: InlineSegment[],
extra?: Partial<IRunOptions>,
): TextRun[] {
return segments.map((seg) => {
const opts: IRunOptions = {
text: seg.text,
bold: seg.bold,
italics: seg.italic,
color: BLACK,
font: seg.code ? MONO_FONT : BODY_FONT,
size: extra?.size ?? BODY_SIZE_HALF_PT,
...extra,
};
if (seg.code) {
opts.font = { name: "Consolas" };
}
return new TextRun(opts);
});
}
function makeParagraphFromInline(text: string): Paragraph {
return new Paragraph({
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeHeadingParagraph(level: number, text: string): Paragraph {
const clamped = Math.max(1, Math.min(level, 6));
return new Paragraph({
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
children: segmentsToRuns(parseInlineSegments(text)),
// We attach a heading level so the table-of-contents still works, but the
// run-level color / font / size below override Word's blue theme.
heading:
({
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
6: HeadingLevel.HEADING_6,
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
spacing: { before: 200, after: 120 },
children: segmentsToRuns(parseInlineSegments(text), {
bold: true,
color: BLACK,
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
}),
});
}
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
function makeListItem(
text: string,
ordered: boolean,
level: number,
): Paragraph {
return new Paragraph({
spacing: { after: 80 },
children: segmentsToRuns(parseInlineSegments(text)),
numbering: ordered
? { reference: "ordered-list", level: Math.min(level, 8) }
@@ -109,10 +186,13 @@ function makeListItem(text: string, ordered: boolean, level: number): Paragraph
function makeCodeParagraph(line: string): Paragraph {
return new Paragraph({
spacing: { after: 0 },
children: [
new TextRun({
text: line || " ",
font: { name: "Consolas" },
text: sanitizeText(line) || " ",
font: MONO_FONT,
color: BLACK,
size: BODY_SIZE_HALF_PT,
}),
],
});
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
function makeQuoteParagraph(text: string): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
indent: { left: 720 },
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
});
}
function makeHorizontalRule(): Paragraph {
return new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "────────" })],
spacing: { before: 120, after: 120 },
children: [
new TextRun({
text: "────────",
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
}),
],
});
}
const CELL_BORDER = {
style: BorderStyle.SINGLE,
size: 4,
color: "BFBFBF",
};
function makeTableCell(
text: string,
options: { header?: boolean } = {},
): TableCell {
const segments = parseInlineSegments(text);
return new TableCell({
margins: { top: 80, bottom: 80, left: 100, right: 100 },
children: [
new Paragraph({
spacing: { after: 0 },
children: segmentsToRuns(segments, {
bold: options.header || undefined,
size: BODY_SIZE_HALF_PT,
}),
}),
],
});
}
function splitTableRow(line: string): string[] {
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
function buildTable(headerLine: string, bodyLines: string[]): Table {
const headerCells = splitTableRow(headerLine);
const colCount = headerCells.length;
const rows: TableRow[] = [];
rows.push(
new TableRow({
tableHeader: true,
children: headerCells.map((cell) =>
makeTableCell(cell, { header: true }),
),
}),
);
for (const row of bodyLines) {
const cells = splitTableRow(row);
// Pad/truncate to match the header column count so Word renders evenly.
const normalized = Array.from(
{ length: colCount },
(_, i) => cells[i] ?? "",
);
rows.push(
new TableRow({
children: normalized.map((c) => makeTableCell(c)),
}),
);
}
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
borders: {
top: CELL_BORDER,
bottom: CELL_BORDER,
left: CELL_BORDER,
right: CELL_BORDER,
insideHorizontal: CELL_BORDER,
insideVertical: CELL_BORDER,
},
});
}
function isTableSeparator(line: string): boolean {
// e.g. |---|:---:|---:| with optional spaces
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
}
function looksLikeTableRow(line: string): boolean {
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
}
/**
* Convert markdown text into an array of docx Paragraph objects.
*
* Block grammar handled (line-by-line):
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
* Convert markdown text into an array of docx block elements (Paragraph | Table).
*/
function markdownToParagraphs(markdown: string): Paragraph[] {
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const paragraphs: Paragraph[] = [];
const blocks: Array<Paragraph | Table> = [];
let inFence = false;
let paragraphBuffer: string[] = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
paragraphBuffer = [];
};
for (const rawLine of lines) {
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i] ?? "";
const line = rawLine.replace(/\t/g, " ");
if (line.trim().startsWith("```")) {
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
continue;
}
if (inFence) {
paragraphs.push(makeCodeParagraph(line));
blocks.push(makeCodeParagraph(line));
continue;
}
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
blocks.push(
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
);
continue;
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
flushParagraph();
paragraphs.push(makeHorizontalRule());
blocks.push(makeHorizontalRule());
continue;
}
const blockquote = /^>\s?(.*)$/.exec(line);
if (blockquote) {
flushParagraph();
paragraphs.push(makeQuoteParagraph(blockquote[1]));
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
continue;
}
// Table detection — a header row followed by a separator row.
if (
looksLikeTableRow(line) &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1] ?? "")
) {
flushParagraph();
const headerLine = line;
i += 2; // skip header + separator
const body: string[] = [];
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
body.push(lines[i] ?? "");
i++;
}
// Re-step back one so the outer loop's i++ lands on the next unprocessed
// line. Without this, blank lines after the table get swallowed.
i--;
blocks.push(buildTable(headerLine, body));
// Word's table rendering eats following spacing; inject a tiny gap.
blocks.push(
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
);
continue;
}
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
if (orderedItem) {
flushParagraph();
const indent = Math.floor(orderedItem[1].length / 2);
paragraphs.push(makeListItem(orderedItem[3], true, indent));
const indent = Math.floor(orderedItem[1]!.length / 2);
blocks.push(makeListItem(orderedItem[3]!, true, indent));
continue;
}
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (unorderedItem) {
flushParagraph();
const indent = Math.floor(unorderedItem[1].length / 2);
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
const indent = Math.floor(unorderedItem[1]!.length / 2);
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
continue;
}
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
}
flushParagraph();
return paragraphs;
return blocks;
}
/**
* Convert a markdown string into a .docx Blob suitable for download.
*/
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
const children: Paragraph[] = [];
export async function markdownToDocxBlob(
markdown: string,
title?: string,
): Promise<Blob> {
const children: Array<Paragraph | Table> = [];
if (title?.trim()) {
children.push(
new Paragraph({
heading: HeadingLevel.TITLE,
children: [new TextRun({ text: title.trim(), bold: true })],
spacing: { before: 0, after: 240 },
children: [
new TextRun({
text: sanitizeText(title.trim()),
bold: true,
color: BLACK,
font: BODY_FONT,
size: 40, // 20pt
}),
],
}),
);
}
children.push(...markdownToParagraphs(markdown));
children.push(...markdownToBlocks(markdown));
const doc = new Document({
styles: {
default: {
document: {
run: {
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
},
},
},
},
numbering: {
config: [
{
reference: "ordered-list",
levels: [
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
{
level: 0,
format: "decimal",
text: "%1.",
alignment: AlignmentType.START,
},
{
level: 1,
format: "decimal",
text: "%2.",
alignment: AlignmentType.START,
},
{
level: 2,
format: "decimal",
text: "%3.",
alignment: AlignmentType.START,
},
],
},
],
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
@@ -27,6 +29,9 @@ export type ScheduledTask = {
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
creator_email?: string | null;
tags?: Tag[];
};
export type PublishedScheduledTask = {
@@ -37,6 +42,8 @@ export type PublishedScheduledTask = {
timezone: string;
published_at?: string | null;
owner_user_id: string;
creator_user_id?: string | null;
creator_email?: string | null;
};
export type SchedulerThread = {
+21 -5
View File
@@ -4,12 +4,16 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
display: {
streamSpeedMeterEnabled: false,
},
};
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
@@ -22,6 +26,14 @@ export interface LocalSettings {
notification: {
enabled: boolean;
};
display: {
/**
* Whether to render a small token/s speed meter while a streaming
* AI reply is in flight. Default off; controlled by the user from
* the appearance settings page.
*/
streamSpeedMeterEnabled: boolean;
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -48,6 +60,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
display: {
...DEFAULT_LOCAL_SETTINGS.display,
...settings?.display,
},
};
}
+31 -2
View File
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
export interface ListSkillsParams {
search?: string;
tagId?: string | null;
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
@@ -174,6 +185,24 @@ export async function setSkillPinned(
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse {
name: string;
category: string;
content: string;
}
export async function getSkillContent(
skillName: string,
): Promise<SkillContentResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill content");
}
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
+21 -4
View File
@@ -1,8 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteCustomSkill,
enableSkill,
getSkillContent,
installSkillUpload,
setSkillPinned,
setSkillPublished,
@@ -13,14 +19,25 @@ import type { SkillState } from "./type";
import { loadSkills } from ".";
export function useSkills() {
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: () => loadSkills(),
queryKey: ["skills", search, tagId],
queryFn: () => loadSkills({ search, tagId }),
placeholderData: keepPreviousData,
});
return { skills: data ?? [], isLoading, error };
}
export function useSkillContent(skillName: string | null) {
return useQuery({
queryKey: ["skill-content", skillName],
queryFn: () => getSkillContent(skillName!),
enabled: !!skillName,
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
+4
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
@@ -23,4 +25,6 @@ export interface Skill {
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
@@ -0,0 +1,44 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface PromptPrefixSettings {
enabled: boolean;
switch_label: string;
prompt_prefix: string;
}
export interface PromptPrefixUpdate {
enabled?: boolean;
switch_label?: string;
prompt_prefix?: string;
}
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
);
if (!response.ok) {
throw new Error(`Failed to load prompt prefix settings: ${response.status}`);
}
return (await response.json()) as PromptPrefixSettings;
}
export async function updatePromptPrefixSettings(
body: PromptPrefixUpdate,
): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update prompt prefix" }));
throw new Error(detail.detail ?? "Failed to update prompt prefix");
}
return (await response.json()) as PromptPrefixSettings;
}
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
loadPromptPrefixSettings,
updatePromptPrefixSettings,
type PromptPrefixUpdate,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
export function usePromptPrefixSettings() {
return useQuery({
queryKey: PROMPT_PREFIX_KEY,
queryFn: () => loadPromptPrefixSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
}
export function useUpdatePromptPrefixSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PromptPrefixUpdate) => updatePromptPrefixSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PROMPT_PREFIX_KEY, data);
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Tag,
TagCreateInput,
TagListResponse,
TagType,
TagUpdateInput,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
const base = () => `${getBackendBaseURL()}/api/tags`;
export async function listTags(tagType?: TagType): Promise<Tag[]> {
const query = tagType ? `?tag_type=${encodeURIComponent(tagType)}` : "";
const res = await apiFetch(`${base()}${query}`);
if (!res.ok) throw await parseDetail(res, "Failed to list tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function createTag(input: TagCreateInput): Promise<Tag> {
const res = await apiFetch(base(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to create tag");
return (await res.json()) as Tag;
}
export async function updateTag(id: string, input: TagUpdateInput): Promise<Tag> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to update tag");
return (await res.json()) as Tag;
}
export async function deleteTag(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw await parseDetail(res, "Failed to delete tag");
}
export async function getResourceTags(
targetType: TagType,
targetId: string,
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
);
if (!res.ok) throw await parseDetail(res, "Failed to load resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function setResourceTags(
targetType: TagType,
targetId: string,
tagIds: string[],
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!res.ok) throw await parseDetail(res, "Failed to update resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
+88
View File
@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTag,
deleteTag,
getResourceTags,
listTags,
setResourceTags,
updateTag,
} from "./api";
import type { TagCreateInput, TagType, TagUpdateInput } from "./types";
// Tags are global (admin-managed), so the cache key is not user-scoped.
const tagKeys = {
root: ["tags"] as const,
list: (tagType?: TagType) => ["tags", "list", tagType ?? "all"] as const,
assignment: (targetType: TagType, targetId: string) =>
["tags", "assignment", targetType, targetId] as const,
};
export function useTags(tagType?: TagType) {
return useQuery({
queryKey: tagKeys.list(tagType),
queryFn: () => listTags(tagType),
});
}
function useInvalidateTags() {
const queryClient = useQueryClient();
return () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
};
}
export function useCreateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (input: TagCreateInput) => createTag(input),
onSuccess: invalidate,
});
}
export function useUpdateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: TagUpdateInput }) =>
updateTag(id, input),
onSuccess: invalidate,
});
}
export function useDeleteTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (id: string) => deleteTag(id),
onSuccess: invalidate,
});
}
export function useResourceTags(targetType: TagType, targetId: string, enabled = true) {
return useQuery({
queryKey: tagKeys.assignment(targetType, targetId),
queryFn: () => getResourceTags(targetType, targetId),
enabled,
});
}
export function useSetResourceTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
targetType,
targetId,
tagIds,
}: {
targetType: TagType;
targetId: string;
tagIds: string[];
}) => setResourceTags(targetType, targetId, tagIds),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
// Resource list pages embed the tags, so refresh them too.
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
+31
View File
@@ -0,0 +1,31 @@
export type TagType = "agent" | "skill" | "scheduled_task";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
export interface Tag {
id: string;
name: string;
tag_type: TagType;
color: string;
description: string;
usage_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface TagListResponse {
tags: Tag[];
}
export interface TagCreateInput {
name: string;
tag_type: TagType;
color?: string;
description?: string;
}
export interface TagUpdateInput {
name?: string;
color?: string;
description?: string;
}
+5 -3
View File
@@ -14,9 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
model_name: string | undefined;
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -93,7 +93,7 @@ export function useExpertAuthBootstrap() {
useEffect(() => {
void bootstrap().catch((error) => {
console.error('专家中心初始化失败:', error);
console.error('智能体中心初始化失败:', error);
});
}, [bootstrap]);
+63 -4
View File
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
@@ -41,6 +42,9 @@ export default function ChatPage() {
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
@@ -50,6 +54,7 @@ export default function ChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
}
}, [isNewThread, threadId]);
@@ -87,15 +92,57 @@ export default function ChatPage() {
},
});
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
// The prefix is intentionally NOT concatenated into the message text:
// doing so would make the user see it. We only flag the request via
// context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only. Writing mode adds the
// active markdown artifact path so the agent keeps editing the canvas.
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
void sendMessage(
threadId,
message,
Object.keys(extraContext).length > 0 ? extraContext : undefined,
);
},
[sendMessage, threadId],
[
sendMessage,
threadId,
isNewThread,
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
settings.context.writing_mode,
writingArtifactPath,
],
);
const handleClarificationSubmit = useCallback(
@@ -109,6 +156,17 @@ export default function ChatPage() {
await thread.stop();
}, [thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
@@ -195,6 +253,7 @@ export default function ChatPage() {
onContextChange={(context) =>
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
downloadLlmMetricsExcel,
type LlmMetricsFilters,
} from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
// Prefer the resolved email's local part (sent by the backend). Fall back to
// the raw UUID only when no account record exists for the recorded user_id —
// showing a UUID is uglier but preserves the link to who triggered the call.
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatNumber(value?: number | null, fractionDigits = 0): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return value.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function speedColor(tps?: number | null): string {
if (tps == null) return "#9ca3af";
if (tps >= 50) return "#16a34a";
if (tps >= 30) return "#65a30d";
if (tps >= 15) return "#eab308";
if (tps >= 5) return "#f97316";
return "#ef4444";
}
export default function LlmMetricsPage() {
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {};
if (model.trim()) out.model_name = model.trim();
if (user.trim()) out.user_name = user.trim();
if (status !== "all") out.status = status;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [model, user, status, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
const handleExport = async () => {
setExporting(true);
try {
await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel");
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setModel("");
setUser("");
setStatus("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Hide columns that are empty for every visible row. Keeps the table dense
// when older records lack a tokens breakdown — better to drop the column
// than fill the page with placeholder dashes.
const visibleColumns = useMemo(() => {
type Col = {
id: string;
header: string;
align?: "left" | "right";
alwaysShow?: boolean;
hasValue: (row: typeof items[number]) => boolean;
render: (row: typeof items[number]) => React.ReactNode;
title?: (row: typeof items[number]) => string | undefined;
className?: string;
};
const columns: Col[] = [
{
id: "time",
header: "时间",
alwaysShow: true,
hasValue: () => true,
render: (row) => formatDate(row.created_at),
},
{
id: "user",
header: "用户",
alwaysShow: true,
hasValue: () => true,
render: (row) => displayUser(row),
title: (row) => row.user_id ?? undefined,
},
{
id: "model",
header: "模型",
alwaysShow: true,
hasValue: () => true,
render: (row) => row.model_name ?? "—",
},
{
id: "agent",
header: "Agent",
hasValue: (row) => Boolean(row.agent_name),
render: (row) => row.agent_name ?? "—",
},
{
id: "input",
header: "输入",
align: "right",
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
render: (row) => formatNumber(row.input_tokens),
className: "tabular-nums",
},
{
id: "output",
header: "输出",
align: "right",
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
render: (row) => formatNumber(row.output_tokens),
className: "tabular-nums",
},
{
id: "total",
header: "总 Token",
align: "right",
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
render: (row) => formatNumber(row.total_tokens),
className: "tabular-nums",
},
{
id: "duration",
header: "耗时",
align: "right",
alwaysShow: true,
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
render: (row) => formatDuration(row.duration_ms),
title: (row) => `${row.duration_ms} ms`,
className: "tabular-nums",
},
{
id: "tokens_per_sec",
header: "tok/s",
align: "right",
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
render: (row) =>
row.tokens_per_sec != null ? (
<span
className="font-mono tabular-nums"
style={{ color: speedColor(row.tokens_per_sec) }}
>
{row.tokens_per_sec.toFixed(1)}
</span>
) : (
"—"
),
},
{
id: "status",
header: "状态",
alwaysShow: true,
hasValue: () => true,
render: (row) => (
<Badge
variant={row.status === "success" ? "default" : "destructive"}
>
{row.status === "success" ? "成功" : "失败"}
</Badge>
),
},
{
id: "error",
header: "错误",
hasValue: (row) => Boolean(row.error_type || row.error_message),
render: (row) => row.error_type ?? row.error_message ?? "—",
title: (row) => row.error_message ?? undefined,
className: "text-muted-foreground max-w-[260px] truncate",
},
];
return columns.filter(
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
);
}, [items]);
const colSpan = visibleColumns.length || 1;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">大模型调用统计</h1>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
后台无条件记录,与前端速度指示器开关无关。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">模型</label>
<Input
value={model}
placeholder="按模型名筛选"
onChange={(e) => {
setModel(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<Input
value={user}
placeholder="按用户名匹配(@ 前部分)"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="md:col-span-5 flex justify-end">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
<div className="rounded-xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
{visibleColumns.map((col) => (
<Th
key={col.id}
className={col.align === "right" ? "text-right" : undefined}
>
{col.header}
</Th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={colSpan} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
{visibleColumns.map((col) => (
<Td
key={col.id}
className={cn(
col.align === "right" && "text-right",
col.className,
)}
title={col.title?.(row)}
>
{col.render(row)}
</Td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
title={title}
>
{children}
</td>
);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
+12 -12
View File
@@ -153,7 +153,7 @@ export default function NewAgentPage() {
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
name = `未命名专家-${Date.now().toString(36)}`;
name = `未命名智能体-${Date.now().toString(36)}`;
}
const modelOverride = modelInput.trim() || null;
@@ -226,7 +226,7 @@ export default function NewAgentPage() {
if (!saved) {
return;
}
toast.success(`专家「${saved.name}」已保存`);
toast.success(`智能体「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback(
@@ -270,7 +270,7 @@ export default function NewAgentPage() {
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
await handleChatSubmit("我暂时不批准创建这个智能体配置,请继续帮我调整。");
},
[handleChatSubmit],
);
@@ -300,7 +300,7 @@ export default function NewAgentPage() {
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">创建专家</h1>
<h1 className="text-sm font-semibold">创建智能体</h1>
</div>
{agent ? (
<Button
@@ -353,7 +353,7 @@ export default function NewAgentPage() {
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
@@ -366,16 +366,16 @@ export default function NewAgentPage() {
<aside className="bg-background min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">专家配置</h2>
<h2 className="text-sm font-semibold">智能体配置</h2>
<p className="text-muted-foreground mt-1 text-xs">
这些内容会作为创建专家时的初始配置。
这些内容会作为创建智能体时的初始配置。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
placeholder="例如:合同审查专家"
placeholder="例如:合同审查智能体"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
@@ -388,7 +388,7 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
placeholder="简要说明这个智能体擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
@@ -415,14 +415,14 @@ export default function NewAgentPage() {
))}
</select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
该智能体被使用时调用的模型。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
placeholder="写下智能体的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
@@ -479,7 +479,7 @@ export default function NewAgentPage() {
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
{agentId ? "保存智能体" : "创建智能体"}
</Button>
</div>
</aside>
@@ -0,0 +1,125 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
usePromptPrefixSettings,
useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks";
export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
// Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click
// would flip state to true and the next refetch would reset it to false.
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称");
return;
}
if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容");
return;
}
try {
await update.mutateAsync({
enabled,
switch_label: switchLabel,
prompt_prefix: promptPrefix,
});
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">向用户暴露开关</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,问答页不显示该开关,且后端不会做任何拼接。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">开关名称</p>
<p className="text-muted-foreground text-xs">
展示在问答页输入框附近的标签文本。
</p>
<Input
value={switchLabel}
onChange={(e) => setSwitchLabel(e.target.value)}
placeholder="例:加入立场前缀"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">拼接内容</p>
<p className="text-muted-foreground text-xs">
拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
</p>
<Textarea
rows={4}
value={promptPrefix}
onChange={(e) => setPromptPrefix(e.target.value)}
placeholder="在新会话的第一次提问前自动加入这段文字"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -23,9 +24,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
@@ -92,6 +95,8 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
type EditState = { draft: string; saving: boolean };
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
@@ -101,6 +106,9 @@ export default function ScheduledTaskRunDetailPage() {
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
@@ -170,8 +178,82 @@ export default function ScheduledTaskRunDetailPage() {
}
};
const handleDownloadFileAsWord = async (file: ScheduledTaskResultFileContent) => {
// Prefer the in-progress draft if the user is editing the file.
const source = edits[file.index]?.draft ?? file.content ?? "";
if (!source) {
toast.error("文件内容未加载,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(source, file.name, result?.title);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleEnterEdit = useCallback((file: ScheduledTaskResultFileContent) => {
setEdits((prev) => ({
...prev,
[file.index]: { draft: file.content ?? "", saving: false },
}));
}, []);
const handleCancelEdit = useCallback((index: number) => {
setEdits((prev) => ({ ...prev, [index]: null }));
}, []);
const handleSaveEdit = useCallback(
async (file: ScheduledTaskResultFileContent) => {
if (!run) return;
const current = edits[file.index];
if (!current) return;
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: true },
}));
try {
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
setFileContents((prev) =>
prev.map((entry) =>
entry.index === file.index
? { ...entry, content: current.draft, size: updated.size ?? entry.size }
: entry,
),
);
setEdits((prev) => ({ ...prev, [file.index]: null }));
toast.success(`文件 ${file.name} 已保存`);
} catch (error) {
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: false },
}));
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[edits, run],
);
const handleDownloadBodyAsWord = async () => {
if (!result?.content) {
toast.error("正文为空,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(
result.content,
result.title || "scheduled-task-result",
result.title || undefined,
);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
if (!run || !canDeleteRun) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
@@ -207,10 +289,23 @@ export default function ScheduledTaskRunDetailPage() {
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDownloadBodyAsWord}
disabled={!result?.content}
title="将正文 Markdown 导出为 .docx"
>
<FileText className="size-3" />
下载正文 Word
</Button>
{canDeleteRun && (
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun}>
<Trash2 className="size-3" />
删除
</Button>
)}
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
@@ -281,26 +376,94 @@ export default function ScheduledTaskRunDetailPage() {
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
{fileContents.map((file) => {
const isMarkdown = isMarkdownFile(file);
const editable = canEditRun && file.previewable;
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
{isMarkdown && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownloadFileAsWord(file)}
disabled={!file.content && !editState?.draft}
title="将 Markdown 导出为 Word"
>
<FileText className="size-4" />
下载 Word
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
{editable && !isEditing && (
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
<Edit3 className="size-4" />
编辑
</Button>
)}
{isEditing && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleCancelEdit(file.index)}
disabled={editState?.saving}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => handleSaveEdit(file)}
disabled={editState?.saving}
>
{editState?.saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
保存
</Button>
</>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
<div className="p-3">
{isEditing ? (
<Textarea
className="min-h-80 font-mono text-xs"
value={editState?.draft ?? ""}
disabled={editState?.saving}
onChange={(event) =>
setEdits((prev) => ({
...prev,
[file.index]: {
draft: event.target.value,
saving: prev[file.index]?.saving ?? false,
},
}))
}
/>
) : (
<RenderPreview file={file} />
)}
</div>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
);
})}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
+153 -25
View File
@@ -15,6 +15,7 @@ import {
Play,
Plus,
RefreshCw,
TagsIcon,
Trash2,
} from "lucide-react";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -35,7 +36,11 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
@@ -73,39 +78,91 @@ const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
function buildCron(cfg: ScheduleConfig): string {
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
switch (cfg.freq) {
case "hourly":
return `${cfg.minute} * * * *`;
return `${m} * * * *`;
case "daily":
return `0 ${cfg.hour} * * *`;
return `${m} ${cfg.hour} * * *`;
case "weekly":
return `0 ${cfg.hour} * * ${cfg.weekday}`;
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
case "monthly":
return `0 ${cfg.hour} ${cfg.date} * *`;
return `${m} ${cfg.hour} ${cfg.date} * *`;
}
}
function parseCron(cron: string): ScheduleConfig | null {
// Parse a 5-field cron expression into the form picker state.
//
// Bug we keep getting bitten by: the edit dialog used to call
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
// input (free-text like "每天 9 点") rather than a real cron string. The
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
// reinterpret the task as "every day at 09:00" — or, when natural-language
// resolved to "0 * * * *" on the backend, the picker honestly reflected
// that as "每小时 0 分". Either way the user lost their original intent.
//
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
// `_schedule_to_cron`); this function stays the single source of truth for
// cron → picker, and also handles the looser step-syntax variants
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
if (!cron) return null;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hr, date, , wd] = parts;
const minute = parseInt(min ?? "", 10);
const hour = parseInt(hr ?? "", 10);
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
// cron isn't misread as daily.
if (hr === "*" && date === "*" && wd === "*") {
const minute = parseInt(min);
if (!isNaN(minute)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
// picker doesn't model "every N hours" yet, so this is the safest fallback
// that at least doesn't silently round to 9:00.
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
if (date === "*" && wd === "*") {
const hour = parseInt(hr);
if (!isNaN(hour)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "daily", hour };
if (!isNaN(hour)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "daily",
hour,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (date === "*" && wd !== "*") {
const hour = parseInt(hr);
const weekday = parseInt(wd);
if (!isNaN(hour) && !isNaN(weekday)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "weekly", hour, weekday };
const weekday = parseInt(wd ?? "", 10);
if (!isNaN(hour) && !isNaN(weekday)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "weekly",
hour,
weekday,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (wd === "*" && date !== "*") {
const hour = parseInt(hr);
const d = parseInt(date);
if (!isNaN(hour) && !isNaN(d)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "monthly", hour, date: d };
const d = parseInt(date ?? "", 10);
if (!isNaN(hour) && !isNaN(d)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "monthly",
hour,
date: d,
minute: isNaN(minute) ? 0 : minute,
};
}
}
return null;
}
@@ -191,6 +248,14 @@ function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
/** Render a creator-like identifier without the email domain part. */
function displayUsername(value?: string | null): string {
const raw = (value ?? "").trim();
if (!raw) return "";
const at = raw.indexOf("@");
return at === -1 ? raw : raw.slice(0, at);
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
@@ -223,7 +288,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const { data: tasks = [], isLoading } = useScheduledTasks();
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -239,6 +307,8 @@ export default function ScheduledTasksPage() {
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -248,6 +318,7 @@ export default function ScheduledTasksPage() {
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
const filteredRuns = useMemo(() => {
@@ -292,7 +363,14 @@ export default function ScheduledTasksPage() {
prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
});
setScheduleConfig(parseCron(task.schedule_text) ?? DEFAULT_SCHEDULE_CONFIG);
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
// which is what made the dialog default back to "每天9点 → 每小时0分".
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
@@ -398,6 +476,14 @@ export default function ScheduledTasksPage() {
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -451,8 +537,31 @@ export default function ScheduledTasksPage() {
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id}
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
{isOwner ? (
<>
<Button
@@ -641,6 +750,13 @@ export default function ScheduledTasksPage() {
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
创建者:
{displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -700,14 +816,16 @@ export default function ScheduledTasksPage() {
<Download className="size-4" />
下载结果
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
{selectedTaskCanEditRuns && run.can_edit !== false && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
)}
</div>
</div>
))}
@@ -850,6 +968,16 @@ export default function ScheduledTasksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
export default function TagsPage() {
return <TagManagementPage />;
}
@@ -20,9 +20,12 @@ import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -181,6 +184,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -193,6 +208,30 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);
+14 -12
View File
@@ -163,16 +163,17 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
### Configuration System
@@ -213,9 +214,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags`); `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents; `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -27,6 +28,7 @@ from app.gateway.routers import (
auth,
channels,
feedback,
llm_metrics,
mcp,
memory,
models,
@@ -36,6 +38,8 @@ from app.gateway.routers import (
scheduled_tasks,
skills,
suggestions,
system_settings,
tags,
thread_runs,
threads,
uploads,
@@ -265,6 +269,93 @@ async def _run_db_migrations() -> None:
logger.exception("Database migration failed — schema may be out of date")
# Skill curator 调度参数。
# - 检查间隔:每隔这么久检查一次"需不需要进化";真正是否执行由 should_run_now() 判定。
# - 启动延迟:启动后先等这么久再首次检查,避开启动高峰。
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 小时
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 分钟
def _acquire_singleton_lock(lock_path):
"""跨平台非阻塞独占文件锁,用于多 worker 下的 leader 选举。
成功 -> 返回持有锁的文件句柄(调用方须保持其存活,直到进程退出/主动释放);
抢不到锁(已有其它 worker 持有)或打开失败 -> 返回 None。
进程异常退出时 OS 会自动释放该锁。
"""
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "a+")
except OSError:
logger.exception("Curator scheduler: 打开锁文件失败")
return None
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def _release_singleton_lock(handle) -> None:
"""释放并关闭 _acquire_singleton_lock 拿到的锁文件句柄。"""
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
finally:
try:
handle.close()
except OSError:
pass
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
运行调度,其余 worker 直接退出,避免 curator 被重复触发、重复调用 LLM。
被 cancel 时 CancelledError 会穿过 ``except Exception``(它是 BaseException
子类,不会被捕获),协程随之干净退出并在 finally 里释放锁。
"""
from deerflow.config.runtime_paths import runtime_home
from deerflow.skills.curator import maybe_run_curator
lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
if lock_handle is None:
logger.info("Curator scheduler: 另一个 worker 已持有调度锁,本 worker 跳过")
return
logger.info("Curator scheduler: 取得调度锁,本 worker 负责 curator 调度")
try:
# 启动后延迟一会儿再首次检查,避开启动高峰
await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
while True:
try:
await maybe_run_curator()
except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
@@ -299,12 +390,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# Kick off skill curator in background if the run interval has elapsed
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
try:
from deerflow.skills.curator import maybe_run_curator
asyncio.create_task(maybe_run_curator())
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
except Exception:
logger.exception("Skill curator startup task failed (non-fatal)")
logger.exception("Skill curator scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
@@ -332,6 +423,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
# 停止 curator 调度器
curator_task = getattr(app.state, "curator_scheduler_task", None)
if curator_task is not None:
curator_task.cancel()
try:
await curator_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Curator scheduler shutdown error")
try:
scheduler_service = getattr(app.state, "scheduled_task_service", None)
if scheduler_service is not None:
@@ -572,6 +674,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
# Admin LLM metrics audit + export
app.include_router(llm_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
@@ -587,6 +695,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Notifications API is mounted at /api/notifications
app.include_router(notifications.router)
# Tags API is mounted at /api/tags
app.include_router(tags.router)
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router)
@@ -62,6 +62,15 @@ class LocalAuthProvider(AuthProvider):
"""Get user by ID."""
return await self._repo.get_user_by_id(user_id)
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user whose email starts with ``local_part@`` regardless of domain.
Used by the passwordless username-login flow to keep a single
username mapped to the same account across legacy / canonical
email-domain defaults.
"""
return await self._repo.find_user_by_email_local_part(local_part)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
"""Create a new local user.
@@ -61,6 +61,26 @@ class UserRepository(ABC):
"""
raise NotImplementedError
@abstractmethod
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user by the local part of their email (the bit before ``@``).
Used by the passwordless username-login flow so that a single
username keeps mapping to the same account even when the synthetic
email domain (e.g. ``cm.com`` vs the legacy ``local.deerflow``)
differs between registration and current login.
Returns the oldest matching user when multiple rows share the same
local part, so collisions resolve deterministically.
Args:
local_part: Substring to match before the ``@``. Case-insensitive.
Returns:
User if found, None otherwise.
"""
raise NotImplementedError
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
@@ -88,6 +88,27 @@ class SQLiteUserRepository(UserRepository):
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
local = (local_part or "").strip().lower()
if not local:
return None
# Escape SQL LIKE wildcards so usernames like ``foo_bar`` or ``a%b`` do
# not match unrelated rows. ``\`` is the chosen escape char below.
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"{escaped}@%"
# Case-insensitive prefix match on the email field, oldest first so a
# subsequent registration cannot shadow an existing account.
stmt = (
select(UserRow)
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
.order_by(UserRow.created_at.asc())
.limit(1)
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def update_user(self, user: User) -> User:
async with self._sf() as session:
row = await session.get(UserRow, str(user.id))
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
from deerflow.persistence.notifications import NotificationStore
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.persistence.thread_meta.base import ThreadMetaStore
@@ -92,6 +93,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.scheduled_task_store = make_scheduled_task_store(sf)
from deerflow.persistence.llm_metrics import make_llm_metrics_store
app.state.llm_metrics_store = make_llm_metrics_store(sf)
from deerflow.persistence.agents import make_agent_store
app.state.agent_store = make_agent_store(sf)
@@ -113,6 +118,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.recommended_question_store = make_recommended_question_store(sf)
from deerflow.persistence.tags import make_tag_store
app.state.tag_store = make_tag_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -176,6 +185,11 @@ def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
return val
def get_llm_metrics_store(request: Request):
"""Return the LLM metrics store, or None when not configured."""
return getattr(request.app.state, "llm_metrics_store", None)
def get_agent_store(request: Request) -> AgentStore:
"""Return the custom-agent store."""
val = getattr(request.app.state, "agent_store", None)
@@ -200,6 +214,14 @@ def get_notification_store(request: Request) -> NotificationStore:
return val
def get_tag_store(request: Request) -> TagStore:
"""Return the tag store."""
val = getattr(request.app.state, "tag_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Tag store not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -9,14 +9,15 @@ from typing import Any
from uuid import uuid4
import yaml
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store
from app.gateway.deps import get_agent_store, get_tag_store
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.paths import get_paths
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -42,6 +43,8 @@ class AgentResponse(BaseModel):
tool_groups: list[str] | None = None
skills: list[str] | None = None
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents."""
@@ -238,15 +241,30 @@ async def _get_editable_or_raise(
summary="List Agents",
description="List built-in agents, the current user's agents, and published agents.",
)
async def list_agents(request: Request) -> AgentsListResponse:
async def list_agents(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
tag_id: str | None = Query(default=None, description="Keep only agents carrying this tag"),
) -> AgentsListResponse:
_require_agents_api_enabled()
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
user_id = _current_user_id(request)
agents = await store.list_visible(user_id)
return AgentsListResponse(agents=[_record_to_response(a, include_soul=True) for a in agents])
responses = [_record_to_response(a, include_soul=True) for a in agents]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.id, [])
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_id:
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
return AgentsListResponse(agents=responses)
except HTTPException:
raise
except Exception as e:
@@ -485,6 +503,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
shutil.rmtree(agent_dir)
try:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException:
raise
@@ -21,6 +21,22 @@ ACTIVE_CONTENT_MIME_TYPES = {
"image/svg+xml",
}
EDITABLE_TEXT_SUFFIXES = {
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".xml",
".log",
}
class ArtifactUpdateRequest(BaseModel):
content: str
def _build_content_disposition(disposition_type: str, filename: str) -> str:
"""Build an RFC 5987 encoded Content-Disposition header value."""
@@ -184,37 +200,28 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
class ArtifactUpdateRequest(BaseModel):
content: str
@router.put(
@router.patch(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Update Artifact File",
description="Update the content of an artifact file.",
description="Update a text artifact file in the current thread workspace.",
)
@require_permission("threads", "write", owner_check=True)
async def update_artifact(thread_id: str, path: str, request: Request, body: ArtifactUpdateRequest) -> dict:
"""Update an artifact file's content.
async def update_artifact(
thread_id: str,
path: str,
request: Request,
body: ArtifactUpdateRequest,
) -> Response:
"""Update a text artifact.
Args:
thread_id: The thread ID.
path: The artifact path (e.g. mnt/user-data/outputs/index.html).
body: Request body containing the new file content.
Returns:
{"ok": true} on success.
Raises:
HTTPException:
- 400 if path is invalid, not a file, or is a .skill archive
- 403 if access denied
- 404 if file not found
This endpoint is intentionally limited to normal text artifacts. Files inside
.skill archives and active web content remain read-only from the artifact UI.
"""
if ".skill/" in path or path.endswith(".skill"):
raise HTTPException(status_code=400, detail="Cannot edit .skill archives")
if ".skill/" in path:
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
actual_path = resolve_thread_virtual_path(thread_id, path)
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
@@ -222,6 +229,13 @@ async def update_artifact(thread_id: str, path: str, request: Request, body: Art
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
mime_type, _ = mimetypes.guess_type(actual_path)
suffix = actual_path.suffix.lower()
if mime_type in ACTIVE_CONTENT_MIME_TYPES:
raise HTTPException(status_code=400, detail="Active web artifacts are read-only.")
if suffix not in EDITABLE_TEXT_SUFFIXES and not (mime_type and mime_type.startswith("text/")):
raise HTTPException(status_code=400, detail="Only text artifacts can be edited.")
actual_path.write_text(body.content, encoding="utf-8")
logger.info(f"Updated artifact: thread_id={thread_id}, path={path}")
return {"ok": True}
return PlainTextResponse(content=body.content, media_type=mime_type or "text/plain")
@@ -164,25 +164,40 @@ class MessageResponse(BaseModel):
# ── Helpers ───────────────────────────────────────────────────────────────
def _normalize_username_local_part(username: str) -> str | None:
"""Return the canonical local-part for a username, or None if user typed an email.
Used both to build the synthetic email and to look up legacy accounts that
were registered with a different default domain. Returning ``None`` for
raw-email input tells callers to skip the domain-agnostic fallback —
if you typed ``alice@old.com``, you meant that specific row.
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
if "@" in raw:
return None
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
return normalized
def _username_to_email(username: str) -> str:
"""Map a frontend username to a valid email used by user storage.
- If username already looks like an email (`contains @`), use it directly.
- Otherwise generate `<normalized-username>@<domain>`.
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
local = _normalize_username_local_part(username)
if local is None:
return username.strip().lower()
if "@" in raw:
return raw.lower()
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "local.deerflow").strip().lower() or "local.deerflow"
return f"{normalized}@{domain}"
# Default mail domain for passwordless / username-only registrations.
# Kept overridable via env so individual deployments can swap it out, but the
# built-in default targets the cm.com tenant.
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "cm.com").strip().lower() or "cm.com"
return f"{local}@{domain}"
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
@@ -368,9 +383,21 @@ async def login_by_username(request: Request, response: Response, body: Username
"""
provider = get_local_provider()
email = _username_to_email(body.username)
local_part = _normalize_username_local_part(body.username)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", body.username, legacy.email)
if user is None:
try:
role = "admin" if body.username == "admin" else "user"
@@ -380,6 +407,8 @@ async def login_by_username(request: Request, response: Response, body: Username
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
elif body.username == "admin" and user.system_role != "admin":
@@ -0,0 +1,323 @@
"""Admin-only LLM call metrics: list + Excel export.
Lets administrators audit every provider invocation made by the runtime —
including failures — and download the full table as ``.xlsx``. The recording
is unconditional on the backend, so this view always reflects every call.
"""
from __future__ import annotations
import logging
from datetime import datetime
from io import BytesIO
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_llm_metrics_store, get_local_provider, get_optional_user_from_request
from deerflow.persistence.llm_metrics.base import LlmMetricsQuery, LlmMetricsStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/llm-metrics", tags=["llm-metrics"])
class LlmMetricResponse(BaseModel):
id: str
created_at: str | None = None
user_id: str | None = None
user_name: str | None = Field(
default=None,
description="Local part (before @) of the user's email, resolved from user_id",
)
thread_id: str | None = None
run_id: str | None = None
agent_name: str | None = None
model_name: str | None = None
duration_ms: int = 0
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
tokens_per_sec: float | None = None
status: str = "success"
error_type: str | None = None
error_message: str | None = None
class LlmMetricsListResponse(BaseModel):
items: list[LlmMetricResponse]
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
limit: int
offset: int
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="LLM metrics admin view is admin-only")
def _require_store(request: Request) -> LlmMetricsStore:
store = get_llm_metrics_store(request)
if store is None:
raise HTTPException(status_code=503, detail="LLM metrics store is not configured")
return store
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
# Accept both ``Z`` suffix and ``+00:00``.
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
def _email_local_part(email: str | None) -> str | None:
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
def _derive_tokens_per_sec(row: dict[str, Any]) -> None:
"""Fill in ``tokens_per_sec`` when the row was recorded before we knew how
to extract ``output_tokens`` from response_metadata.token_usage.
Uses ``output_tokens / elapsed`` when available, otherwise falls back to
``total_tokens / elapsed`` as an approximation. Newer rows already have
the field set during recording, so this only kicks in for legacy data.
"""
if not isinstance(row, dict):
return
if row.get("tokens_per_sec") not in (None, 0):
return
duration_ms = row.get("duration_ms") or 0
if duration_ms <= 0:
return
numerator = row.get("output_tokens") or row.get("total_tokens")
if not numerator:
return
row["tokens_per_sec"] = round((numerator / duration_ms) * 1000, 3)
async def _resolve_user_name_to_id(name: str | None) -> str | None:
"""Look up a UUID for the given username (email local-part)."""
if not name:
return None
try:
provider = get_local_provider()
except Exception:
return None
try:
user = await provider.find_user_by_email_local_part(name)
except Exception:
return None
return str(user.id) if user is not None else None
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
Batches lookups so one query per distinct user_id, not per row. Tolerates
missing accounts (deleted users) by leaving ``user_name`` blank.
"""
if not rows:
return rows
user_ids = {row["user_id"] for row in rows if isinstance(row, dict) and row.get("user_id")}
if not user_ids:
return rows
try:
provider = get_local_provider()
except Exception:
provider = None
email_by_id: dict[str, str] = {}
if provider is not None:
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
if user is not None and user.email:
email_by_id[uid] = user.email
for row in rows:
if isinstance(row, dict) and row.get("user_id"):
email = email_by_id.get(row["user_id"])
row["user_name"] = _email_local_part(email) or row["user_id"]
return rows
def _build_query(
*,
user_id: str | None,
model_name: str | None,
status: str | None,
since: str | None,
until: str | None,
limit: int,
offset: int,
) -> LlmMetricsQuery:
return LlmMetricsQuery(
user_id=user_id or None,
model_name=model_name or None,
status=status or None,
since=_parse_dt(since),
until=_parse_dt(until),
limit=limit,
offset=offset,
)
@router.get("", response_model=LlmMetricsListResponse)
async def list_llm_metrics(
request: Request,
user_id: str | None = Query(default=None),
user_name: str | None = Query(default=None, description="Username (email local part) to filter by"),
model_name: str | None = Query(default=None),
status: str | None = Query(default=None, pattern="^(success|error)$"),
since: str | None = Query(default=None, description="ISO 8601 datetime, inclusive lower bound"),
until: str | None = Query(default=None, description="ISO 8601 datetime, inclusive upper bound"),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> LlmMetricsListResponse:
await _require_admin(request)
store = _require_store(request)
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# Username unknown → no rows; short-circuit instead of a global scan.
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
user_id = resolved
query = _build_query(
user_id=user_id,
model_name=model_name,
status=status,
since=since,
until=until,
limit=limit,
offset=offset,
)
rows = await store.list(query)
total = await store.count(query)
await _attach_usernames(rows)
for row in rows:
_derive_tokens_per_sec(row)
return LlmMetricsListResponse(
items=[LlmMetricResponse(**row) for row in rows],
total=total,
limit=limit,
offset=offset,
)
def _format_cell(value: Any) -> Any:
if value is None:
return ""
if isinstance(value, datetime):
return value.replace(tzinfo=None) if value.tzinfo else value
return value
@router.get("/export")
async def export_llm_metrics(
request: Request,
user_id: str | None = Query(default=None),
user_name: str | None = Query(default=None),
model_name: str | None = Query(default=None),
status: str | None = Query(default=None, pattern="^(success|error)$"),
since: str | None = Query(default=None),
until: str | None = Query(default=None),
):
"""Stream the full filtered metric set as an ``.xlsx`` file."""
await _require_admin(request)
store = _require_store(request)
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# No matching user — return an empty xlsx so the user still gets a
# file (matching the list view's empty state).
user_id = "__no_such_user__"
else:
user_id = resolved
try:
from openpyxl import Workbook
except ImportError as exc:
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
query = _build_query(
user_id=user_id,
model_name=model_name,
status=status,
since=since,
until=until,
limit=1, # unused: iter_all paginates internally
offset=0,
)
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet("llm_call_metrics")
columns = [
"id",
"created_at",
"status",
"user_name",
"user_id",
"thread_id",
"run_id",
"agent_name",
"model_name",
"duration_ms",
"input_tokens",
"output_tokens",
"total_tokens",
"tokens_per_sec",
"error_type",
"error_message",
]
sheet.append(columns)
# Pre-resolve all distinct user_ids in chunks so the export doesn't pay one
# round trip per row.
buffered: list[dict[str, Any]] = []
FLUSH_AT = 200
async def flush() -> int:
if not buffered:
return 0
await _attach_usernames(buffered)
for row in buffered:
_derive_tokens_per_sec(row)
sheet.append([_format_cell(row.get(col)) for col in columns])
n = len(buffered)
buffered.clear()
return n
count = 0
async for row in store.iter_all(query):
buffered.append(row)
if len(buffered) >= FLUSH_AT:
count += await flush()
count += await flush()
buffer = BytesIO()
workbook.save(buffer)
buffer.seek(0)
logger.info("Exported %d llm_call_metrics rows", count)
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
filename = f"llm_call_metrics-{timestamp}.xlsx"
headers = {
"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}",
}
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
@@ -3,15 +3,31 @@ import logging
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["mcp"])
async def _require_admin(request: Request) -> None:
"""Reject the request unless the caller is an authenticated admin user.
Allows the call through silently when auth is disabled (no user attached),
so existing single-user / no-auth deployments keep working.
"""
user = await get_optional_user_from_request(request)
if user is None:
# No authenticated user — auth might be disabled or middleware bypassed.
# Defer to the AuthMiddleware decision and let the call through.
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="MCP configuration requires admin privileges")
class McpOAuthConfigResponse(BaseModel):
"""OAuth configuration for an MCP server."""
@@ -43,6 +59,10 @@ class McpServerConfigResponse(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
disabled_tools: list[str] = Field(
default_factory=list,
description="Tool names (without server-name prefix) to hide from the agent",
)
class McpConfigResponse(BaseModel):
@@ -101,7 +121,7 @@ async def get_mcp_configuration() -> McpConfigResponse:
summary="Update MCP Configuration",
description="Update Model Context Protocol (MCP) server configurations and save to file.",
)
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request: Request) -> McpConfigResponse:
"""Update the MCP configuration.
This will:
@@ -133,6 +153,22 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
}
```
"""
await _require_admin(http_request)
# Light validation: each server must have the fields required by its transport.
for server_name, server in request.mcp_servers.items():
if not server_name.strip():
raise HTTPException(status_code=400, detail="MCP server name cannot be empty")
transport = (server.type or "stdio").lower()
if transport == "stdio":
if not (server.command or "").strip():
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses stdio transport but command is empty")
elif transport in ("http", "sse"):
if not (server.url or "").strip():
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses {transport} transport but url is empty")
else:
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' has unsupported transport: {transport}")
try:
# Get the current config path (or determine where to save it)
config_path = ExtensionsConfig.resolve_config_path()
@@ -10,11 +10,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from app.gateway.deps import get_current_user, get_local_provider, get_tag_store
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
@@ -61,6 +61,50 @@ async def _user_id(request: Request) -> str:
return user_id
async def _enrich_with_creator(tasks: list[dict[str, Any]] | dict[str, Any] | None) -> Any:
"""Attach ``creator_email`` / ``creator_user_id`` to one or many task dicts.
Looks up the auth user once per distinct ``user_id`` in the batch so we do
not pay N+1 round trips when rendering a long task list. Tolerates missing
user records (deleted accounts) by leaving the email blank.
"""
if tasks is None:
return tasks
items = tasks if isinstance(tasks, list) else [tasks]
user_ids = {str(t.get("user_id")) for t in items if isinstance(t, dict) and t.get("user_id")}
if not user_ids:
return tasks
try:
provider = get_local_provider()
except Exception:
# Auth provider not initialised (e.g. memory-mode test harness): keep
# the bare user_id and let the frontend render the raw id.
provider = None
email_by_id: dict[str, str] = {}
if provider is not None:
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
if user is not None:
email_by_id[uid] = user.email
for item in items:
if not isinstance(item, dict):
continue
uid = item.get("user_id")
if uid is None:
continue
uid_str = str(uid)
item["creator_user_id"] = uid_str
item["creator_email"] = email_by_id.get(uid_str, "")
return tasks
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
@@ -130,10 +174,26 @@ async def get_scheduler_thread(request: Request) -> dict:
@router.get("")
async def list_scheduled_tasks(request: Request) -> list[dict]:
async def list_scheduled_tasks(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"),
tag_id: str | None = Query(default=None, description="Keep only tasks carrying this tag"),
) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_tasks(user_id)
tasks = await service.list_tasks(user_id)
await _enrich_with_creator(tasks)
tag_store = get_tag_store(request)
assignments = await tag_store.list_assignments("scheduled_task", [str(t.get("task_id")) for t in tasks])
for task in tasks:
task["tags"] = assignments.get(str(task.get("task_id")), [])
if search:
needle = search.strip().lower()
tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()]
if tag_id:
tasks = [t for t in tasks if any(tg.get("id") == tag_id for tg in t.get("tags", []))]
return tasks
@router.get("/{task_id}/runs")
@@ -158,7 +218,10 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_run(user_id, run_id)
try:
deleted = await service.delete_run(user_id, run_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@@ -298,7 +361,7 @@ async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateReques
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.create_task(
task = await service.create_task(
user_id=user_id,
name=body.name,
prompt=body.prompt,
@@ -307,6 +370,8 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
await _enrich_with_creator(task)
return task
@router.patch("/{task_id}")
@@ -316,6 +381,7 @@ async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest,
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
await _enrich_with_creator(task)
return task
@@ -326,6 +392,10 @@ async def delete_scheduled_task(task_id: str, request: Request) -> dict:
deleted = await service.delete_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
try:
await get_tag_store(request).unassign_all("scheduled_task", task_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
return {"success": True}
@@ -366,7 +436,17 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
service = get_scheduled_task_service()
await _user_id(request)
return await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
tasks = await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
# published list uses owner_user_id; resolve it into a creator_email here too
# so the discovery view can show the author's account.
for task in tasks:
if isinstance(task, dict) and task.get("owner_user_id"):
task["user_id"] = task["owner_user_id"]
await _enrich_with_creator(tasks)
for task in tasks:
if isinstance(task, dict):
task.pop("user_id", None)
return tasks
@router.post("/{task_id}/publish")
@@ -5,15 +5,16 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Request, UploadFile
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store, get_config, get_skill_store
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
@@ -95,6 +96,7 @@ class SkillResponse(BaseModel):
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
class SkillsListResponse(BaseModel):
@@ -208,6 +210,24 @@ async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[
return {record["name"]: record for record in records}
async def _attach_and_filter_tags(
tag_store: TagStore,
responses: list[SkillResponse],
search: str | None,
tag_id: str | None,
) -> list[SkillResponse]:
"""Bulk-attach tags to skill responses, then apply name/tag filters."""
assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.name, [])
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_id:
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
return responses
@router.get(
"/skills",
response_model=SkillsListResponse,
@@ -218,6 +238,9 @@ async def list_skills(
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
tag_id: str | None = Query(default=None, description="Keep only skills carrying this tag"),
) -> SkillsListResponse:
try:
user_id = _current_user_id(request)
@@ -234,6 +257,7 @@ async def list_skills(
if record is None:
continue
responses.append(_skill_to_response(skill, record))
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_id)
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error(f"Failed to load skills: {e}", exc_info=True)
@@ -553,6 +577,11 @@ async def delete_custom_skill(
else:
await skill_store.delete(skill_name, user_id)
try:
await get_tag_store(request).unassign_all("skill", skill_name)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
# Strip references from every agent that pulled this skill in,
# and notify their owners (no-op for self).
await cascade_skill_unpublished_or_deleted(
@@ -816,6 +845,36 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
class SkillContentResponse(BaseModel):
"""SKILL.md 原文内容,适用于公共和自定义技能。"""
name: str
category: SkillCategory
content: str
@router.get(
"/skills/{skill_name}/content",
response_model=SkillContentResponse,
summary="Get Skill Markdown Content",
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
)
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
content = skill.skill_file.read_text(encoding="utf-8")
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
@router.put(
"/skills/{skill_name}",
response_model=SkillResponse,
@@ -0,0 +1,65 @@
"""Admin-configurable system-wide runtime settings."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.system_settings import (
PromptPrefixSettings,
SystemSettings,
load_system_settings,
save_system_settings,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
class PromptPrefixResponse(BaseModel):
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
switch_label: str = Field(..., description="Toggle label shown in the chat input")
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix,
)
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="System settings can only be modified by an admin")
@router.get("/prompt-prefix", response_model=PromptPrefixResponse)
async def get_prompt_prefix() -> PromptPrefixResponse:
"""Return the current prompt-prefix configuration (readable by any user)."""
return _to_response(load_system_settings().prompt_prefix)
@router.put("/prompt-prefix", response_model=PromptPrefixResponse)
async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request) -> PromptPrefixResponse:
"""Update the prompt-prefix configuration. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
updated_prefix = current.prompt_prefix.model_copy(update=fields)
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
return _to_response(saved.prompt_prefix)
@@ -0,0 +1,212 @@
"""Tag management API.
Tags are split into three types (``agent`` / ``skill`` / ``scheduled_task``).
A tag may only be applied to a resource of its own type. Tag CRUD and tag
assignment are admin-only; listing tags and reading a resource's tags is open
to everyone (the frontend needs them to render chips and search filters).
"""
from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request, get_tag_store
from deerflow.persistence.tags import TAG_TYPES, TagStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tags", tags=["tags"])
# --------------------------------------------------------------------------- auth
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Tag management is admin-only")
def _validate_tag_type(tag_type: str) -> str:
if tag_type not in TAG_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid tag type '{tag_type}'. Allowed: {', '.join(TAG_TYPES)}")
return tag_type
# ------------------------------------------------------------------- schemas
class TagResponse(BaseModel):
id: str
name: str
tag_type: str
color: str = "gray"
description: str = ""
usage_count: int = 0
created_at: str | None = None
updated_at: str | None = None
class TagListResponse(BaseModel):
tags: list[TagResponse]
class TagCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=256)
tag_type: str = Field(..., description="One of: agent, skill, scheduled_task")
color: str = Field(default="gray", max_length=16)
description: str = Field(default="", max_length=1024)
class TagUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=256)
color: str | None = Field(default=None, max_length=16)
description: str | None = Field(default=None, max_length=1024)
class TagAssignmentRequest(BaseModel):
tag_ids: list[str] = Field(default_factory=list, description="Full set of tag ids to assign; replaces the existing set")
# ----------------------------------------------------------------- tag CRUD
@router.get("", response_model=TagListResponse, summary="List Tags")
async def list_tags(
tag_type: str | None = Query(default=None, description="Filter by tag type"),
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
if tag_type is not None:
_validate_tag_type(tag_type)
try:
records = await store.list_tags(tag_type)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except Exception as e:
logger.error("Failed to list tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list tags: {e}")
@router.post("", response_model=TagResponse, status_code=201, summary="Create Tag")
async def create_tag(
body: TagCreateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
_validate_tag_type(body.tag_type)
try:
record = await store.create_tag(
{
"id": uuid.uuid4().hex,
"name": body.name.strip(),
"tag_type": body.tag_type,
"color": body.color,
"description": body.description,
}
)
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to create tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to create tag: {e}")
@router.put("/{tag_id}", response_model=TagResponse, summary="Update Tag")
async def update_tag(
tag_id: str,
body: TagUpdateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
update_data = body.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
update_data["name"] = update_data["name"].strip()
try:
record = await store.update_tag(tag_id, update_data)
if record is None:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update tag: {e}")
@router.delete("/{tag_id}", status_code=204, summary="Delete Tag")
async def delete_tag(
tag_id: str,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> None:
await _require_admin(request)
try:
deleted = await store.delete_tag(tag_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete tag: {e}")
# --------------------------------------------------------------- assignments
@router.get(
"/assignments/{target_type}/{target_id}",
response_model=TagListResponse,
summary="Get Resource Tags",
)
async def get_resource_tags(
target_type: str,
target_id: str,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
_validate_tag_type(target_type)
try:
records = await store.get_target_tags(target_type, target_id)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except Exception as e:
logger.error("Failed to get resource tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get resource tags: {e}")
@router.put(
"/assignments/{target_type}/{target_id}",
response_model=TagListResponse,
summary="Set Resource Tags",
)
async def set_resource_tags(
target_type: str,
target_id: str,
body: TagAssignmentRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
await _require_admin(request)
_validate_tag_type(target_type)
try:
records = await store.set_target_tags(target_type, target_id, body.tag_ids)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set resource tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to set resource tags: {e}")
@@ -19,6 +19,7 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
from app.gateway.utils import sanitize_log_param
from deerflow.config.system_settings import load_system_settings
from deerflow.runtime import (
END_SENTINEL,
HEARTBEAT_SENTINEL,
@@ -102,6 +103,43 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
The prefix is stored in ``additional_kwargs['prompt_prefix']`` rather than
concatenated into ``content``. This keeps the persisted/displayed message
clean — the user never sees the prefix — while ``PromptPrefixMiddleware``
injects it into the content of the request actually sent to the LLM.
Leaves the input unchanged when there are no messages or the first message
is not a human turn.
"""
if not prefix:
return graph_input
messages = graph_input.get("messages")
if not isinstance(messages, list) or not messages:
return graph_input
new_messages = list(messages)
for idx, msg in enumerate(new_messages):
if isinstance(msg, HumanMessage):
extra = dict(getattr(msg, "additional_kwargs", None) or {})
extra["prompt_prefix"] = prefix
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
break
return {**graph_input, "messages": new_messages}
def _prompt_prefix_opted_in(body: Any) -> bool:
"""Detect whether the caller asked for the prompt prefix to be applied."""
context = getattr(body, "context", None)
if isinstance(context, Mapping) and context.get("prompt_prefix_enabled"):
return True
metadata = getattr(body, "metadata", None)
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
return True
return False
# Whitelist of run-context keys that the langgraph-compat layer forwards from
# ``body.context`` into the run config. ``config["context"]`` exists in
# LangGraph >=0.6, but these values must be written to both ``configurable``
@@ -114,8 +152,10 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"is_plan_mode",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
@@ -363,6 +403,19 @@ async def start_run(
agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input)
# Admin-configurable prompt prefix: when the caller opted in via
# context.prompt_prefix_enabled, tag the human message with the configured
# prefix (stored in additional_kwargs, not concatenated into content, so the
# user never sees it — PromptPrefixMiddleware injects it for the LLM only).
# The frontend only sends the flag on the opening turn, so this naturally
# applies once. We intentionally do NOT gate on thread-row existence: the
# thread_meta row is frequently pre-created, which previously skipped the
# prefix for genuine first turns.
if _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
@@ -0,0 +1,220 @@
# 台湾选举分析知识库 — 顶层入口
模型每次分析前必读此文件。本文件不含实质政治知识,仅定义工作流程、知识库结构、与必须遵循的元规则。
---
## 一、本知识库的目的与边界
### 目的
为 2028 年台湾大选相关的研究分析提供:
- 结构化的分析框架与方法论
- 政治行动者、议题、事件的档案
- 标准化的分析流程(playbooks)
### 适用任务类型
- 政治论述与发言的解读
- 选情分析与情境推演
- 两岸议题分析
- 美台、日台关系动态分析
- 国内政党与派系动态分析
- 舆论事件的深度解读
- 历史类比与趋势追踪
### 不适用任务类型
- 经济、科技、法律等非政治领域的专门问题
- 个人隐私、未公开通讯
- 未经查证的指控
- 撰写政治宣传、竞选文案、攻击性内容
- 价值评判("X 党是好/坏"、"应该/不应该")
遇到不适用任务,应明确告知"超出本知识库设计范围",不勉强回应。
---
## 二、知识库结构
| 目录 | 角色 | 何时读 |
|---|---|---|
| `routing/` | 分诊层:根据问题类型选择该读哪些其他文件 | 接到任务后第一站 |
| `playbooks/` | 操作手册:针对各类分析任务的具体流程 | 每次分析必有至少一个 |
| `primitives/` | 分析工具:概念定义、理论框架、方法论 | 当 playbook 调用时 |
| `entities/` | 行动者档案:人物、政党、机构、媒体、外国行动者 | 当分析涉及具体行动者 |
| `narratives/` | 叙事层:政治论述与其变化 | 当分析涉及政治论述时 |
| `events/` | 事件层:历史事件、时间线、重复模式 | 当需要事件背景或类比时 |
| `data_pointers/` | 实时数据接口说明 | 当需要当前素材时 |
| `reference/` | 参考资料:术语、人物索引、对照表 | 按需查询 |
---
## 三、接到任务的标准工作流
### Step 0: 必读宪法文件(每次分析必读,不可跳过)
每次分析前必须先读以下四份"宪法文件":
1. **`playbooks/_how_to_use_playbooks.md`** — playbook 使用规范
2. **`playbooks/avoid_pitfalls.md`** — 常见陷阱清单
3. **`playbooks/uncertainty_communication_protocol.md`** — 不确定性表达规范
4. **`reference/source_credibility_tiers.md`** — 来源可信度分级标准
这四份文件定义了"什么叫合格的分析"。所有后续步骤都在这四份文件的约束下进行。
### Step 1: 任务类型识别
判断任务属于哪类(可多选):
- 事实查询?事件解读?政治论述分析?预测推论?比较分析?
- 涉及哪个层面:国内政治/两岸/美台/日台/国际/综合?
- 时间范围:当下/近期/长期?
根据判断结果,前往对应的 `routing/by_*.md`。
### Step 2: 路由分诊
根据 Step 1 的判断,至少读一个 routing 文件:
- 任务以"问题类型"分类 → `routing/by_question_type.md`
- 任务以"议题"为核心 → `routing/by_topic.md`
- 任务以"行动者"为核心 → `routing/by_actor.md`
- 任务以"事件"为核心 → `routing/by_event.md`
- 任务涉及多重视角 → `routing/by_analytical_lens.md`
- 任务涉及多议题/多人物 → 加读 `routing/cross_reference_rules.md`
routing 文件会进一步指引该读哪些具体内容文件与 playbook。
### Step 3: 选定 playbook
根据 routing 指引,选 1-3 个 playbook。多 playbook 时遵循 `playbooks/multi_playbook_orchestration.md` 的组合规则。
### Step 4: 按 playbook 流程执行
playbook 每个 step 会指示读取具体的 entities / narratives / events / primitives 文件。**严格按 playbook 流程执行**,不要自行裁断要不要某步。
### Step 5: 调用实时素材(如需要)
若分析涉及当前事件或近期资料,按当前实施阶段的实时数据接入方式调用。
**第一阶段实施**:使用者提供"事件文档"作为输入,模型直接处理该文档。详见 `data_pointers/event_document_format.md`(占位)。
### Step 6: 输出前自检
按 playbook 指定的自检清单进行,至少确认:
- [ ] 每个判断都标注了信心度(依宪法文件 3)
- [ ] 没有落入宪法文件 2 中列出的陷阱
- [ ] 每个事实陈述都有来源,且来源等级达标(依宪法文件 4)
- [ ] 区分了"事实"、"立场表态"、"分析推论"
- [ ] 涉及争议议题时呈现了多方视角
- [ ] 不确定时明示了不确定性
### Step 7: 输出
按 playbook 指定的输出格式产出。末尾通常应有"信心度总结"段落(依宪法文件 3 第八节)。
---
## 四、关键元规则(每次分析都适用)
### 元规则 1: 宪法文件具有优先权
四份宪法文件的规范优先于任何 playbook 或 narrative 中的指引。若有冲突以宪法文件为准。
### 元规则 2: 区分事实层、诠释层、推论层
任何分析输出都应区分:
- **事实层**:发生了什么(来源等级 T1-T2 支持)
- **诠释层**:各方如何理解(多方视角呈现)
- **推论层**:分析者基于框架的判断(信心度标注)
不可混淆。混淆会让"立场"伪装成"事实"。
### 元规则 3: 不做价值评判
不写"应该""理想""糟糕""幸运""可惜"等评价性词汇。
分析的工作是"解码与解释",不是"评价"。
涉及多方利害时呈现各方视角,不替任何方背书。
### 元规则 4: 立场中立的语言
参见 `reference/code_words_and_euphemisms.md`(占位)的中性术语表。
在分析(非引用)中用最中性词汇。
引用某方说法时明示"某派人士主张..."。
### 元规则 5: 知识库未涵盖即明示
不推测、不编造、不"补全"。
明示"知识库未涵盖此问题"是合格分析。
### 元规则 6: 实时素材必须经过审视
实时素材(事件文档中的内容)是"分析对象",不是"分析框架"。
不要把素材中的观点直接吸收为知识库的知识。
### 元规则 7: 引用具体到段落
文件引用应具体("读 X 文件的 Y 段落"),不要笼统("读 X 文件")。
这是 playbook 的撰写要求,也是模型的执行要求。
---
## 五、特殊情境的处理
### 情境 1: 简单事实查询
若使用者只是查询单一已知事实("2024 年总统选举得票率"),可跳过完整工作流,直接从 reference 或 events 取出资料。但仍应标注来源等级。
### 情境 2: 模糊任务
若任务描述不清,先要求使用者澄清,不要硬猜。澄清问题应具体("您想分析的是 X 还是 Y?")。
### 情境 3: 任务涉及多重 playbook
若任务复杂涉及多 playbook,按 `playbooks/multi_playbook_orchestration.md` 的组合规则。**不要平行执行多 playbook**——会导致输出结构混乱。
### 情境 4: 紧急或时效性任务
即使紧急,仍走标准流程。"快速分析"不是"省略宪法文件"的理由。但可输出"快速版"(较短、聚焦、明示是初步判断)。
### 情境 5: 关于本工具或元问题
若使用者询问"这个工具能做什么"、"知识库怎么组织"等元问题,直接回答,不走 playbook。
---
## 六、本知识库的"自我意识"
模型在使用本知识库时应保持以下自觉:
1. **本知识库不全知**——总有未涵盖的部分
2. **本知识库有时差**——重大事件可能尚未更新
3. **本知识库有偏好**——选择什么放进知识库本身就是判断
4. **本知识库是工具,不是真理**——使用者应批判性使用产出
诚实标注以上局限,是合格使用本知识库的标志。
---
## 七、版本与更新
- 本 INDEX 文件最后更新:2026-05-08
- 当知识库结构有重大改动时,本文件应同步更新
- 当宪法文件有改动时,本文件应反映新的工作流
---
## 八、最简版工作流(提醒卡)
如果模型读到这里只能记住一件事,应该是这个流程:
```
1. 读 4 份宪法文件
2. 走 routing → 选 playbook
3. 按 playbook 执行
4. 自检 → 输出
5. 不知道就说不知道
```
其他都是这五步的展开。
@@ -0,0 +1,487 @@
# 2028 台湾选举前景分析框架
---
**类型**: 分析框架
**当前角色**: 总统大选整合分析工具
**何时调用**: 评估 2028 大选走势;整合各党路径与外部变数;判断选举结构变迁;分析特定事件对 2028 的结构性冲击;需要区分长期趋势与短期波动时
---
## 一句话浓缩
2028 台湾选举不是 2024 的简单延长——是**结构变量(三脚督固化、基本盘重组、能源与国安绑定)**与**情境变量(川普 2.0 外溢、北京施压节奏、2026 期中惩罚)**的碰撞点;分析 2028 不能只看候选人特质,必须拆解变量层级与交互作用。
---
## 框架的核心问题
任何"2028 大选走势"分析都应回答:
- **这是结构变量还是情境变量?**——长期趋势还是短期波动?
- **三脚督结构是否稳固?**——TPP 是否能维持关键少数,还是回归二元对立?
- **外部变数如何内化?**——川普 2.0 与北京施压如何穿透台湾内部政治?
- **执政党的"惩罚积累点"在哪?**——8 年执政包袱如何与期中惩罚交互?
- **议题切割线是否位移?**——两岸、能源、世代议题的阵营归属是否改变?
不回答这些问题的分析,会简化为"某党推出某人所以会赢/输"——这种分析几乎总是不完整。
---
## 维度一:结构变量 vs 情境变量
分析 2028 的第一步,是区分哪些因素是"慢变的结构",哪些是"快变的情境"。混淆两者会导致误判——把短期波动当成长期趋势,或把结构性限制当成可以靠候选人魅力突破。
### 结构变量(慢变、深层、限制选项范围)
| 变量 | 当前状态 | 对 2028 的结构性限制 |
|---|---|---|
| 人口结构 | 高龄化加速、首投族减少 | 铁票(高龄)权重增,但年轻游离票决定胜负 |
| 基本盘版图 | 绿约 35-40%、蓝约 25-30%、白约 15-20% | 无一政党可单独过半,必须跨版图动员 |
| 三脚督结构 | 总统层级已确立三强 | 弃保触发门槛提高,20% 成关键杠杆 |
| 能源结构 | 核三除役、再生能源进度滞后 | 供电缺口与电价压力成为执政党硬约束 |
| 两岸关系基线 | 北京反应阶梯常态化升级 | "抗中保台"边际效用递减,但"避战"需求上升 |
| 产业结构 | 半导体与 AI 供应链核心 | 经济红利分配不均,"繁荣感受"与数据脱节 |
**分析含义**:结构变量决定选举的"可能空间"。任何候选人或政策都必须在此空间内运作——超出此空间的主张(如重启核四、立即统一)会被结构排除。
### 情境变量(快变、表层、创造波动机会)
| 变量 | 当前状态 | 对 2028 的情境冲击 |
|---|---|---|
| 川普 2.0 政策 | 关税、军费要求、交易主义 | 台美关系从"资产"变"双面刃" |
| 北京施压节奏 | 选前可能调整(升级或降级) | 直接影响"抗中/避战"议题权重 |
| 2026 期中选举结果 | 待定 | 决定执政党包袱重量与在野整合基础 |
| 候选人特质与丑闻 | 待定 | 可能打破基本盘结构或强化弃保 |
| 全球经济周期 | 软着陆/衰退风险并存 | 影响执政党经济治理评价 |
| 突发军事/安全事件 | 不确定 | 可能冻结选举动态或彻底重塑议题 |
**分析含义**:情境变量决定选举的"实际路径"。同一结构下,不同情境组合会产生截然不同的结果。分析 2028 必须多情境并陈,而非预测单一情境。
### 结构与情境的交互
关键洞察:**情境变量通过结构变量发挥作用**。
例如:川普 2.0 的军费要求(情境)之所以对 DPP 构成压力,是因为台湾内部存在"能源与国安绑定"的结构(能源供给不足让国防韧性受质疑)。如果能源结构充足,川普的要求可能被转化为"强化国防"的正向动员。
分析时必须问:**这个情境冲击,是强化了现有结构,还是挑战了现有结构?**
---
## 维度二:三党路径的结构性张力
三党在 2028 面临的挑战不是"找最强候选人"——是**内部结构张力如何在外部压力下找到平衡点**。
### DPP:执政包袱与保位路线
**核心张力**:"维持现状"的治理绩效 vs "抗中保台"的动员需求
| 维度 | 优势 | 劣势/挑战 |
|---|---|---|
| 基本盘 | 35-40% 铁板稳固,抗中论述所有权 | 8 年执政包袱(居住、能源、通胀感受) |
| 议题所有权 | 两岸主权、民主价值 | 经济分配、能源稳定、司法改革信任度下降 |
| 组织动员 | 执政资源、地方派系整合 | 2022 地方选举挫败的余震,2026 期中惩罚风险 |
| 世代连结 | 中壮世代接班(如赖清德后的权力交接) | 年轻选票流失至 TPP,"青绿连结"断裂 |
**结构性命题**:DPP 的"抗中保台"在 2028 面临边际效用递减——不是因为北京威胁减少,而是因为此论述已从"动员口号"变为"日常基线"。当"抗中"成为常态,选民的关注点会从"是否抗中"转向"抗中的代价谁承担"(能源、经济、兵役)。
**分析时如何用**:评估 DPP 路径时,不要只看其两岸论述强度——看其**如何将两岸议题与内部治理(特别是能源与经济)重新绑定**。若无法绑定,两岸议题将不再是自动提款机。
详见 `entities/parties/dpp_2028_path.md`。
### KMT:路线内战与整合困境
**核心张力**:"深层蓝"的统派/亲中遗产 vs "本土蓝"的台湾主体性需求
| 维度 | 优势 | 劣势/挑战 |
|---|---|---|
| 基本盘 | 25-30% 铁票,组织动员扎实 | 基本盘持续萎缩,年轻断层严重 |
| 议题所有权 | 两岸和平、经济稳定 | 亲中标签在危机情境下成为负债 |
| 组织动员 | 地方派系、农渔会系统 | 派系利益与党中央路线常冲突 |
| 路线整合 | 深蓝/本土蓝在反 DPP 上一致 | 在两岸定位上根本分歧,候选人难两面讨好 |
**破除常见简化**:把 KMT 等同于"亲中政党"是错的。KMT 内部存在"两岸和平协议"与"坚守中华民国主权"的深层路线斗争。这种斗争在平时可被"反 DPP"掩盖,但在总统大选的聚光灯下必然爆发——候选人必须在"北京可接受"与"台湾中间选民可接受"之间走钢丝。
**分析时如何用**:评估 KMT 路径时,看其**候选人如何处理"九二共识"的变体**。若候选人试图模糊处理,深蓝可能不出席投票(流失 5-8%);若候选人向深蓝靠拢,中间选民将流向 TPP 或 DPP。这是一个零和结构。
详见 `entities/parties/kmt_two_lines_struggle.md`。
### TPP:后柯时代的存续挑战
**核心张力**:"一人政党"的遗产 vs "制度性政党"的转型需求
| 维度 | 优势 | 劣势/挑战 |
|---|---|---|
| 基本盘 | 15-20% 铁板,年轻、高学历、都会区 | 高度依赖柯文哲个人魅力,组织薄弱 |
| 议题所有权 | 反建制、打破蓝绿、新政治 | 柯文哲司法案件冲击"清廉"品牌 |
| 组织动员 | 网络动员强,空战能力佳 | 陆战薄弱,地方扎根不足 |
| 关键少数地位 | 立法院关键少数,维持能见度 | 关键少数的投票记录成为执政包袱 |
**结构性命题**:TPP 在 2028 面临的不是"如何赢"——是**如何避免泡沫化**。总统大选的三脚督结构是 TPP 存续的最大资产,但若柯文哲司法案件定谳或 TPP 立法院表现被定性为"小蓝",其 15-20% 的铁板可能崩解,回归蓝绿二元结构。
**分析时如何用**:评估 TPP 路径时,看其**立法院投票联盟的倾向**。若持续与 KMT 合作,可能流失浅绿游离票;若与 DPP 合作,可能流失浅蓝游离票。TPP 的"超越蓝绿"定位在立法院的实质投票中不断被检验。
详见 `entities/parties/tpp_post_ko.md`。
---
## 维度三:三脚督 vs 二元结构的两种情境
2028 选举的核心结构问题是:**三脚督是否能在总统层级存活?**
### 情境一:三脚督延续(TPP 维持 15%+)
**数学逻辑**:
- 三脚督下,胜选门槛降至 40% 左右
- DPP 只需固守 35-40% 基本盘 + 拿下部分中间选民即可连任
- KMT 必须拿下 30% 基本盘 + 大部分 TPP 流向的选票才能胜选
- TPP 的 15-20% 是"否决权"——决定谁当选,但自己无法当选
**政治逻辑**:
- 弃保效应在总统选举中难以触发(与立委单一选区不同)
- TPP 支持者的第二选择分散(部分偏蓝、部分偏绿、部分不投票)
- "下架 DPP"不足以整合在野——因为 KMT 与 TPP 的支持者对"谁该让"没有共识
**分析含义**:三脚督延续对 DPP 有结构性优势——不是因 DPP 较强,而是因在野力量分散。DPP 的风险在于"执政包袱让基本盘萎缩至 35% 以下"。
### 情境二:回归二元对立(TPP 泡沫化至 10% 以下)
**数学逻辑**:
- 二元结构下,胜选门槛回到 50%+
- 中间选民成为决定性力量
- 执政党的包袱被放大——每一张流失的选票都直接流向对手
**政治逻辑**:
- 弃保效应在选前最后两周可能剧烈发生
- "谁最能赢"成为在野整合的唯一标准
- 两岸议题的二元对立性被强化——"抗中 vs 亲中"的光谱重新主导
**分析含义**:二元结构对 KMT 有结构性优势——不是因 KMT 较强,而是因反执政票可以集中。但 KMT 的风险在于"候选人无法让 TPP 支持者愿意含泪投票"。
### 两种情境的触发条件
| 触发条件 | 三脚督延续 | 回归二元 |
|---|---|---|
| 柯文哲司法案件 | 无罪或轻判 | 重判定谳,形象崩塌 |
| TPP 立法院表现 | 维持关键少数,议题独立 | 被完全吸收为"小蓝"或"小绿" |
| KMT 候选人 | 深蓝或争议人选 | 温和、可接受度高的人选 |
| 两岸情境 | 平稳或低度紧张 | 重大危机,强化二元对立 |
| 2026 期中选举 | TPP 维持县市议员基本盘 | TPP 地方选举全面溃败 |
**分析时如何用**:不要假设三脚督必然延续——这是 2024 的新现象,尚未经过第二次总统大选验证。分析时必须同时评估两种情境,并监控触发条件的演变。
---
## 维度四:关键内部议题的切割线
2028 的内部议题不是平行并列——是**相互绑定**的。分析时不能单独看"居住政策"或"能源政策",必须看其如何与两岸、世代议题交叉。
### 两岸议题:从"统独"到"避战/备战"的光谱位移
**事实层**:
- "统独"议题在台湾内部已无市场——支持统一者低于 10%
- "维持现状"是主流,但"现状"的定义分歧
- 北京的施压让"避战"成为跨党派共识,但"如何避战"分歧极大
**脉络层**:
- DPP 的"备战以避战" vs KMT 的"对话以避战" vs TPP 的"自主以避战"
- 这三种论述不是"亲中/反中"的二元对立——是"风险来源认定"的差异
- DPP 认定风险来自北京;KMT 认定风险来自两岸缺乏互信;TPP 认定风险来自过度依赖单方
**分析含义**:把 2028 的两岸议题简化为"抗中 vs 亲中"是错的。更准确的分析框架是"风险来源认定"——各党如何定义威胁,决定了其政策主张的合理性。分析时看各党如何争夺"避战"的诠释权。
### 能源议题:与国安绑定的结构性硬约束
**事实层**:
- 核三将于 2025 除役,核电归零
- 再生能源进度滞后,电网韧性受质疑
- AI 与半导体产业用电需求持续增长
**脉络层**:
- 能源不再只是环保议题——是国安议题
- 供电缺口影响半导体投资,半导体影响经济,经济影响国防预算
- 北京的封锁演练让"能源储备"成为国防问题
**破除常见简化**:把"两岸议题"等同于"北京施压"是错的。2028 的两岸议题是"北京施压 + 华盛顿要求 + 台湾内部能源转型"的三元方程。能源依赖(天然气接收站、电网韧性)与两岸风险深度绑定——若台湾电网脆弱,北京的军事压力(甚至不需实际封锁)就能造成经济恐慌。这是结构性命题。
**分析时如何用**:评估各党能源主张时,不只看"核不核"——看其**如何将能源政策与国安论述绑定**。能够将能源转型与"强化国防韧性"成功绑定的政党,将在 2028 取得议题主导权。
### 居住与世代议题:跨党派痛点但非决定性切割线
**事实层**:
- 房价所得比持续攀升
- 年轻世代相对剥夺感强烈
- 各党均提出居住政策,但执行成效有限
**脉络层**:
- 居住是"跨党派痛点"——DPP 与 KMT 执政县市均面临房价问题
- 这意味着居住议题**难以成为单一政党的攻击武器**——因为对方可以反问"你执政时做了什么"
- 年轻选票的流动更多受"反建制"情绪驱动,而非具体政策比较
**分析含义**:居住议题是"背景噪音"——影响选民情绪,但难以成为决定性切割线。分析时看哪个政党能将居住不满**转化为对执政党的整体不信任**,而非仅停留在政策辩论。
### 议题交互矩阵
| 议题 | DPP 优势 | KMT 优势 | TPP 优势 | 交互绑定 |
|---|---|---|---|---|
| 两岸 | 主权捍卫 | 和平对话 | 避战自主 | 能源、国安 |
| 能源 | 绿能愿景 | 核能延役 | 务实供电 | 两岸、经济 |
| 居住 | 社宅政策 | 批评高房价 | 反建制 | 世代、分配 |
| 世代 | 民主价值 | 传统文化 | 打破蓝绿 | 居住、两岸 |
---
## 维度五:关键外部变数的穿透机制
外部变数不会直接决定 2028 结果——它们**通过台湾内部的政治结构发挥作用**。分析外部变数时,必须追踪其"穿透路径"。
### 川普 2.0:台美关系从"资产"变"双面刃"
**穿透路径**:
1. 川普要求增加军费 → 台湾内部"谁买单"的分配争议
2. 川普的交易主义 → 台湾内部对"美国是否可靠"的信任危机
3. 川普的对中关税 → 台湾供应链的重新洗牌 → 经济感受变化
**对三党的差异化影响**:
- **DPP**:台美关系原本是资产,但川普的"交易主义"让"亲美"变成"被勒索"的代名词。DPP 必须重新定义"台美关系"——从"价值同盟"转向"实力贡献"。
- **KMT**:川普的不确定性为 KMT 的"疑美论"提供弹药,但过度操作可能被反噬为"亲中卖台"。
- **TPP**:川普的"美国优先"与 TPP 的"台湾自主"有论述亲和性,TPP 可能操作"不依赖任何一方"的叙事。
**分析含义**:川普 2.0 对 2028 的最大影响不是具体政策——是**改变台湾内部对"台美关系"的诠释框架**。从"美国保护我们"到"我们必须付出代价",这种框架位移让 DPP 的传统优势变成需要防守的阵地。
### 北京施压模式:选前节奏的调整
**穿透路径**:
1. 军事压力升级 → "抗中保台"动员增强(对 DPP 短期有利)
2. 经济胁迫升级 → 经济感受恶化(对 DPP 执政评价不利)
3. 法律工具升级 → 两岸敌意加深(长期强化台湾主体意识)
4. 选前刻意降级 → 试图影响选举结果(降低抗中动员力)
**北京的两难**:
- 过度施压 → 为 DPP 助选(2020 效应)
- 不施压 → 台湾内部"避战"需求下降,DPP 仍占优势
- 精准施压 → 难以控制台湾内部的诠释方向
**分析含义**:北京的施压不是单一方向——是**在"惩罚 DPP"与"避免为 DPP 助选"之间的动态调整**。分析时看北京的反应阶梯(参考 `primitives/frameworks/beijing_response_typology.md`)是在哪一层,以及台湾内部如何诠释。
详见 `events/recurring_patterns/pattern_pre_election_pressure.md`(占位)。
### 经济周期:繁荣感受与分配脱节
**穿透路径**:
1. 全球经济衰退 → 出口下滑 → 经济成长率下降
2. AI 与半导体红利 → 股市创新高 → 但受雇人员薪资感受脱节
3. 通胀感受 → 民生物价上涨 → 执政党惩罚
**结构性命题**:台湾经济呈现"宏观数据好、微观感受差"的矛盾——GDP 成长受半导体出口驱动,但多数受雇者不在半导体业。这种脱节让执政党的"经济成绩单"无法转化为选票。
**分析时如何用**:评估经济变数时,不看 GDP 成长率——看**受雇人员薪资成长率与物价上涨率的差距**。这个差距才是影响投票行为的"经济感受"。
---
## 维度六:潜在"黑天鹅"与极端情境
黑天鹅事件的特征是**低概率、高冲击、事后可解释**。分析 2028 不能预测黑天鹅,但必须**识别黑天鹅可能出现的领域,并评估其结构性影响**。
### 黑天鹅领域一:重大军事事件
**情境**:台海封锁、离岛冲突、飞弹试射越界、海空交火
**结构性影响**:
- 冻结选举动态——"团结旗帜"下难以挑战执政党
- 彻底重塑议题权重——两岸议题压倒一切
- 可能触发美方直接介入——改变台美关系框架
- 经济立即冲击——股汇市崩盘、供应链中断
**分析含义**:重大军事事件是**唯一可能让"三脚督"瞬间回归"二元对立"的变数**。在危机情境下,TPP 的"超越蓝绿"论述失去着力点,选民被迫在"抗中"与"避战"之间选择。
### 黑天鹅领域二:领导人变故
**情境**:总统健康危机、重大司法案件、突发辞职
**结构性影响**:
- 执政党紧急换将——打乱原有布局
- 可能引发党内权力斗争——分裂风险
- 在野党失去"共同敌人"——攻击主轴失效
**分析含义**:领导人变故是**最可能改变"执政包袱"权重的变数**。新领导人可以"切割前任"重新开始,但也可能因缺乏民意基础而更脆弱。
### 黑天鹅领域三:经济危机
**情境**:全球金融危机、半导体产业衰退、房地产泡沫破裂
**结构性影响**:
- 执政党经济治理评价崩盘
- "繁荣感受"彻底破灭
- 可能引发社会动荡——抗议、罢工
**分析含义**:经济危机是**最可能让"居住与分配"议题升级为决定性切割线的变数**。在危机情境下,各党的经济政策主张将被严格检验,空洞的口号不再有效。
### 黑天鹅的应对原则
分析黑天鹅不是"预测哪一个会发生"——是**评估各政党在不同黑天鹅情境下的脆弱度**:
| 黑天鹅 | DPP 脆弱度 | KMT 脆弱度 | TPP 脆弱度 |
|---|---|---|---|
| 重大军事事件 | 低(抗中论述优势)| 高(亲中标签风险)| 中(论述空间被压缩)|
| 领导人变故 | 高(执政连续性中断)| 中(在野调整空间大)| 低(反建制定位不受影响)|
| 经济危机 | 高(执政包袱)| 中(经济议题优势但无执政经验)| 中(反建制情绪增强但无解决方案)|
---
## 实务应用:用此框架分析 2028
### 步骤一:建立结构基线
首先确认当前的结构变量状态:
- 基本盘版图(参考民调趋势,非单一时点)
- 三脚督结构的稳固度(TPP 支持度是否稳定在 15%+)
- 能源与国安的绑定程度(供电状况、北京施压基线)
### 步骤二:设定情境组合
基于情境变量,设定 2-3 个最可能的情境组合:
- 情境 A:川普温和施压 + 北京低度施压 + 经济平稳 + 三脚督延续
- 情境 B:川普强力施压 + 北京中度施压 + 经济放缓 + 三脚督松动
- 情境 C:重大危机(军事或经济)+ 二元对立回归
### 步骤三:评估各党路径在每种情境下的表现
使用三党路径的结构性张力分析,评估各党在不同情境下的优势与劣势变化。
### 步骤四:识别议题切割线的位移
在每种情境下,判断哪个议题成为决定性切割线:
- 情境 A:居住与世代(内部治理主导)
- 情境 B:能源与国安(外部压力主导)
- 情境 C:两岸与生存(危机主导)
### 步骤五:评估黑天鹅的冲击
识别当前最可能爆发的黑天鹅领域,评估其对各党脆弱度的差异化影响。
### 步骤六:整合判断
不预测单一结果——给出**各情境下的可能结果范围**,并明示判断的假设与不确定性。
### 步骤七:持续监控与更新
识别哪些指标变化会触发情境转换:
- TPP 支持度跌破 15% → 三脚督松动
- 北京军演升级至第五层 → 两岸议题权重急升
- 川普对台提出具体军费要求 → 台美关系框架位移
- 2026 期中选举 DPP 大败 → 执政包袱加重
---
## 关联分析
### 关联一:与 DPP 2028 路径的连结
本框架提供 DPP 路径的"外部约束条件"——DPP 的路线选择不是在真空中进行,而是在结构变量与情境变量的约束下调整。
**功能**:提供脉络层——DPP 的"抗中保台"论述如何受川普 2.0 与北京施压节奏的交互影响。
详见 `entities/parties/dpp_2028_path.md`。
### 关联二:与 KMT 路线斗争的连结
本框架提供 KMT 路线斗争的"选举结构背景"——KMT 的路线选择取决于三脚督是否延续,以及两岸议题的权重。
**功能**:提供脉络层——KMT 的"深蓝/本土"路线选择如何受外部变数(特别是北京施压模式)的影响。
详见 `entities/parties/kmt_two_lines_struggle.md`。
### 关联三:与 TPP 后柯路径的连结
本框架提供 TPP 存续的"结构性条件"——TPP 的命运取决于三脚督结构是否稳固,以及黑天鹅是否压缩其论述空间。
**功能**:提供对照——TPP 的"超越蓝绿"论述在二元对立情境下如何失效。
详见 `entities/parties/tpp_post_ko.md`。
### 关联四:与选举周期论述的连结
本框架的"结构 vs 情境"区分与选举周期论述的"期中惩罚"模式相互验证——2026 的期中惩罚是情境变量,但其对 2028 的影响是结构性的。
**功能**:提供事实层——期中惩罚的历史模式如何作为 2028 分析的基线。
详见 `narratives/domestic/election_cycles_2028.md` 与 `events/recurring_patterns/pattern_mid_term_punishment.md`。
### 关联五:与情境规划方法的连结
本框架的多情境并陈方法与情境规划 playbook 一致——分析 2028 不是预测单一结果,是评估多种可能情境。
**功能**:提供方法论——如何系统性地构建与评估 2028 情境。
详见 `playbooks/scenario_planning_2028.md`。
### 关联六:与北京反应类型学的连结
本框架的"外部变数穿透机制"需要北京反应类型学的具体工具——北京在选前的施压节奏调整,是影响 2028 最重要的外部变数之一。
**功能**:提供交叉验证——北京的施压阶梯如何与台湾内部选举动态交互。
详见 `primitives/frameworks/beijing_response_typology.md`。
---
## 常见的分析陷阱
### 陷阱一:把 2028 当作 2024 的延长线
2024 的三脚督是新现象,尚未经过第二次验证。假设 2028 必然是三脚督,会忽略 TPP 泡沫化的可能性。
### 陷阱二:只看候选人特质,忽略结构约束
"某党推出强棒就会赢"的分析忽略了基本盘版图、议题权重、外部变数的结构性约束。候选人特质是情境变量,不是结构变量。
### 陷阱三:把"两岸议题"等同于"北京施压"
2028 的两岸议题是"北京施压 + 华盛顿要求 + 台湾内部能源转型"的三元方程。只看北京会忽略华盛顿与内部结构的交互作用。
### 陷阱四:假设"台美关系"必然是 DPP 的资产
川普 2.0 的交易主义可能让"亲美"变成"被勒索"的代名词。台美关系在 2028 可能从 DPP 的资产变为需要防守的阵地。
### 陷阱五:低估"期中惩罚的跨期效应"
2026 的期中惩罚不会在 2026 结束——其效应会延续至 2028。若 DPP 在 2026 大败,2028 的执政包袱将异常沉重。
### 陷阱六:把"经济数据"等同于"繁荣感受"
台湾经济的"宏观数据好、微观感受差"脱节,让 GDP 成长率无法转化为选票。分析经济变数时必须看受雇人员薪资与物价的差距。
---
## 仍不确定的部分
- **TPP 在后柯时代的组织存续能力**:柯文哲司法案件的最终结果与 TPP 立法院表现仍不确定
- **川普 2.0 对台具体要求的强度与时机**:交易主义的具体操作方式仍不明确
- **北京在 2027-2028 的施压节奏**:北京可能调整策略,但具体方向不确定
- **2026 期中选举的结果**:这将决定执政包袱的重量
- **能源转型的实际进度**:供电状况与电价政策仍受技术与政治因素影响
- **黑天鹅事件的发生概率**:本质上是不可预测的
---
## 与其他文件的关系
- DPP 2028 路径:`entities/parties/dpp_2028_path.md` —— 提供 DPP 内部路线细节
- KMT 路线斗争:`entities/parties/kmt_two_lines_struggle.md` —— 提供 KMT 内部张力分析
- TPP 后柯路径:`entities/parties/tpp_post_ko.md` —— 提供 TPP 存续条件分析
- 选举周期论述:`narratives/domestic/election_cycles_2028.md` —— 提供选举周期模式
- 情境规划方法:`playbooks/scenario_planning_2028.md` —— 提供多情境分析方法
- 期中惩罚模式:`events/recurring_patterns/pattern_mid_term_punishment.md` —— 提供历史基线
- 北京反应类型学:`primitives/frameworks/beijing_response_typology.md` —— 提供北京施压阶梯分析工具
- 美国对台政策框架:`primitives/frameworks/us_taiwan_policy_analysis.md` —— 提供美方多层次分析工具
---
## 一句话浓缩
> 2028 台湾选举是**结构变量(基本盘重组、三脚督固化、能源国安绑定)与情境变量(川普 2.0、北京施压、期中惩罚)的碰撞点**——分析时不能只看候选人或单一议题,必须拆解变量层级、追踪穿透路径、多情境并陈,并时刻警惕"把 2024 延长线当必然"的简化陷阱。
---
last_updated: 2025-05-20
@@ -0,0 +1,74 @@
# data_pointers/ 数据指针
不存放数据本身,存放对外部数据源的"使用说明"。playbook 在需要资料时根据这里的指针调用。
每个文件应说明:数据源是什么、如何取得、资料结构、刷新频率、使用注意事项。
## 文件清单
### 实时舆情数据
- `live_news_feed.md` — 即时新闻流(中央社/联合/自由/中时/报导者等)的接入与使用
- `live_ptt_feed.md` — PTT 政黑/八卦版的实时取样规则
- `live_x_twitter_feed.md` — X 平台关键意见领袖与议题流
- `live_youtube_political.md` — YouTube 政治频道与留言流
- `live_threads_dcard.md` — Threads / Dcard 政治话题取样
- `live_facebook_pages.md` — Facebook 公共粉专的接入限制与替代方案
- `live_political_talk_shows.md` — 政论节目监看(逐字稿来源与时差)
- `live_chinese_state_media.md` — 大陆官媒涉台内容的合法监看途径
- `live_international_media.md` — 国际媒体涉台报导的聚合
- `live_official_statements.md` — 官方声明流(总统府/陆委会/外交部/国安局/国台办/白宫/美国务院/AIT)
### 历史选举数据
- `cec_official_data.md` — 中选会官方资料库使用指南
- `g0v_election_apis.md` — g0v Ronny Wang 等社群 API 使用
- `precinct_level_data.md` — 村里级数据的取得与处理
- `historical_election_archive.md` — 历届选举资料的整合资源
### 立法资料
- `ly_official_api.md` — 立法院官方 API
- `ly_g0v_api.md` — ly.g0v.tw 国会观测站资料
- `voting_records_api.md` — 表决纪录的取得
- `legislator_speech_corpus.md` — 立委发言文本库
### 民调数据
- `teds_panel_data.md` — TEDS 学术调查资料的申请与使用
- `nccu_election_center_trends.md` — 政大选研中心长期趋势数据
- `polling_aggregator.md` — 主要民调机构发布的聚合
- `tvbs_polling.md` — TVBS 民调资料库
- `formosa_polling.md` — 美丽岛民调
- `mywebpoll_aggregator.md` — Mywebpoll 等聚合站点
- `polling_house_effects_table.md` — 各民调机构系统偏差对照表
### 财务与背景数据
- `political_donations_db.md` — 监察院政治献金资料库
- `legislator_assets_db.md` — 公职人员财产申报
- `government_procurement.md` — 政府采购公开资料
- `lobbying_disclosure_taiwan.md` — 台湾的游说揭露资料(限制)
- `g0v_finance_tools.md` — g0v 政治献金工具
### 国际数据
- `congress_gov_api_taiwan.md` — 美国国会 Congress.gov 涉台立法查询
- `us_state_dept_archive.md` — 美国国务院涉台档案
- `csis_taiwan_data.md` — CSIS 中国实力计画涉台数据集
- `ait_press_archive.md` — AIT 新闻稿存档
- `japan_official_archive.md` — 日本政府涉台公开档案
- `eu_taiwan_resolutions_db.md` — 欧洲议会涉台决议资料库
- `un_voting_taiwan_related.md` — 联合国涉台投票资料
### 大陆侧资料
- `tao_press_archive.md` — 国台办发布会档案
- `chinese_white_papers_taiwan.md` — 历次涉台白皮书
- `pla_activity_tracking.md` — 解放军活动公开追踪资源
- `chinese_media_taiwan_archive.md` — 大陆官媒涉台报导存档
### 地理与人口数据
- `taiwan_demographics.md` — 台湾人口统计资料
- `district_demographics.md` — 选区人口学背景
- `internal_migration_data.md` — 内部迁徙资料(影响选区结构)
- `economic_indicators_by_district.md` — 各选区经济指标
### 资料管线运作
- `pipeline_freshness_table.md` — 各类数据的刷新频率与延迟
- `data_quality_warnings.md` — 已知的资料品质问题清单
- `village_name_normalization.md` — 村里名异体字标准化处理(已知问题)
- `entity_disambiguation_rules.md` — 同名人物的消歧规则
@@ -0,0 +1,13 @@
# entities/ 实体层
存放所有"行动者"的档案:人物、政党、机构、媒体、外国行动者。每个文件应是分析导向(不是维基风),重点是"分析涉及此实体时该注意什么"。
## 子目录
- `people/` — 政治人物个人档案
- `parties/` — 政党档案(含派系)
- `institutions/` — 政府机构与重要组织
- `media/` — 媒体档案(含立场光谱与所有权)
- `foreign_actors/` — 外国政治人物、机构、智库
详见各子目录 `_MANIFEST.md`。
@@ -0,0 +1,319 @@
# 美日同盟与台湾
---
**类型**: 机构档案(同盟体系)
**当前角色**: 台海安全架构的核心制度基础 / 印太战略的轴心
**何时调用**: 分析美日联合声明涉台措辞变化;评估台海冲突时的外部军事干预可能性;解读日本防卫政策转向;处理美日台三边安全对话议题;评估北京对「同盟威慑」的反应
---
## 一句话浓缩
美日同盟是「台湾有事即日本有事」论述的制度基础——它从冷战时期的「战略模糊」走向当代的「政治清晰」,但在军事执行与法律授权上仍维持「刻意模糊」;理解美日同盟涉台演变的关键,不在于同盟说了什么,而在于其「政治表态」与「作战规划」之间的落差及结构性张力。
---
## 美日同盟的基本结构:法律与制度基础
### 核心法律文件
美日同盟的运作建立在三大法律与制度支柱上:
1. **美日安保条约(Treaty of Mutual Cooperation and Security, 1960)**
- **第五条**:缔约国在日本施政领域下遭到武装攻击时,视为对自身的攻击,将采取行动应对共同危险。这是同盟的「防卫义务」核心。
- **第六条**:为维护远东的国际和平与安全,允许美军使用日本设施与区域。这是驻日美军合法性的基础,也是日本作为「不沉航母」的法律依据。
2. **美日地位协定(U.S.-Japan Status of Forces Agreement, 1960)**
- 规范驻日美军的法律地位、税收、管辖权等。
- 在台海情境中,涉及美军从日本基地出击时是否需与日本事前协商(事前协议制度),这是日本行使「基地否决权」的潜在法律杠杆。
3. **防卫合作指针(Guidelines for U.S.-Japan Defense Cooperation)**
- 1978 年首版:冷战背景,针对苏联。
- 1997 年修订:纳入「周边事态」,模糊处理是否包含台湾。
- 2015 年修订:纳入「存立危机事态」与「重要影响事态」,为日本在非直接受击时提供后勤甚至武装支援的法律框架。
### 指挥与协调机制
- **美日安全协商委员会(2+2 会议)**:由美日外长与防长组成,是同盟最高决策机制。涉台措辞的升级多由此机制发布。
- **同盟协调机制(ACM)**:2015 年指针修订后建立,用于平时与危急时的实时协调。
- **联合指挥体系**:驻日美军司令部与自卫队各层级对应单位的日常协调。2024 年双方同意升级指挥体系,以应对突发事态。
**分析含义**:美日同盟不是单一行动者,而是由条约、指针、机制组成的复杂官僚体系。分析同盟行动时,必须区分「政治宣示(2+2 声明)」与「军事操作(指挥体系协调)」——前者可以快速升级,后者受制于法律解释与官僚作业。
---
## 涉台议题在美日同盟中的位置:历史 vs 当代
### 冷战时期的「战略模糊」与「台湾条款」
1960 年安保条约修订时,关于「远东」范围的界定引发日本国内激烈辩论。当时岸信介政府解释「远东」范围时,实际上包含了台湾海峡,但刻意保持模糊。
1969 年尼克松与佐藤荣作发表的联合声明中,包含著名的「台湾条款」:
> 「首相认为维护台湾地区的和平与安全对日本安全是极重要的因素。总统表示,美国将维持该地区和平与安全的义务。」
这是美日同盟首次在联合声明中明示台湾对日本安全的重要性。
**转折点:1972 年中日建交**
随着尼克松访华与中日建交,「台湾条款」从官方声明中消失。此后长达近五十年间,美日联合声明不再明文提及台湾。这并非台湾不再重要,而是为了维护与北京的关系,涉台议题转入「只做不说」的暗处。
### 当代的「政治清晰」回归
2021 年是分水岭。拜登与菅义伟联合声明明确提及:
> 「我们强调台海和平稳定的重要性,鼓励和平解决两岸议题。」
这是自 1969 年以来,美日领导人联合声明首次明文提及台湾。此后,涉台表述成为美日联合声明的「标准配置」。
**分析含义**:从 1969 到 2021,美日同盟涉台表述呈现 U 型回归。但两次「明示」的结构性基础完全不同:1969 年是冷战围堵的产物,2021 年则是应对中国军力崛起的威慑需求。分析时不能将两者等同——当代的明示伴随着实质性的军事部署调整,而不仅仅是政治宣示。
详见 `narratives/japan_taiwan/taiwan_emergency_japan_emergency.md`。
---
## 「台湾海峡和平稳定」在美日联合声明的演变
### 措辞的微调与升级
分析美日联合声明涉台措辞,是观察同盟立场变化的精确指标:
| 时间 | 场合 | 涉台措辞 | 分析含义 |
|---|---|---|---|
| 1969 | 尼克松-佐藤声明 | 「维护台湾地区的和平与安全对日本安全是极重要的因素」 | 冷战围堵,明示台湾为防卫范围 |
| 1996 | 桥本-克林顿峰会 | 无直接涉台表述,但提及「亚太地区稳定」 | 台海飞弹危机后,以「区域稳定」间接涵盖 |
| 2005 | 2+2 声明 | 将台海列为「共同战略目标」之一 | 首次将台海纳入战略目标,但未提「和平稳定」 |
| 2021.03 | 2+2 声明 | 「强调台海和平稳定的重要性」 | 涉台表述回归,去除了 2005 年的「战略目标」强硬措辞,改采「和平稳定」框架 |
| 2021.04 | 拜登-菅义伟声明 | 「强调台海和平稳定的重要性,鼓励和平解决两岸议题」 | 领导人级别确认,加入「鼓励和平解决」 |
| 2022.05 | 拜登-岸田声明 | 「强烈反对任何单方面改变东海现状的企图」 | 虽未点名,但「东海」措辞常被解读为涵盖台海 |
| 2023.01 | 2+2 声明 | 「强调维护台海和平稳定的重要性,作为国际社会安全与繁荣不可或缺的要素」 | 将台海稳定从「双边关切」升级为「国际社会关切」 |
| 2024.04 | 拜登-岸田声明 | 「强调维持台海和平稳定作为全球繁荣不可或缺要素的重要性,呼吁和平解决两岸问题」 | 再度确认,并强调「全球繁荣」 |
**分析含义**:措辞演变呈现三个趋势:(1) 从「不提」到「必提」;(2) 从「区域关切」升级为「全球关切」;(3) 始终维持「和平稳定」框架,避免直接承诺防卫。这种「政治清晰(必须关注)+ 军事模糊(如何行动)」的组合,是同盟刻意维持的平衡。
---
## 驻日美军与自卫队在台海情境中的角色
### 驻日美军的战略功能
详见 `primitives/concepts/first_island_chain.md`。
驻日美军是台海军事威慑的实质基础,其核心基地在台海情境中具有不可替代的功能:
- **嘉手纳基地(冲绳)**:距台北约 600 公里,是西太平洋最大的空军基地。台海冲突时,将是制空权争夺的核心枢纽。
- **普天间基地(冲绳)**:陆战队航空站,负责快速部署与两栖作战支援。
- **岩国基地(本州)**:F-35B 战机部署地,是远征打击群的核心。
- **横须贺基地(神奈川)**:美海军第七舰队母港,航母战斗群的后勤与指挥中枢。
- **佐世保基地(长崎)**:两栖戒备群母港,负责离岛防卫与两栖投射。
**分析含义**:驻日美军的部署逻辑是「前沿部署、快速反应」。冲绳的地理 proximity(邻近性)决定了它在台海情境中的核心地位——但这也意味着冲绳在冲突中将成为首要打击目标。这种「高价值目标」的脆弱性,是同盟威慑与风险计算的关键变量。
### 自卫队的角色演变:从「盾」到「矛」的辅助
传统上,美日分工是「美攻日守」——美军负责打击,自卫队负责防御与后勤。但 2015 年新安保法通过后,自卫队的角色发生质变:
1. **「重要影响事态」**:台海冲突影响日本安全时,自卫队可向美军提供后勤支援(弹药、燃料等)。这突破了以往「周边事态」时不能提供弹药的限制。
2. **「存立危机事态」**:当与日本关系密切的他国遭到攻击,威胁日本存立时,自卫队可行使「集体自卫权」,有限度地使用武力防卫他国。这是日本涉台军事行动的最关键法律门槛。
详见 `entities/foreign_actors/japan_self_defense_forces_southwest.md`。
**分析含义**:自卫队从「盾」升级为「矛的辅助」,但法律门槛极高。「存立危机事态」的认定需满足「威胁日本存立」与「无其他手段」两个严苛条件,且需国会事前或事后批准。这意味着,即使台海发生冲突,日本能否迅速通过法律认定参与武装行动,仍存在巨大的国内政治变数。
---
## 美日台三方对话:智库层与官方层
### 二轨与一轨半的突破
美日台三方因「一个中国」政策限制,无法建立官方的三边安全机制。因此,对话主要在二轨(智库)与一轨半(退役官员与现职官员的非正式会晤)进行:
- **智库层(二轨)**:如 CSIS、JFSS、台海安全研析中心等机构的联合兵棋推演与研讨会。这些活动公开或半公开,为三方提供学术掩护下的政策对话。
- **一轨半对话**:由退休将领、前官员参与的闭门会议。此类会议常包含现职官员以「顾问」身份列席,实质讨论政策协调。
- **官方层(一轨)**:美日台三方目前无正式的三边安全对话机制。但美日、美台、日台的双边对话中,常以「交换对第三方的看法」为名,实质进行三边信息同步。
### 对话的限制与突破
- **限制**:日本受制于中日关系,对与台湾的官方安全对话极为谨慎。任何被北京视为「三边安全机制」的举动,都会引发强烈抗议。
- **突破**:近年来,日本政界与智库开始公开讨论「台海安全对话」的必要性。2021 年后,日本防卫省官员与台湾军方退役人员的接触频率明显增加。
详见 `narratives/japan_taiwan/us_japan_taiwan_triangle.md`。
**分析含义**:三方对话的「非正式性」既是限制也是保护。它让三方能在不触发外交危机的前提下进行实质沟通,但也导致信息传递的「损耗」与「误解风险」。分析时,不应将「二轨对话的共识」等同于「官方政策」,但应将其视为「政策可能性的探针」。
---
## 美日同盟的「涉台限制」:宪法、政治与外交考量
### 宪法第九条的实质约束
日本和平宪法第九条是美日同盟涉台行动的最大内部限制:
- **放弃战争权**:第九条规定日本放弃以战争作为解决国际争端的手段。
- **集体自卫权的限制**:2014 年解禁集体自卫权时,设定了「武力行使三条件」,其中「威胁日本存立」的认定标准极高。
- **最小必要武力**:即使行使集体自卫权,武力使用也限于「最小必要限度」。
**分析含义**:宪法第九条不仅是法律限制,更是日本国内政治的「心理锚点」。任何试图将自卫队行动范围扩大到台海的举动,都会在国内引发激烈辩论。这种国内政治的「否决点」,是同盟威慑可信度的最大变量。
### 政治与外交考量
- **经济依存**:日本与中国有巨额贸易投资。台海冲突导致的经济制裁与供应链中断,对日本经济的冲击远大于对美国的冲击。
- **国内民意**:虽然日本民众对台海和平的关注度上升,但支持「为台湾出兵」的比例仍低。民意制约了政治人物的决策空间。
- **中日关系**:日本在台海议题上的表态,必须考虑对中日关系全局的冲击。过度表态可能引发北京在其他议题(如东海、半岛)上的反制。
**分析含义**:美日同盟的涉台限制,本质上是「同盟承诺」与「国家利益计算」之间的张力。美国希望日本承担更多前沿风险,日本则希望将风险控制在「不直接卷入战争」的范围内。这种利益不完全一致,是同盟内部的结构性矛盾。
---
## 对北京的「同盟威慑」效果
### 威慑的逻辑
美日同盟涉台化对北京的威慑逻辑是:
> 通过展示「台海冲突将同时面对美日两国军事力量」,提高北京动武的成本与风险,从而维持台海现状。
这种威慑包含三个层次:
1. **政治威慑**:联合声明涉台,向北京传递「台海不是中台双边议题」的信号。
2. **军事威慑**:驻日美军与自卫队的部署与演训,展示「快速干预」的能力。
3. **经济威慑**:暗示台海冲突将触发美日联合经济制裁。
### 威慑的局限与反效果
然而,同盟威慑也存在局限与潜在反效果:
- **可信度问题**:北京可能质疑美日同盟的「决心」。特别是日本国内的法律与政治限制,让北京认为「日本不会真正出兵」。
- **安全困境**:同盟威慑越强,北京越感到被围堵,可能加速其军事准备,反而增加冲突风险。
- **刺激反制**:美日联合声明涉台,常引发解放军在台海与东海的针对性军演,形成「声明-军演」的螺旋升级。
详见 `entities/foreign_actors/quad_and_taiwan.md`。
**分析含义**:同盟威慑是一把双刃剑。它在短期内可能降低北京动武的意愿,但在长期可能加剧北京「必须尽早解决台湾问题」的紧迫感。分析时,不能只看威慑的「意图」,更要看威慑在接收端(北京)产生的「实际认知」。
---
## 未来可能的演变方向
### 短期(2028 前)
- **联合声明涉台措辞持续强化**:从「和平稳定」向「反对单方面改变现状」甚至「反对武力胁迫」升级。
- **指挥体系整合**:驻日美军与自卫队的指挥体系进一步整合,提升危机时的决策效率。
- **西南诸岛防卫强化**:自卫队在西南诸岛的部署持续增加,提升对台海的监控与快速反应能力。
### 中长期(2028 后)
- **法律框架调整**:日本可能进一步修改安保法制,降低「存立危机事态」的认定门槛,或明确将台海冲突纳入「重要影响事态」的适用范围。
- **三方对话机制化**:美日台可能建立更常态化的安全对话机制,即使仍以「非官方」形式。
- **经济安全与供应链韧性**:同盟合作从军事领域扩展到经济安全,建立台海冲突时的供应链应急机制。
**分析含义**:美日同盟涉台化的趋势是明确的,但速度与程度取决于三个变量:(1) 北京的军事压力;(2) 美国对同盟的投入程度;(3) 日本国内政治的演变。分析时,应关注这三个变量的交互作用,而非单一预测。
---
## 关联分析
### 关联一:与「台湾有事即日本有事」论述的关系
美日同盟是此论述的制度基础。没有美日同盟,此论述只是政治口号;有美日同盟,此论述才具有实质的战略意涵。
- **功能**:提供脉络层。论述的政治信号,最终需要通过同盟的军事机制来落实。
详见 `narratives/japan_taiwan/taiwan_emergency_japan_emergency.md`。
### 关联二:与美日台三角关系的关系
美日同盟是三角关系的「底边」。美日关系的强度决定了三角关系的稳定性。
- **功能**:提供结构框架。分析三角关系时,必须先确认美日同盟的状态。
详见 `narratives/japan_taiwan/us_japan_taiwan_triangle.md`。
### 关联三:与自卫队西南部署的关系
自卫队在西南诸岛的部署,是美日同盟涉台军事准备的「前沿体现」。
- **功能**:提供事实层与执行层。同盟的政治决策,通过自卫队的部署来落实。
详见 `entities/foreign_actors/japan_self_defense_forces_southwest.md`。
### 关联四:与第一岛链战略架构的关系
美日同盟是第一岛链防卫的「核心骨架」。没有美日同盟,第一岛链的防卫概念无法成立。
- **功能**:提供战略脉络。同盟的军事部署,必须放在第一岛链的地理与战略架构中理解。
详见 `primitives/concepts/first_island_chain.md`。
### 关联五:与 QUAD 及台湾的关系
QUAD 是美日同盟的「扩展架构」。美日同盟是 QUAD 的核心,QUAD 则为同盟提供了更广泛的多边支持网络。
- **功能**:用于对照。分析同盟在多边框架中的角色。
详见 `entities/foreign_actors/quad_and_taiwan.md`。
### 关联六:与美国对台政策的关系
美日同盟涉台化,在很大程度上是跟随美国对台政策的变化。美国对台承诺的强度,直接影响日本对台表态的力度。
- **功能**:提供因果解释。日本的对台立场,部分是内生需求,部分是对美国政策的响应。
---
## 常见的分析陷阱
### 陷阱一:把联合声明等同于作战协议
美日联合声明涉台是政治信号,不代表双方已就台海冲突时的具体军事分工达成协议。作战规划涉及复杂的法律授权与指挥协调,远比声明复杂。
### 陷阱二:忽视日本国内政治的否决点
日本宪法第九条与国内民意,是同盟涉台行动的硬约束。分析时不能只看美日政府的意图,必须考虑日本国内政治的可行性。
### 陷阱三:假设美日利益完全一致
美国希望日本承担更多前沿风险,日本则希望将风险控制在最小范围。这种利益差异在危机时可能放大,导致同盟协调失灵。
### 陷阱四:低估「事前协议」的制约
美军从日本基地出击执行台海任务,理论上需与日本事前协商。日本若拒绝,将严重制约美军的行动能力。这是同盟军事操作中的关键摩擦点。
### 陷阱五:将「威慑」效果视为单向
同盟威慑可能降低北京动武的意愿,但也可能增加北京的紧迫感,加速其军事准备。威慑的净效果是不确定的,取决于北京的认知与计算。
### 陷阱六:忽视冲绳的反对声音
驻日美军基地集中在冲绳,当地民众对卷入台海冲突有强烈抵触。冲绳的政治态度,是同盟军事部署的潜在制约因素。
---
## 仍不确定的部分
- **「存立危机事态」的认定标准**:何种程度的台海冲突会被日本认定为「威胁日本存立」,目前仍无明确标准,这是危机时的最大法律变数。
- **美日作战规划的具体内容**:双方是否已就台海冲突时的具体分工达成共识,外界无法确知。
- **日本在「灰色地带」的行动选择**:台海若发生未达战争门槛的「灰色地带」冲突,日本将如何响应,仍不明确。
- **北京对同盟威慑的真实认知**:北京是将美日涉台化视为「不可逾越的障碍」,还是「必须尽快解决的挑战」,外界难以判断。
- **美国政策变化的影响**:若美国政府对同盟承诺减弱,日本将如何调整对台立场,存在高度不确定性。
- **冲绳因素在危机时的实际影响**:冲绳地方政府的反对,在危机时能在多大程度上制约中央政府的决策,未经测试。
---
## 与其他文件的关系
| 主题 | 关联文件 | 关联功能 |
|---|---|---|
| 「台湾有事即日本有事」论述 | `narratives/japan_taiwan/taiwan_emergency_japan_emergency.md` | 提供论述层,同盟是论述的制度基础 |
| 美日台三角关系 | `narratives/japan_taiwan/us_japan_taiwan_triangle.md` | 提供结构框架,同盟是三角的底边 |
| 自卫队西南部署 | `entities/foreign_actors/japan_self_defense_forces_southwest.md` | 提供执行层,同盟决策的军事落实 |
| 第一岛链战略 | `primitives/concepts/first_island_chain.md` | 提供战略脉络,同盟部署的地理架构 |
| QUAD 与台湾 | `entities/foreign_actors/quad_and_taiwan.md` | 提供扩展架构,同盟的多边延伸 |
| 战略模糊概念 | `primitives/concepts/strategic_ambiguity.md` | 提供概念层,同盟涉台表述的分析框架 |
| 美方对台承诺 | `narratives/us_taiwan/us_commitment_evidence.md` | 提供对照,美方承诺与同盟承诺的互动 |
---
## 一句话浓缩
> 美日同盟是台海安全架构的轴心——它从冷战的「战略模糊」走向当代的「政治清晰」,但在军事执行与法律授权上仍维持「刻意模糊」;理解同盟涉台角色的关键,不在于联合声明说了什么,而在于「政治表态」与「作战规划」之间的落差,以及日本国内政治对同盟行动的实质制约。
---
last_updated: 2025-05-19
@@ -0,0 +1,378 @@
# 反舰飞弹(雄风、鱼叉)
---
**类型**: 政治叙事
**当前角色**: 台湾拒止登陆的核心战力 / 豪猪战略的实体「刺」 / 国造与美制军事体系的竞合场
**出生时间**: 1980年代末(雄风二型)/ 2000年代初(雄风三型)/ 2000年代末(雄风2E)/ 2020(鱼叉大宗军购)
**何时调用此档案**: 分析台湾反登陆/拒止作战结构;评估国造国防工业与美制武器的竞合;解读「不对称作战」的具体武力配置;观察海陆军军种政治与资源分配;判断两岸军力平衡中「拒止」与「纵深打击」的红线位移;预测战时指管通情存续与机动部署效力
---
## 一句话浓缩
反舰飞弹是台湾从「制海权争夺」向「拒止海域控制」转型的**物质锚点**,也是豪猪战略最锋利的「刺」——它既是国造自主(雄风)与美系体系(鱼叉)妥协并存的**双轨缩影**,更是「防御性拒止」与「进攻性报复」(雄风2E)界线模糊的**政治争议场**。
---
## 事实层:规格、进程与结构
### 雄风系列的核心规格与战略意涵
台湾的反舰飞弹体系以中科院研发的「雄风」系列为主干。理解其规格,关键在于掌握**哪些技术特性改变了台海战术环境**:
**关键规格与政治/战略意涵**:
| 型号 | 事实内容(射程/速度/导引) | 政治与战略意涵 |
|---|---|---|
| 雄风二型 (HF-2) | 次音速(约0.85马赫);射程约100-150公里;红外线+主动雷达归向 | 台湾首款完全自研的反舰飞弹,确立了「以弹制舰」的不对称路线基础。次音速虽易被拦截,但具备较好的掠海飞行与多轴向饱和攻击弹性,是反登陆消耗战的主力。 |
| 雄风三型 (HF-3) | 超音速(约2.0-3.0马赫);射程约150-200公里(增程型传闻达400公里);主动雷达归向 | 全球少数服役的超音速反舰飞弹。其核心价值不在射程,而在「压缩反应时间」——迫使敌方神盾舰的防空系统在极短时间内应对,增加饱和攻击穿透率。 |
| 雄风2E (HF-2E) | 次音速巡弋飞弹;射程传闻600公里以上(增程型传闻达1000公里);GPS/地形匹配/影像归向 | 名义上常与反舰飞弹并列,实质为**攻陆巡弋飞弹**。跨越了「防卫性武器」的模糊界线,赋予台湾对大陆沿岸与内陆军事目标的纵深打击与报复性威慑能力,是北京最敏感的红线之一。 |
**分析含义**:雄风系列的演进,反映了台湾国防思维从「近海防御」向「纵深威慑」的暗流涌动。雄二反船团,雄三反高价值舰(如航母、两栖突击舰),雄风2E则是对敌源头的威慑。这三者构成了一个从战术拒止到战略威慑的光谱,但政治风险也随射程增加而指数级上升。
### 鱼叉飞弹的引入与2020年大宗军购
**事实内容**:
- 时间:2020年10月,川普政府宣布售台100套岸置鱼叉飞弹系统(RGM-84L-4 Harpoon Block II),含400枚飞弹与相关设备。
- 总价:约23.7亿美元。
- 特性:次音速(0.85马赫),射程约200公里以上(Block II增程型),具备攻陆(对岸上目标)与反舰双功能。
- 部署:全数编配于陆军,成立「防卫飞弹指挥部」,采用机动发射车部署。
- 交付时程:原订2024年起交付,预计2028年前后完成,实际进度受美方产能排挤影响。
**脉络层**:
在台湾已拥有大量雄风飞弹且持续推进增程型之际,编列巨资采购美制鱼叉,绝非单纯的「数量补充」。这是美台军事政治互动的典型产物:
1. **产能缺口填补**:中科院产能有限,无法在短期内满足「海量飞弹」的不对称需求。
2. **指管体系的接轨**:鱼叉飞弹的采购不仅是买弹药,更是买美军的指管通情(C2)接口与数据链路。这隐含了战时与美军情报共享甚至联合作战的潜力。
3. **政治保证的预付**:在川普时期,大宗军购是台美关系升温的实质凭证。采购美制武器等于向美方缴纳「安全俱乐部」的会费,确保美方持续关注台海。
**分析如何用此事实**:
1. 鱼叉入台,打破了海军海锋大队对岸置反舰飞弹的垄断,将反舰/攻陆能力大规模引入陆军。这不只是战术部署,是**军种政治的重新洗牌**。
2. 雄风与鱼叉并存,形成了「国造+美制」的双轨后勤体系。分析战时持续作战能力时,必须计算两种完全不同的零件供应链与维修体系,这是巨大的后勤负担。
3. 鱼叉Block II的攻陆能力,与雄风2E形成了某种重叠与竞争,暗示了美方对台湾「纵深打击」能力的默许甚至引导。
### 海军 vs 陆军使用的分工
**事实内容**:
- **海军海锋大队**:负责操作雄风二型、三型飞弹。历史悠久,基地多位于本岛西岸与东引、澎湖等离岛。编制内包含固定阵地与机动发射车混合。
- **陆军防卫飞弹指挥部**:负责操作2020年采购的鱼叉飞弹系统。全机动部署,编制于陆军司令部下。
**脉络层**:
表面上是武器来源不同导致的军种分工,深层是**「制海权」与「陆地防卫」思维的战略分歧**。海军将反舰飞弹视为舰队作战的延伸(以岸制海),陆军则将其视为反登陆作战的火力屏障(岸上拒止)。
**分析如何用此事实**:
1. 海军的固定阵地思维(海锋大队仍有高比例固定阵地)与陆军的机动生存思维(鱼叉全机动)形成对比。战时,海军的固定阵地极易在第一波打击中被摧毁,而陆军的机动部队生存率较高,但指管通情整合难度更大。
2. 陆军掌握鱼叉,意味着在防卫作战中,陆军拥有了独立于海军的远距打击火力。这在军种预算争取与战时指挥权分配上,埋下了潜在的博弈空间。
### 机动发射 vs 固定阵地的战术意义
**事实内容**:
- 固定阵地:拥有预设的指管线路、雷达基座与弹药库,反应速度快,但位置已知,战时生存率极低。
- 机动发射:飞弹装载于卡车,平时隐蔽于掩体,战时机动至预设或临时阵地发射,生存率高,但依赖无线数据链与机动指管车,易受电子战干扰。
**脉络层**:
海锋大队的固定阵地是冷战后期的产物(当时假设有预警时间且打击精度有限),在今日解放军长程精准火力下已成为「脆弱的资产」。近年海锋大队虽积极增编机动中队,但固定阵地仍是主架构。相对地,陆军鱼叉强调「全机动」,反映了美方对台军「战力保存」的强烈要求。
**分析如何用此事实**:
1. 评估台湾反舰飞弹的存活率,不能只算总数量,必须区分「固定」与「机动」。第一波打击后,存活的机动飞弹数量才是真实的威慑力。
2. 机动飞弹的效力取决于**指管通情(C4ISR)的存活**。飞弹车可以躲藏,但提供目标数据的雷达与指挥中心若被摧毁,飞弹将变成盲目武器。这是「机动生存」叙事中最脆弱的一环。
---
## 叙事层:反舰飞弹的多重政治诠释
同一枚反舰飞弹,在不同政治行动者的叙事中承载着截然不同的战略意图。
### 叙事一:「拒止登陆」的生存底线
**核心命题**:反舰飞弹是台湾防卫的绝对核心,没有大量反舰飞弹,就没有拒止解放军跨海登陆的能力。
**论述逻辑**:
- 解放军攻台的关键瓶颈在于跨海输运与两栖登陆
- 登陆船团在海上最为脆弱,是反舰飞弹的最佳猎物
- 只要能击沉或重创足够数量的登陆舰与运输船,攻台行动即告失败
- 因此,反舰飞弹是豪猪战略中最致命的「刺」,是纯粹的防御性武器
**支持者**:台湾国防部、美方不对称战略倡导者、多数本土防卫论者
**反对者视角**:此叙事假设了解放军会采取大规模传统两栖登陆(诺曼第模式),但现代战争可能更多是封锁、灰色地带行动或特种作战,反舰飞弹在这些场景中几乎无用武之地。
### 叙事二:「国造自主」的骄傲与限制
**核心命题**:雄风系列是台湾国防自主的图腾,证明台湾有能力研发世界级武器,不应被美制武器取代。
**论述逻辑**:
- 雄风三型是全球少数服役的超音速反舰飞弹,技术自主
- 依赖美制武器(鱼叉)将受制于美方政治考量与交货期程
- 国造武器可自主量产,战时供应链不受制于人
- 采购鱼叉排挤了国造雄风的产能与预算
**支持者**:中科院、台湾本土国防工业利益相关者、部分蓝营政治人物(强调国造延续性)
**反对者视角**:国造武器的性能与可靠性缺乏实战验证,且量产速度与成本效益未必优于成熟的美制武器。过度强调国造,可能导致战力空窗期或后勤孤岛。详见 `narratives/domestic/indigenous_defense_industry.md`。
### 叙事三:「纵深打击」的红线争议
**核心命题**:雄风2E及其增程型已跨越防卫需求,具备对大陆沿岸甚至内陆的攻击能力,是挑衅且破坏稳定的武器。
**论述逻辑**:
- 防卫性武器不应具备长程攻陆能力
- 雄风2E使台湾具备「先制打击」或「报复性打击」的潜力,鼓励台独冒险主义
- 此举将迫使北京采取更强硬的先制措施,升高战争风险
- 美方出售/默许此类武器,是违反战略模糊的挑衅行为
**支持者**:北京官方、部分国际反战/反军扩团体
**反对者视角**:此叙事将「报复性威慑」等同于「挑衅」。在台湾缺乏战略纵深的现实下,能威胁敌方出发地与指管中心,是提升敌方攻台成本、达成「吓阻」的必要手段。没有报复能力,仅守不攻只会被逐步蚕食。
### 叙事四:「数量不足」的脆弱性
**核心命题**:尽管采购了鱼叉并量产雄风,台湾的反舰飞弹数量仍远不足以应对现代饱和攻击与消耗战。
**论述逻辑**:
- 解放军海军舰艇数量庞大,且拥有完善的近防系统(CIWS、海红旗等)
- 现代反舰飞弹的单发命中率并非百分之百,突防需要多枚齐射(饱和攻击)
- 战时飞弹消耗极快,台湾现有库存(估计千余枚)在数日内即可能耗尽
- 中科院产能与美方交货速度,无法支撑长期消耗战
**支持者**:美方部分悲观派智库、台湾内部务实派军事评论者
**反对者视角**:数量迷思忽略了「击沉关键节点」的杠杆效应。不需击沉所有舰艇,只需重创几艘大型两栖突击舰或运输舰,即可瘫痪整个登陆船团的节奏与能力。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:「机动生存」与「指管通情」的悖论
反舰飞弹的效力高度依赖「杀伤链」(Kill Chain)的完整:发现目标 → 识别跟踪 → 发射飞弹 → 中途更新 → 命中评估。
**内在矛盾**:
- 机动发射车为了生存,必须经常变换位置、保持无线电静默
- 但飞弹要命中远距离目标,又高度依赖固定的大型雷达站、预警机或卫星数据提供目标诸元
- 当固定雷达站被摧毁、预警机无法升空时,机动飞弹车将沦为「有弹无眼」的孤立平台
- 为了解决此问题,需依赖美方的数据链(如Link-16)与卫星情报,但这又加深了对美依赖,且战时美方是否愿意实时共享高敏感目标数据仍是未知数
**分析如何用此矛盾**:
评估台湾反舰飞弹战力时,**绝不能只算飞弹数量,必须算「指管节点」的数量与存活率**。解放军的打击顺序必然是「先瞎再打」,摧毁雷达与指挥车优先于摧毁飞弹车。飞弹车的机动性,掩盖了指管体系的脆弱性。
### 矛盾二:国造雄风 vs 美制鱼叉的体系冲突与政治妥协
台湾同时操作两套性能相似但体系完全不同的反舰飞弹,这在军事后勤上是灾难,但在政治上是必然。
**内在矛盾**:
- 买鱼叉是为了补数量与接美体系;造雄风是为了保自主与国防工业
- 两者并存导致后勤双轨:零件不互通、维修体系不同、操作介面迥异
- 在预算有限下,采购鱼叉必然排挤雄风的量产经费,引发内部零和博弈
- 但若不买鱼叉,不仅数量不足,更失去与美方指管体系接轨的机会,也失去向美方展示防卫决心的政治筹码
**结构性命题**:
**反舰飞弹的「双轨体系」,是台湾在「主权自主防卫」与「依赖盟邦体系」之间摇摆的结构性投射。** 台湾既无法完全依赖国造(产能与技术瓶颈),也不敢完全依赖美制(政治与断供风险),最终只能两者并陈,以双倍的后勤代价换取双重的政治保险。
### 矛盾三:反舰(防御) vs 攻陆(威慑)的界线模糊
**内在矛盾**:
- 雄风三型增程型若具备对地攻击能力,鱼叉Block II本身就是攻陆飞弹,雄风2E更是长程攻陆巡弋飞弹
- 台湾官方论述始终维持「防卫性武器」的基调,但武器实体已具备「攻势作为」的潜力
- 这种模糊是刻意为之:对内安抚反战声音,对外(对北京)保持战略模糊,对内(对军方)提供「复仇」的战术选项
- 然而,一旦在战时使用攻陆能力,将跨越北京的红线,可能招致更无节制的报复性打击甚至核威慑
**分析如何用此矛盾**:
台湾的反舰飞弹体系正在悄悄进行「角色蜕变」——从纯粹的「盾」(拒止登陆),转变为兼具「矛」的功能(报复威慑)。分析者必须区分「能力具备」与「意图使用」。北京关注的是前者,因为这决定了其先制打击的范围必须扩及所有飞弹阵地;台北考量的是后者,何时跨越红线使用攻陆能力,将是战时最艰难的政治决定。
---
## 对比分析:台湾反舰飞弹 vs 解放军登陆船团/舰艇
分析反舰飞弹的威慑力,必须置于两岸军力对比与战术场景中。
### 对比一:雄风三型 vs 大型水面舰(航母、两栖突击舰)
| 维度 | 雄风三型 (超音速) | 解放军大型舰防空体系 (神盾) | 分析含义 |
|---|---|---|---|
| 速度 | 2-3马赫,缩短反应时间 | 近防系统(海红旗-10/1130近防炮)反应时间极短 | 超音速压缩了防御方时间,但非不可拦截。单发突防概率低,需多枚饱和攻击。 |
| 弹道 | 高空弹道(超音速冲压需高空加速) | 神盾雷达对高空目标探测距离远 | 雄三高空飞行段易被远距发现,是其战术弱点。掠海段虽隐蔽,但时间短。 |
| 目标 | 高价值舰艇 | 伴随护航的驱逐舰/护卫舰 | 雄三需穿透护航圈才能攻击航母/两栖舰,难度极高。 |
**破除常见简化**:
**把「雄风三型」等同于「航母杀手」是媒体与政治人物的简化迷思。** 超音速反舰飞弹对缺乏完整防空体系的舰艇极具威胁,但面对拥有完整神盾体系的航母战斗群,单凭雄三难以突破。雄三的真实战略价值在于「迫使敌方集结更多护航资源」,从而降低登陆船团的输运效率,而非「单发击沉航母」。
### 对比二:雄风二型/鱼叉 vs 登陆船团(民用滚装船、登陆舰)
| 维度 | 雄二/鱼叉 (次音速) | 解放军登陆船团 | 分析含义 |
|---|---|---|---|
| 速度 | 0.85马赫,易被拦截 | 船团防空能力较弱(依赖护航舰) | 次音速飞弹对护航舰突防难,但对防空薄弱的登陆船团致命。 |
| 数量 | 台湾库存数百至上千枚 | 登陆舰/滚装船数百艘 | 典型的「弹海对船海」消耗战。飞弹数量是否足以造成「不可承受的损失」是关键。 |
| 弹头 | 高爆半穿甲,对大型民船毁伤力足够 | 民船损管能力差 | 击沉一艘满载的滚装船,对登陆计划的打击远大于击伤一艘驱逐舰。 |
**分析如何用此对比**:
1. 台湾反舰飞弹的战术目标应有主次之分:超音速飞弹(雄三)用于牵制/威慑护航舰,次音速飞弹(雄二/鱼叉)集中火力于登陆船团与后勤输运线。
2. 威慑力的计算不在于「能否全歼敌军」,而在于「能否在敌方建立滩头堡前,击沉足够数量的输运船,使其后续兵力与重装备无法跟进」。这是豪猪战略的核心算计。
---
## 豪猪战略辩论中的反舰飞弹
详见 `narratives/us_taiwan/porcupine_strategy.md`。
### 反舰飞弹作为「完美豪猪武器」的典型
在豪猪战略的光谱上,岸置反舰飞弹几乎完美契合其原则:小型(相对战机舰艇)、致命(击沉船舰)、机动(发射车)、即用(无需复杂平台支持)、防御导向(拒止登陆)。
**豪猪纯粹主义者的期盼**:
- 应将海空军大型平台的预算转移至反舰/防空飞弹的「海量」采购
- 全岛要塞化、飞弹化,让台湾海峡成为「无人舰坟场」
- 放弃制海制空,专注反舰反登陆
**传统防卫者的反驳**:
- 没有制空权,机动飞弹车在空优战机与无人机面前无所遁形
- 没有制海权,敌方可在飞弹射程外实施封锁
- 反舰飞弹是防卫的一环,但不能是唯一的一环
### 反舰飞弹在辩论中的「实证位置」
反舰飞弹是少数「豪猪纯粹主义者」与「传统防卫者」都能接受的武器。这使得它在国防预算的博弈中,往往成为「最大公约数」。然而,真正的分歧在于**数量级**:
- 买多少才够?千枚?万枚?
- 产能与预算如何支撑?
- 是否为了反舰飞弹而牺牲F-16V或潜舰?
**分析含义**:
反舰飞弹在台湾国防辩论中,是一个**「共识的表象,分歧的深层」**。所有人同意它重要,但对它应占用的资源比例、它依赖的指管体系由谁提供、它是否应具备攻陆能力,却存在深刻分歧。分析时,不能被「支持反舰飞弹」的表面共识所蒙蔽,必须深挖背后的资源排挤与战略路线之争。
---
## 未来叙事:更长射程、更多数量与更深依赖
### 发展趋势的多重可能
关于台湾反舰飞弹体系的未来,存在三种叙事与演变方向:
**叙事一:「海量威慑」论**
- 主张:大幅增加雄风与鱼叉的产量,目标向数千枚甚至万枚迈进
- 逻辑:数量本身就是质量。面对解放军的舰海与近防系统,只有饱和攻击才能穿透
- 障碍:预算限制、中科院产能瓶颈、美方交货时程、储存空间与维护成本
**叙事二:「纵深报复」论**
- 主张:加速部署雄风2E增程型与雄三攻陆型,将威慑范围延伸至大陆内陆
- 逻辑:最好的防御是攻击敌方出发地。让北京计算攻击台湾的代价时,必须纳入其沿海基础设施受损的风险
- 障碍:极高政治红线,可能触发北京先制打击;美方可能限制关键技术(如导引芯片)输出
**叙事三:「无人载台整合」论**
- 主张:将反舰飞弹整合至无人艇(USV)与无人机(UAV)上,实现更分散、更前推的部署
- 逻辑:弥补岸置飞弹射程不足,将打击线推向海峡中线甚至更远
- 障碍:无人载台指管易受干扰,技术成熟度尚需验证
### 未来发展的分析含义
1. **雄风2E是台海稳定的「隐形破口」**:每当中美关系紧张或台湾内部政治需要展现「自主防卫决心」时,雄风2E的试射或量产消息就会被释放。这是台湾对内的「强心针」,对北京的「挑衅针」,对美方的「谈判筹码」。分析2028选举,候选人是否在政见中隐晦提及「纵深打击能力」,将是测试其国防路线激进程度的指标。
2. **数量焦虑与战时储备的矛盾**:和平时期的采购数量,基于「打几天仗」的想定。若想定是「撑到美军介入(2-4周)」,则需极高库存;若想定是「独立防卫至最后」,库存永远不够。这种想定的模糊,使得反舰飞弹的采购数量永远处于「不够」的焦虑中。
3. **对美指管依赖的不可逆**:鱼叉的引入,加上潜在的美方数据链整合,意味着台湾的反舰飞弹体系正从「国造孤岛」走向「美系网络」。这在战时可能是保命符(获得美军目标数据),也可能是缰绳(若美方限制数据共享,飞弹效力大减)。
---
## 关联分析
### 关联一:与豪猪战略核心的结构性连结
详见 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:提供脉络层与用于对照
- 反舰飞弹是豪猪战略在台湾落地的最典型案例。理解反舰飞弹的部署逻辑(机动、分散、致命),即是理解豪猪战略的物质化过程。
- 交叉验证:若出现「台湾增加飞弹采购但减少大型平台预算」的新闻,应回溯检视豪猪战略在台美军事互动中的推进程度。
### 关联二:与国造国防工业的命运连结
详见 `narratives/domestic/indigenous_defense_industry.md`。
- 功能:提供事实层与结构性命题
- 雄风系列是国造国防工业的旗舰产品。鱼叉的采购是否排挤了雄风的量产,是观察台湾「国防自主」口号与「务实建军」现实之间落差的温度计。
- 结构性命题:雄风与鱼叉的双轨,是国造工业在美制体系阴影下求存的缩影。
### 关联三:与海军组织文化的张力连结
详见 `entities/organizations/roc_navy.md`。
- 功能:提供脉络层
- 海军长期将反舰飞弹视为「舰队之眼与拳」的延伸(海锋大队)。陆军接手鱼叉,打破了海军对岸置反舰火力的垄断。
- 分析时,应观察海锋大队是否因资源排挤而缩减,或因不对称战略而扩编,这将反映海军在豪猪战略下的角色转型。
### 关联四:与陆军角色转型的因果连结
详见 `entities/organizations/roc_army.md`。
- 功能:提供结构性命题
- 陆军传统上是「守滩反登陆」的消耗战角色,缺乏远距打击火力。鱼叉的编配,使陆军拥有了「岸置反舰/攻陆」的战略性打击能力。
- 这改变了陆军在国防体系中的边缘化趋势,使其从「纯消耗性步兵」转向「高价值火力提供者」。
### 关联五:与美对台军售动态的连结
- 功能:提供事实层与交叉验证
- 2020年鱼叉军购案是川普时期大宗军售的标志性事件。此案的时间点(大选前夕)、品项(全机动岸置型)、数量(400枚)皆反映了美方对台防卫策略的明确化。
- 详见 `narratives/us_taiwan/arms_sales_dynamics.md`(若存在)。
### 关联六:与北京反应类型学的互动
- 功能:提供脉络层
- 对雄风2E的试射与部署,北京的反应通常比其他军购更为激烈,直接定性为「挑衅」与「分裂行径」。
- 这显示北京对「防卫性反舰」与「攻陆纵深打击」有明确的红线区分。分析北京反应时,必须区分飞弹型号,而非一概而论。
- 详见 `primitives/frameworks/beijing_response_typology.md`(占位)。
---
## 常见的分析陷阱
### 陷阱一:将「雄风三型」等同于「航母杀手」
如前所述,这是媒体与政治叙事的简化。超音速反舰飞弹对航母战斗群的突防概率受限于高空弹道与神盾体系。将其神化为「航母杀手」,会高估台湾的拒止能力,导致战略误判。
### 陷阱二:忽略指管通情(C4ISR)的决定性作用
算飞弹数量很容易,算指管节点存活率很难。机动飞弹车若无雷达导引与数据链更新,只是昂贵的废铁。分析时若不考虑指管体系的脆弱性,会严重高估反舰飞弹的战时效力。
### 陷阱三:在「国造雄风」与「美制鱼叉」的优劣中选边
两者各有利弊:雄风自主但产能受限、指管孤岛;鱼叉成熟且接美体系但受制于人、后勤双轨。将其视为零和竞争是错的,它们是台湾在双重风险下对冲的产物。分析者应呈现其互补与冲突,而非判定谁应取代谁。
### 陷阱四:低估后勤双轨的长期代价
同时维持两套完全不同的飞弹体系,零件、维修、训练、弹药的边际成本极高。和平时期或许可支撑,战时在供应链受封锁的情况下,双轨可能同时面临断链风险。
### 陷阱五:将「攻陆能力」视为纯军事选项
雄风2E与鱼叉Block II的攻陆能力,在战时是否使用,绝非军事将领决定,而是最高政治决策。使用攻陆飞弹打击大陆目标,将跨越北京红线,可能引发无限制升级。分析时,不能只算「能不能打到」,必须算「敢不敢用」与「用了后果为何」。
### 陷阱六:假设飞弹库存足以支撑消耗战
现代海战飞弹消耗极快。以现有库存,台湾的反舰飞弹可能在数日至数周内耗尽。若美方未能及时补给(受限于海空封锁),飞弹海将迅速退潮。以静态库存推算战局,会忽略战时的动态消耗与补给困难。
---
## 仍不确定的部分
- **雄风2E的真实数量与部署状态**:受限于高度机密,外界对雄风2E的量产数量、射程极限(增程型是否达1000公里)与实际部署位置,仅有模糊推测,这直接影响对北京红线与威慑平衡的评估。
- **战时指管链路的存续率**:在解放军首波针对雷达站、指挥中心与通信节点的打击下,台湾的反舰飞弹杀伤链能维持多少完整度,目前仅有兵棋推演假设,缺乏实战验证。
- **美方数据链的战时共享意愿**:鱼叉飞弹若要发挥最大效力,需依赖美方的卫星与预警机数据。战时美方是否愿意实时提供高敏感目标数据(此举可能被视为参战),仍是最大的不确定变量。
- **解放军电子战对飞弹导引的干扰效力**:现代反舰飞弹的主动雷达归向极易受电子战干扰。解放军的电子战能力在实战中能否使大量飞弹「致盲」,目前缺乏实证。
- **2028后鱼叉交付的实质进度**:受美方军工产能排挤与政治局势影响,400枚鱼叉能否如期交付,将直接影响台湾反舰火力的数量基础。
---
## 与其他文件的关系
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md`(提供反舰飞弹所在的战略辩论框架与不对称论述)
- 国造国防工业:`narratives/domestic/indigenous_defense_industry.md`(提供雄风系列作为国造象征的工业与政治脉络)
- 台湾海军:`entities/organizations/roc_navy.md`(提供海锋大队的组织文化与军种利益脉络)
- 台湾陆军:`entities/organizations/roc_army.md`(提供陆军接手鱼叉后的角色转型与军种重组脉络)
- 北京反应类型学:`primitives/frameworks/beijing_response_typology.md`(提供预测北京对雄风2E等纵深打击武器反应的分析框架)
- 疑美论:`narratives/cross_strait/skepticism_of_us.md`(提供鱼叉交付延迟或技术限制被诠释为疑美论证据的脉络)
---
## 一句话浓缩
> 反舰飞弹是台湾从「制海」向「拒止」转型的物质锚点,也是豪猪战略最锋利的「刺」——它既是国造(雄风)与美制(鱼叉)妥协并存的**双轨缩影**,更是「防御性拒止」与「进攻性报复」界线模糊的**政治争议场**。
> 分析反舰飞弹,不是算它能击沉几艘舰艇,而是看它如何**重塑军种利益、锚定对美依赖、试探两岸红线**。
---
last_updated: 2025-05-19
@@ -0,0 +1,440 @@
# 台灣無人機發展敘事
---
**類型**: 政治敘事
**當前角色**: 不對稱作戰的核心載體 / 國防自主產業的試驗場 / 美台軍售與國造競合的焦點 / 烏克蘭經驗移植的投影幕
**出生時間**: 2017(無人機國家隊成立)/ 2022(烏克蘭戰爭催化與戰略地位躍升)
**何時調用此檔案**: 分析台灣不對稱防衛戰力建構與豪豬戰略的物質基礎;評估國防自主產業政策中的「民轉軍」效益與瓶頸;解讀烏克蘭戰爭經驗在台海防衛敘事中的移植與變形;觀察美台軍售(外購)與國機國造(自研)的資源排擠與系統整合問題;評估解放軍兩棲登島部隊面臨的新型態威脅;分析台海電子戰環境對無人機存活率的結構性影響;解讀2028選戰中國防產業政策與地方經濟的連結
---
## 一句話濃縮
台灣無人機發展是「以量取勝的消耗戰」與「高度依賴C4ISR的網路戰」之間的矛盾體——它被烏克蘭戰爭經驗推上不對稱作戰的神壇,被視為獵殺登陸艦與癱瘓雷達的廉價蜂群,但其戰效高度依賴在台海高密度電子戰環境下極可能被切斷的「大腦與神經」,而「國造自主」與「外購整合」的並行,則暴露了產業鏈的深層斷裂與系統孤島的風險。
---
## 事實層:系統、類型與結構
### 無人機的類型學與戰略意涵
理解台灣無人機發展,不能僅看飛行器本身,而需掌握**不同類型無人機在防衛體系中扮演的結構性角色**。無人機不是單一武器,而是一個從戰略偵察到戰術自殺攻擊的光譜。
**無人機類型與政治戰略意涵**:
| 類型 | 功能定義 | 台灣代表性系統 | 戰略與政治意涵 |
|---|---|---|---|
| 戰略/戰術偵察 | 長滯空、廣域監視、目標標定 | 銳鳶、騰雲、MQ-9B | 是「看不見的耳朵與眼睛」,提供遠程打擊(如HIMARS)的目標數據;外購與國造的競爭焦點 |
| 攻擊型無人機 | 攜帶精確彈藥,對地/對海打擊 | 騰雲(具潛力)、MQ-9B(掛載地獄火) | 賦予台灣「非接觸式打擊」能力,但跨越了「防禦性武器」的模糊界線 |
| 游蕩彈藥/自殺式 | 發現即摧毀,雷達壓制,飽和攻擊 | 劍翔、紅雀(衍生型) | 典型的不對稱武器,低成本高威脅;「豪豬刺」的最具體呈現,對敵方防空與登陦部隊構成心理與實質壓力 |
| 民用/商用轉軍用 | 物資投擲、近距偵察、FPV穿越機 | 民間改裝、採購商用現貨 | 戰時填補戰術縫隙的「游擊武器」,但品質與通訊抗干擾能力極不穩定 |
**分析含義**:無人機的戰略價值不在於「飛行」,而在於「感知與連結」。一架無法回傳數據或接收指令的無人機,只是昂貴的模型或毫無準頭的炸彈。分析時,必須將無人機視為「感測器節點」與「火力延伸器」,而非獨立的作戰平台。
### 台灣自製無人機:進程與瓶頸
台灣的無人機自製路線,是「國防自主」論述在風險與效益間來回擺盪的縮影。
**核心自製系統**:
| 系統 | 類型 | 研發/製造單位 | 現狀與關鍵數據 | 結構性問題 |
|---|---|---|---|---|
| 劍翔 | 反輻射/游蕩彈藥 | 中科院 | 已量產,具雷達源追蹤能力,推測航程可達1000公里以上 | 「反輻射」功能侷限於敵雷達開機;導引頭與電子反反制技術是否跟得上解放軍雷達升級,存在疑慮 |
| 銳鳶 | 戰術偵察 | 中科院 | 已服役,提供旅級單位偵蒐 | 過去墜機率偏高,暴露國造系統的可靠度問題;抗干擾能力受限,影響部隊信任度 |
| 騰雲 | 中型長航時(MALE)偵察/攻擊 | 中科院 | 研發測試中,歷經多次墜毀與改版 | 核心瓶頸在於「引擎」與「通信數據鏈」;缺乏合適的渦槳引擎,迫使中科院必須整合不同來源的次系統,增加系統不穩定性 |
| 紅雀 | 手擲式近距偵察 | 中科院 | 已服役,衍生型具攻擊能力 | 屬於戰術層級,填補基層單位偵蒐空白,但易受天候與地形影響 |
**脈絡層**:
台灣自製無人機的發展,呈現「兩極化」現象:一極是相對成熟的短程/自殺式無人機(如劍翔),因技術門檻較低且符合不對稱作戰需求,進展較快;另一極是需求迫切的中大型長航時無人機(如騰雲),因涉及航空引擎、高帶寬衛星通信、複雜飛控等高門檻技術,屢遭挫折。這種兩極化暴露了台灣國防工業「系統整合能力」與「關鍵零組件自主」的深層斷裂。詳見 `narratives/domestic/indigenous_defense_industry.md`。
**分析如何用此事實**:
1. 分析者不應將「劍翔量產」與「騰雲研發」視為同等的國造勝利。劍翔是「消耗性武器」的量產,反映的是戰時消耗的焦慮;騰雲的延宕則反映台灣無法獨立跨越「系統平台」的技術門檻。
2. 過去銳鳶的高墜機率在部隊中留下了「國造不可靠」的組織文化印記。分析陸軍對無人機的接受度與戰術創新時,必須將此「信任赤字」納入計算,否則會高估紙面戰力轉化為實質戰力的速度。
### 外購無人機:美台軍售的紅線與實用主義
在國造系統成熟前,外購是填補戰力空白的唯一途徑,但外購案本身是美國紅線管理與台灣戰略需求的精密妥協。
**關鍵外購案**:
| 系統 | 類型 | 採購狀態 | 關鍵數據 | 政治與戰略意涵 |
|---|---|---|---|---|
| MQ-9B 海衛 | MALE偵察/打擊 | 2020年宣布軍售4架,預計2025-2026交付 | 航程超過10000公里,滯空超過30小時 | 台灣首次獲得具備長程海域監視與潛在打擊能力的無人機;是美方將台灣納入區域情報網路的象徵,但單價極高且戰時在解放軍防空網下存活率存疑 |
| 彈簧刀300/600 | 巡飛彈藥 | 2023年宣布軍售 | 300型反人員,600型反裝甲 | 烏克蘭戰爭的明星武器,直接移植不對稱作戰經驗;但美國軍工產能能否按時交付,以及台灣能否建立足夠的彈藥庫存,是未知數 |
| RQ-21 黑杰克 | 短程偵察 | 已接收 | 艦載偵察 | 提升海軍艦艇的超視距感知能力 |
**脈絡層**:
MQ-9B的軍售,本質上賣的不是「飛機」,而是「北約標準的數據鏈與情報共享介面」。擁有MQ-9B意味著台灣的感測器數據可以在戰時(若美方選擇介入)無縫接入美軍的指揮體系。這是深層的軍事互信展現,也是北京極度反對的原因。詳見 `narratives/us_taiwan/arms_sales_dynamics.md`。
**分析如何用此事實**:
1. MQ-9B在台海的戰略價值,平時大於戰時。平時,它是跨越海峽中線偵蒸的合法載台,可持續監視解放軍海空動態;戰時,這種翼展龐大、速度緩慢的無人機在福建沿海的存活時間可能以分鐘計算。分析時應將其定位為「平時戰略ISR資產」與「戰時殉爆品」,而非具備持久作戰能力的平台。
2. 彈簧刀的軍售反映了「烏克蘭經驗移植」的急迫性,但台灣面臨的威脅規模遠大於烏克蘭前線的排級陣地。數百枚彈簧刀對於反登陸作戰的千軍萬馬而言只是杯水車薪,分析時不應將其視為改變戰局的銀彈,而應視為戰術層級的補充火力。
---
## 產業政策層:「無人機國家隊」與民轉軍的承諾與陷阱
「無人機國家隊」是台灣國防自主論述中最具企圖心,也最暴露產業結構脆弱性的一環。它試圖複製「半導體護國群山」的成功經驗,但忽視了軍規市場與商規市場的根本差異。
### 「無人機國家隊」的政策源流與結構
**事實內容**:
- 2017年,時任行政院長賴清德推動「無人機國家隊」,由中科院主導,結合漢翔、經緯航太等民間廠商。
- 2022年,嘉義亞洲無人機AI創新應用研究中心掛牌,企圖打造無人機聚落。
- 政策目標:2028年無人機產值達300億新台幣,並建立自主供應鏈。
- 核心邏輯:利用台灣強大的資通訊(ICT)與精密製造代工能力,轉化為無人機的生產優勢。
**脈絡層**:
無人機國家隊不僅是國防政策,更是**地方經濟與選舉政治的複合體**。嘉義作為無人機產業基地,直接連結了民進黨在中台灣的選票佈局與地方建設承諾。這使得無人機產業的成敗,不僅被軍事標準檢驗,也被政治標準(選舉支票兌現、就業數字)檢驗。
**分析如何用此事實**:
1. 分析無人機政策時,必須注意「產值目標」與「戰力目標」的潛在衝突。為了衝高產值,政策可能傾向補助門檻較低的商用無人機或代工零組件;但對國防真正有價值的,是具備抗干擾與系統整合能力的軍規無人機。兩者的技術路線與廠商生態截然不同。
2. 「無人機聚落」的設想面臨戰時脆弱性的悖論:將供應鏈集中於嘉義或特定科學園區,平時有利於群聚效應,戰時卻提供了解放軍高價值的打擊目標。這與無人機「分散、機動、存活」的作戰邏輯背道而馳。
### 民間產業轉向軍用的「快與慢」
**事實內容**:
- 台灣擁有全球最密集的ICT代工供應鏈,在無人機的飛控晶片、伺服器、光學鏡頭等次系統具備成本與產能優勢。
- 多家民用無人機廠商(如雷虎)轉型投入軍規市場,或透過「軍民通用」模式參與研發。
- 實際瓶頸:軍規產品要求極高的環境耐受度(鹽害、震動、溫差)、抗電磁干擾(EMI)能力與長期可靠度,這與民用產品追求快速迭代、低成本淘汰的邏輯完全相反。
- 關鍵零組件(如高階紅外線熱顯像儀、軍規數據鏈、特定等級的無刷馬達)仍高度依賴進口,受出口管制制約。
**脈絡層**:
「民轉軍」的核心困難不在於「製造能力」,而在於「信任機制」。軍方對民間廠商的品保體系缺乏信任,民間廠商對軍方漫長的測評流程與微薄的利潤缺乏耐心。這種制度性的摩擦力,使得台灣的ICT優勢難以迅速轉化為無人機的軍事優勢。
**分析如何用此事實**:
1. **破除常見簡化**:將台灣的「無人機產業」類比於「半導體產業」是錯誤的。半導體是全球化分工的極致,台灣掌握的是高資本門檻的製造節點;無人機是系統整合的產物,台灣掌握的是可被替代的代工節點。半導體護國是因為不可替代,無人機國造目前極具可替代性。
2. 分析者應追蹤「關鍵零組件的受制程度」。如果一台國造無人機的飛控晶片來自聯發科(台灣)、光學鏡頭來自大立光(台灣),但數據鏈晶片來自美國、熱顯像儀來自法國、馬達來自中國大陸,那麼這種「國造」在戰時面臨斷鏈的風險極高。所謂的「自主」,僅止於「系統整合」層次。
---
## 烏克蘭參照:經驗素材的移植與變形
烏克蘭戰爭是無人機從「輔助偵察工具」躍升為「戰術決定性武器」的轉折點。但台灣在移植此經驗時,面臨與HIMARS相同的「過度類比」風險,且因無人機對電子環境的依賴更深,其誤差後果更為嚴重。
### 烏克蘭戰場上的無人機效應
**事實內容**:
- 偵察打擊一體化:TB2無人機在戰爭初期成功摧毀俄軍指揮車與防空系統,建立「無人機無敵」的初期神話。
- 游蕩彈藥的壓制:彈簧刀與俄軍的柳葉刀,在戰線僵持階段成為反裝甲與反砲兵的有效手段。
- FPV穿越機的游擊戰:商用現貨改裝的FPV無人機,以極低成本實現對單兵與戰壕的精確打擊,改變了塹壕戰的攻防計算。
- 電子戰的逆向反制:隨著戰爭延長,俄軍部署密集電子戰系統,導致烏軍無人機大量失去信號或被接管,無人機存活率急降,雙方進入「電子戰消耗賽」。
**脈絡層**:
烏克蘭經驗證明了無人機的「低成本高效益」是建立在「對手尚未建立完整反制體系」的窗口期。一旦電子戰環境成熟,無人機的戰效便急速衰減。無人機不是無敵的,它只是「第一個找到防空網漏洞的消耗品」。
### 烏克蘭經驗的台海過濾:電子戰與地形的根本差異
| 維度 | 烏克蘭戰場 | 台海戰場 | 分析含義 |
|---|---|---|---|
| 電子戰環境 | 俄軍初期EW部署混亂,後期逐步加強,但戰線長導致密度不均 | 解放軍EW能力全球頂尖,且戰場集中於狹窄海峽與台灣本島,密度極高 | 台海無人機面臨的電子干擾強度將遠超烏克蘭;依賴GPS與射頻數據鏈的無人機,在台海西岸可能面臨「全頻譜壓制」的盲區 |
| 地形與視距 | 廣袤平原與森林,低空飛行易於隱蔽,地形遮蔽效應強 | 海峽無遮蔽,本島平原窄小,中央山脈阻隔東西岸通訊 | 跨海飛行無地形掩護,極易被雷達與光學系統鎖定;本島東岸的無人機難以與西岸部隊直接通聯,需依賴衛星或中繼機 |
| 作戰目標 | 陸上裝甲、戰壕、砲兵陣地 | 兩棲登陸艦、氣墊艇、灘頭集結區、沿海雷達 | 反艦需要更大的裝藥量與更精準的穿透能力,小型FPV或彈簧刀對大型登陸艦的毀傷效力有限,需更高層級的攻擊無人機或反艦飛彈 |
| 後勤補充 | 透過北約陸路源源不絕輸入零組件,民間自組裝產能龐大 | 戰時海空封鎖,零組件進口停滯,依賴戰前庫存與島內有限產能 | 台灣無法複製烏克蘭「邊打邊造」的民間自組裝模式,一旦特定晶片或馬達耗盡,無人機群將迅速萎縮 |
**分析含義**:
將「烏克蘭FPV無人機獵殺步兵」的經驗直接套用於台海反登陸,是危險的認知偏誤。台灣無人機的首要挑戰不是「如何發現目標」,而是「如何在強電子干擾下維持通訊與導航」以及「如何穿透軍艦的近程防空系統」。如果無法解決電子戰存活問題,台灣的無人機群在開戰首日可能就會面臨大規模失聯或墜海的命運。
---
## 敘事層:無人機的多重政治詮釋
同一批無人機,在不同政治行動者的敘事中,扮演著截然不同的角色,甚至內部存在邏輯斷裂。
### 敘事一:「豪豬戰略」的完美結晶——廉價、致命、大量
**核心命題**:無人機是不對稱作戰的典範——它讓台灣用相對低廉的成本,具備讓侵略者付出慘痛代價的能力,完美契合「讓戰爭無法承受」的防衛需求。
**論述邏輯**:
- 傳統大型平台(戰機、軍艦)造價昂貴且數量有限,無人機則可實現「蜂群」飽和攻擊。
- 即使被擊落,損失的也只是機器而非飛行員,符合「人命無價」的防衛原則。
- 商規零組件的取得容易,戰時可由民間工廠大量生產,具備持久戰潛力。
- 詳見 `narratives/us_taiwan/porcupine_strategy.md`
**支持者**:美方不對稱戰略推動者、台灣國防部決策層、民間無人機廠商
**反對者視角**:此敘事忽略了「大量」的前提是「零組件不受制於人」與「電子戰環境可控」。在台海高強度電磁干擾下,無人機的失聯率可能極高,所謂的「蜂群」可能瞬間退化為毫無戰術意義的「廢鐵群」。此外,對抗大型登陸艦仍需反艦飛彈,小型無人機的裝藥量不足以構成致命威脅。
### 敘事二:「國防自主」的經濟兌現——無人機護國群山
**核心命題**:無人機產業是台灣ICT優勢的延伸,發展軍用無人機不僅強化國防,更能帶動產業升級與出口,成為繼半導體之後的「護國群山」。
**論述邏輯**:
- 台灣在晶片、代工、精密製造具有全球優勢,轉做無人機水到渠成。
- 國防預算投入無人機,等於變相補助民間科技研發,創造雙贏。
- 建立自主產能後,還可外銷友好國家,擴展國際空間。
**支持者**:執政黨(作為政績與地方建設)、民間廠商、部分經濟部門
**反對者視角**:此敘事混淆了「代工能力」與「系統整合能力」。台灣能代工無人機的零件,不代表能研發軍規的整機系統。更現實的是,軍規無人機市場已被美國、土耳其、以色列等國佔據,台灣缺乏外交空間,幾乎無法外銷軍規整機,所謂的「產值300億」極可能淪為內需市場的數字遊戲,缺乏國際競爭力。
### 敘事三:「源頭打擊」與「不對稱升級」的灰色地帶
**核心命題**:劍翔等具備長航程與反輻射能力的無人機,讓台灣具備將戰火燒向敵方沿海甚至內陸的能力,這是「防衛固守」向「重層嚇阻」轉型的實質證明。
**論述邏輯**:
- 劍翔無人機航程可達1000公里,足以威脅東部戰區的縱深目標。
- 反輻射能力迫使解放軍雷達在戰時不敢輕易開機,壓縮其防空預警時間。
- 這種反擊能力是維持民心士氣的關鍵——「我們有能力讓敵人也付出代價」。
**支持者**:台灣執政黨、中科院、部分戰略鷹派
**反對者視角**:此敘事在國內是強心針,但在北京眼中是挑釁。長航程無人機具備「先制打擊」的潛力,北京可能主張必須在第一擊即摧毀台灣的無人機基地與產線。此外,劍翔的「反輻射」定位意味著它依賴敵方雷達開機,若解放軍採用雷達靜默或以誘餌欺敵,其戰效將大打折扣。
### 敘事四:「脆弱的消費品」——對國造品質的深層質疑
**核心命題**:台灣自製無人機在過去屢傳墜機與可靠度問題,在面對解放軍完整的防空與電子戰體系時,可能只是「昂貴的煙火」。
**論述邏輯**:
- 銳鳶無人機過去的高墜機率,顯示國造系統在品保與環境適應上存在根本缺陷。
- 民間廠商缺乏軍規研發經驗,以「消費性電子」思維設計「軍用武器」,無法承受戰場的嚴酷環境。
- 中科院的研發能量被多個大案(潛艦、飛彈)分散,無人機可能只是「拼裝車」。
**支持者**:部分悲觀派軍事分析者、在野黨(作為監督施政的切入點)、北京官方媒體(用於貶低台軍戰力)
**反對者視角**:此敘事以過去的失敗否定未來的進步,忽略了中科院在劍翔等新系統上已展現的技術突破。早期銳鳶的問題多出於操作程序與環境適應,而非不可修復的設計缺陷,近年已見改善。
---
## 深層結構:內在矛盾與結構性命題
### 矛盾一:「可消耗載台」與「不可消耗C4ISR」的依賴陷阱
無人機的「廉價」與「可消耗」特性,是建立在一個極度昂貴且脆弱的基礎設施之上:C4ISR(指揮、管制、通訊、資訊、情報、監視與偵察)。
**內在矛盾**:
- 無人機的價值在於「感知」與「打擊」的即時連結,這依賴高帶寬、抗干擾的數據鏈與衛星通信。
- 載台(飛機)可以很便宜,但數據鏈與衛星頻寬極其昂貴且稀缺。
- 一旦解放軍實施電子壓制,數據鏈中斷,無人機將退化為盲飛的模型,或只能依賴預程式飛行,喪失對動態目標的打擊能力。
- 這意味著,無人機的「不對稱優勢」,在結構上與「制電磁權」深度綁定。誰掌握了電磁權,誰的無人機才是武器;失去電磁權,無人機就是廢鐵。
**分析如何用此矛盾**:
分析者不應將無人機的數量直接等同於戰力。1000架依賴射頻數據鏈的無人機,在強電子干擾下,戰力可能趨近於零。因此,台灣無人機發展的真正瓶頸,不是「造不出飛機」,而是「造不出能在戰時保持通聯的數據鏈」。**這創造了一個結構性命題:無人機戰爭的本質是「通訊基礎設施的消耗戰」,而非「飛行器的消耗戰」。**
### 矛盾二:國造自主與外購整合的「系統孤島」
台灣採取「國造與外購並行」的路線,看似務實,實則在系統整合上埋下巨大隱患。
**內在矛盾**:
- 外購的MQ-9B使用北約標準數據鏈(如STANAG 4586),可與美方系統無縫接軌。
- 國造的銳鳶、騰雲使用中科院自研的數據鏈,與美規系統不相容。
- 這導致台灣軍方在作戰時,必須同時操作兩套無法互通的指管系統。
- 美方不願開放原始碼或數據鏈介面給台灣國造系統(基於資安與保密),中科院也難以逆向整合美規介面。
**分析如何用此矛盾**:
「國造」與「外購」並行,在硬體上增加了數量,但在軟體上卻製造了「系統孤島」。戰時,MQ-9B蒐集的情報無法即時傳輸給操作騰雲的單位,反之亦然。這種互不連通的現象,違反了現代戰爭「網路中心戰」的核心原則,使得1+1<2。分析時,應將「系統整合度」視為比「無人機數量」更關鍵的指標。
### 矛盾三:民間產業「敏捷」與軍方「保守」的文化衝突
「無人機國家隊」試圖引入民間科技業的敏捷開發模式,但與軍方的保守採購文化發生劇烈碰撞。
**內在矛盾**:
- 民間ICT業習慣「快速迭代、容忍失敗、模組化升級」,追求的是上市速度。
- 軍方採購要求「十年壽期、零缺陷品保、規格凍結」,追求的是絕對可靠。
- 結果:民間廠商抱怨軍方測評曠日廢時,且利潤微薄;軍方抱怨民間產品粗糙,不耐野戰環境,不願貿然採用。
**分析如何用此矛盾**:
這不是單純的行政效率問題,而是兩種不可調和的系統邏輯。若要實現「蜂群」作戰,必須採用民間的敏捷迭代,不斷更新軟體與對抗電子戰;但若要確保飛安與可靠,又必須遵守軍規品保。**破除常見簡化**:以為「下個訂單給民間」就是「民轉軍」是錯的。真正的挑戰在於,台灣國防部必須發明一套「介於軍規與商規之間」的新採購與驗收標準(例如針對消耗性游蕩彈藥降低耐候要求,僅要求一次性可靠),否則民間產能永遠無法有效轉化為軍事戰力。
---
## 對解放軍的威脅評估與目標邏輯
無人機對解放軍的威脅,不在於單機的殺傷力,而在於**對防空網的飽和施壓與對作戰節奏的干擾**。
### 對登陸船團的「蜂群」威脅
**事實與邏輯**:
- 兩棲登陸船團在航渡階段,是缺乏地形掩護的大型目標。
- 台灣若能發射大量自殺式無人機或FPV穿越機,可對登陸艦的雷達、指揮艙與近防武器進行飽和攻擊。
- 即使無人機裝藥不足以擊沉大型艦艇,也能破壞其感測器與通訊設備,使其「聾盲」,為反艦飛彈創造攻擊窗口。
**分析含義**:無人機在此場景中是「助攻者」而非「主攻者」。它負責「致盲」,反艦飛彈負責「擊殺」。分析時不應過度誇大小型無人機的反艦能力,而應關注其破壞防空殺傷鏈的戰術價值。
### 對沿海雷達與防空陣地的「壓制」威脅
**事實與邏輯**:
- 劍翔等反輻射無人機,專門鎖定雷達波源。
- 解放軍在福建沿海部署了密集的防空網(S-400、HQ-9等),這些雷達是保護登陸部隊的保護傘。
- 反輻射無人機的存在,迫使解放軍雷達操作員在開機偵搜與關機保命之間猶豫,甚至必須頻繁換位,降低了防空網的效率。
**分析含義**:這是一種「心理戰與程序戰」的結合。劍翔的威脅不在於它真的擊毀了多少雷達車,而在於它迫使敵方改變操作程序(縮短開機時間、增加假目標),從而為台灣的攻擊機與飛彈撕開了時間窗口。
### 對指揮中樞與後勤節點的「縱深」威脅
**事實與邏輯**:
- 具備長航程的無人機(如劍翔增程型或未來的攻擊型無人機),可打擊解放軍集結港口、指揮所與油彈庫。
- 這種威脅迫使解放軍必須將高價值目標向內陸後撤,增加登陸作戰的後勤距離。
**分析含義**:與HIMARS的ATACMS類似,長程無人機的威懾在於「空間的強制重分配」。但它比飛彈更具「徘徊」與「獵殺」的彈性,也更容易因電子干擾而失效。
---
## 豪豬戰略辯論中的無人機
詳見 `narratives/us_taiwan/porcupine_strategy.md`。
### 無人機作為「不對稱作戰」的標竿
在豪豬戰略的辯論光譜上,無人機佔據了核心位置:它是最典型的「低成本、高威脅、可消耗」資產。
**豪豬純粹主義者的擁護**:
- 無人機符合「大量、廉價、持久」的原則,是台灣應對數量劣勢的最佳解方。
- 商規零組件的普及,讓台灣有可能在戰前儲備大量庫存,甚至發展「列印即用」的無人機產線。
**傳統防衛者的質疑**:
- 無人機無法奪取制空權與制海權,只能作為騷擾與輔助手段。
- 過度投資無人機,可能排擮戰機、潛艦等「戰略資產」的預算,導致防衛體系失衡。
- 烏克蘭經驗顯示,無人機無法單獨決定戰局,最終仍需地面部隊與重火力的配合。
### 無人機在辯論中的「橋樑位置」
無人機案反映了美台雙方在「純粹不對稱」與「傳統嚇阻」之間的妥協:
- 美方樂見台灣發展無人機(因符合不對稱防禦,且不挑戰美國技術優勢),但對出售MQ-9B等戰略偵察無人機則較為謹慎(因涉及情報共享)。
- 台方藉由發展無人機,既回應了美方的不對稱要求,又為「國防自主」與「產業升級」找到了著力點。
**分析含義**:
無人機是豪豬戰略與國防自主的「最大公約數」。分析台灣國防結構時,應將無人機視為**「政治正確的不對稱武器」**——它在戰略論述上無懈可擊,但在戰術執行上充滿變數(電子戰、系統整合)。這使得無人機預算極易通過,但戰力生成卻未必如預期。
---
## 未來敘事:AI、蜂群與反無人機的幽靈
### AI自主飛行與蜂群作戰的議題
關於台灣無人機是否應追求高度AI自主化與蜂群作戰能力,存在三種敘事:
**敘事一:「AI補償」論**
- 主張:台海電子戰環境極度惡劣,依賴人工遙控的無人機必死無疑;必須發展AI視覺辨識與自主決策,讓無人機在斷訊時仍能完成任務。
- 邏輯:AI是對抗電子壓制的唯一解方;「發射後不管」的蜂群,才能實現真正的飽和攻擊。
- 障礙:AI技術門檻極高,且軍方對「自主殺傷」存在倫理與失控疑慮。
**敘事二:「人機協作」論**
- 主張:不應追求完全自主,而應發展「有人/無人協作」(如無人機由戰機或地面站引導)。
- 邏輯:保留人的判斷力,避免誤擊與升級;利用無人機延伸感測範圍,降低有人平台風險。
- 障礙:數據鏈仍是瓶頸,且協作戰術複雜,訓練成本高。
**敘事三:「反無人機」優先論**
- 主張:台灣在發展無人機的同時,必須同步甚至優先發展反無人機系統(C-UAS)。
- 邏輯:解放軍同樣擁有龐大的無人機群(如翼龍、彩虹系列),且數量更佔優勢;若只攻不守,台灣的灘岸陣地與指揮所將先被敵方無人機癱瘓。
- 障礙:反無人機技術(微波、雷射、干擾)同樣昂貴,且預算排擠效應明顯。
### 未來議題的分析含義
1. **AI是「信任溫度計」**:台灣是否能從美方獲得AI飛控與目標辨識的關鍵演算法,是檢驗美台軍事技術互信的新指標。詳見 `narratives/us_taiwan/arms_sales_dynamics.md`。
2. **「反無人機」的忽略**:台灣的無人機敘事高度集中在「我如何攻擊」,而相對忽略了「我如何防禦敵方的無人機」。這是一種攻擊偏誤。解放軍的無人機對台灣固定設施(雷達站、砲陣地)的威脅,可能比台灣無人機對解放軍的威脅更為迫近。
3. **2028選舉的潛在議題**:隨著AI與無人機結合,「自主殺傷武器的倫理爭議」可能在台灣內部發酵。候選人是否支持「讓AI決定何時開火」?這將是測試國防路線與科技倫理邊界的關鍵辯論。
---
## 關聯分析
### 關聯一:與美對台軍售動態的結構性連結
詳見 `narratives/us_taiwan/arms_sales_dynamics.md`。
- 功能:提供事實層與脈絡層
- MQ-9B軍售案是美國對台軍售從「純防禦性平台」轉向「具備戰略偵察與聯合作戰潛力」的里程碑。
- 彈簧刀的軍售,則是美方將台灣定位為「不對稱消耗戰場」的物質體現。
- 交叉驗證:若出現美方暫緩售台無人機數據鏈升級或特定彈藥的新聞,應回溯檢視美中在電子戰與太空領域的博弈是否升溫。
### 關聯二:與國防自主產業論述的連結
詳見 `narratives/domestic/indigenous_defense_industry.md`。
- 功能:提供結構層與用於交叉驗證
- 無人機是國防自主論述的「試驗場」:它比潛艦與戰機門檻低,更適合作為「民轉軍」的示範項目。
- 交叉驗證:無人機產業的「國家隊」模式,是否重蹈了過去「系統整合能力不足、關鍵組件受制於人」的覆轍?觀察中科院在無人機引擎與數據鏈上的研發進度,可驗證國防自主的實質深度。
### 關聯三:與豪豬戰略辯論的張力連結
詳見 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:用於對照與提供內在矛盾
- 無人機是豪豬戰略的「矛」,但也是「易碎的矛」。
- 分析時,應將無人機視為豪豬戰略內部「數量派」(主張大量廉價消耗品)與「品質派」(主張需具備抗干擾與精確打擊能力)爭論的焦點案例。
### 關聯四:與北京反應類型學的互動
- 功能:提供脈絡層
- 北京對台灣發展無人機的反應,區分為「自製」與「外購」兩種層次:對MQ-9B等外購案,定性為「外部勢力干涉與情報共享」,反應強烈;對劍翔等自製案,則傾向貶低其技術水準,但同時加大對台電子戰部隊的部署。
- 詳見 `primitives/frameworks/beijing_response_typology.md`(占位)。
### 關聯五:與疑美論的複雜共生
- 功能:提供脈絡層與用於交叉驗證
- 無人機外購案是疑美論的「雙面刃」:
- 售台MQ-9B = 美方支持台灣戰略偵察能力(反疑美論)
- 僅售4架且延遲交付 = 美方留一手,不讓台灣具備持久監視能力(疑美論)
- 彈簧刀數量有限 = 象徵性軍售,實質效益低(疑美論)
- 分析時應注意:同一軍售案在台灣內部被不同政治陣營選擇性引用,作為「美國可靠」或「美國不可靠」的證據。
### 關聯六:與台海電子戰平衡的連結
- 功能:提供結構性命題
- 無人機的戰效,是台海電子戰平衡的函數。
- 解放軍在東南沿海的電子戰部署密度,直接決定了台灣無人機的存活率;而台灣無人機的抗干擾能力,則是測試中科院電戰技術的試金石。
- 詳見 `entities/equipment/ew_capabilities.md`(占位)。
---
## 常見的分析陷阱
### 陷阱一:將烏克蘭經驗直接套用於台海
受烏克蘭戰爭初期效應影響,極易將無人機神話化。烏克蘭的無人機成功,建立在俄軍初期電子戰部署的混亂與北約的情報/零組件援助之上。台海是高強度電子戰環境,且戰時外援中斷。將烏克蘭的「無人機游擊戰」直接預測為台海的未來,是忽略了電磁環境的根本差異。
### 陷阱二:混淆「民用無人機」與「軍規無人機」的門檻
認為台灣有強大的ICT代工能力,就能迅速轉產軍用無人機,是常見的謬誤。民用無人機容忍當機、依賴GPS、不抗干擾;軍用無人機要求極高可靠度、抗干擾數據鏈與惡劣環境耐受。兩者在品保、測試與系統整合上的成本與時間差距,遠大於硬體製造的差距。
### 陷阱三:忽略「數據鏈」才是無人機的核心
分析時常計算「台灣有多少架無人機」,卻忽略「台灣有多少條抗干擾數據鏈」。在電子壓制下,沒有數據鏈的無人機只是盲飛的炸彈。以無人機數量推算戰力,會嚴重高估其實質威懾力。
### 陷阱四:假設「蜂群」技術已成熟可用
「蜂群作戰」在概念上極具吸引力,但目前全球尚無在高強度電子戰環境下,大規模AI自主蜂群成功作戰的實戰案例。假設台灣能在短期內具備成熟的蜂群作戰能力,是低估了AI決策、通訊組網與敵我識別的技術門檻。
### 陷阱五:忽略反無人機(C-UAS)的急迫性
分析者常聚焦於「台灣無人機如何攻擊解放軍」,卻忽略了「解放軍無人機如何攻擊台灣」。解放軍同樣擁有龐大的無人機群與蜂群戰術,且工業基礎更為雄厚。若台灣只重視發展無人機而忽略反無人機防禦,將在未來戰場面臨單方面被獵殺的窘境。
### 陷阱六:將「國家隊」產值等同於「戰力」
將無人機產業的「經濟產值」或「廠商家數」,等同於「軍事戰力」,是混淆了政治指標與軍事指標。大量生產商用等級的無人機,或代工無關緊要的零組件,可以創造產值,但對嚇阻解放軍毫無貢獻。
---
## 仍不確定的部分
- **無人機在台海高強度電子戰下的實際存活率**:目前所有評估皆基於兵棋推演與假設,缺乏在高密度電子對抗環境下的實戰驗證。中科院的抗干擯數據鏈能否有效對抗解放軍最新型電戰系統,仍屬未知。
- **關鍵零組件的戰時供應鏈韌性**:若戰爭爆發,台灣能否在無法進口特定晶片、馬達與光學元件的情況下,維持無人機的量產?「自主產線」的實質自給率仍不透明。
- **AI自主飛行的倫理與戰術邊界**:台灣軍方是否願意在何種程度上授權AI自主決定開火?這涉及交戰規則(ROE)的根本改變,目前無明確政策方向。
- **民間產能轉換為軍用的真實速度**:在「無人機國家隊」框架下,民間工廠從事民生代工轉為軍規生產,需要多少調適期?品保體系能否在戰時壓力下維持?這些變量僅存在於紙面計畫。
- **解放軍反無人機戰術的演進速度**:解放軍是否已針對台灣的劍翔與彈簧刀,發展出特定的攔截戰術(如微波武器、雷射防空)?若是,台灣無人機的威脅邏輯需重新修正。
- **MQ-9B的情報共享深度**:美方是否會在危機中向台灣開放MQ-9B的即時數據鏈,讓其接入美軍指管體系?這涉及極高的政治風險,目前僅有模糊的「強化合作」宣示。
---
## 與其他文件的關係
- 美對台軍售動態:`narratives/us_taiwan/arms_sales_dynamics.md`(提供MQ-9B與彈簧刀軍售案在美台戰略博弈中的定位與政治背景)
- 國防自主產業:`narratives/domestic/indigenous_defense_industry.md`(提供無人機國家隊在國造政策中的位置,以及民轉軍的產業結構脈絡)
- 豪豬戰略:`narratives/us_taiwan/porcupine_strategy.md`(提供無人機在不對稱作戰辯論中的框架,以及「消耗 vs 存活」的論述脈絡)
- 疑美論:`narratives/cross_strait/skepticism_of_us.md`(提供外購無人機數量限制與交付延遲被詮釋為疑美論證據的脈絡)
- 北京反應類型學:`primitives/frameworks/beijing_response_typology.md`(提供預測北京對台灣無人機發展反應的分析框架)
- 電子戰能力:`entities/equipment/ew_capabilities.md`(占位)(提供無人機存活率評估的關鍵電磁環境背景)
---
## 一句話濃縮
> 台灣無人機發展是「以量取勝的消耗戰」與「高度依賴C4ISR的網路戰」之間的矛盾體——它被烏克蘭戰爭推上不對稱作戰的神壇,被視為獵殺登陸艦的廉價蜂群,但其戰效高度依賴在台海高密度電子戰環境下極可能被切斷的「大腦與神經」。
> 它的政治意義遠超其戰術價值——無人機國家隊是國防自主與地方經濟的黏合劑,而外購案則是美台情報共享的試探針。
> 分析無人機,不是算它能飛多遠、帶多少炸藥,而是看它如何**暴露國造系統整合的深層斷裂、測試民轉軍的制度摩擦力、並在電子戰的幽靈下重新定義什麼是「可消耗」的戰力**。
---
last_updated: 2025-05-19
@@ -0,0 +1,390 @@
# F-16V 战斗机案(F-16V Combat Aircraft Program)
---
**类型**: 政治叙事
**当前角色**: 台湾空军核心战机 / 美台军售政治象征 / 豪猪战略辩论焦点
**出生时间**: 2011(凤展专案启动)/ 2019(新购66架案通过)
**何时调用此档案**: 分析台湾空防结构变动;评估美台军售的政治信号与红线管理;解读「传统平台 vs 不对称」的战略辩论;观察两岸军力平衡叙事;分析台湾国防预算排挤效应;预测下一代战机(F-35)政治前景
---
## 一句话浓缩
F-16V 是台湾维持传统制空权叙事的**最后支柱**,也是美台在「不对称战略」与「传统战力」之间妥协的**具体结晶**——它既是台湾跨越党派、历经三任总统才争取到的「安全保证符号」,也是「大型昂贵平台在第一击下能存活多久」此一结构性质疑的活靶。
---
## 事实层:规格、进程与结构
### F-16V 的核心规格与战略意涵
F-16V(Viper)是 F-16 家族的最新改良型。理解其规格不需深入工程细节,而需掌握**哪些规格改变了空战政治**:
**关键规格与政治意涵**:
| 规格 | 事实内容 | 政治与战略意涵 |
|---|---|---|
| 雷达 | APG-83 主动电子扫描阵列雷达(AESA) | 让旧机身拥有与第五代战机同等级的探测与抗干扰能力,是美方「不售新机但给新雷达」的妥协产物 |
| 任务计算机 | 模块化任务计算机(MMC 7000) | 提升运算能力,使 V 型能挂载与运用新型精准弹药 |
| 武器整合 | AGM-84H/K(SLAM-ER)、AGM-154(JSOW)等 | 具备对地/对海的远距打击能力,跨越了传统「防御性武器」的模糊界线 |
| 机身寿命 | 延寿至 8000-12000 小时 | 让 1990 年代的旧机身延长服役至 2040 年代,填补下一代战机前的空窗期 |
| 通信与数据链 | 升级数据链路 | 增强与盟邦(隐含美日)的战术资讯共享潜力 |
**分析含义**:F-16V 的核心逻辑是「旧瓶装新酒」——用相对低政治敏感度的旧机身,搭载高作战效能的新航电。这让美方能在不提供 F-35 的前提下,满足台方战力升级需求,同时管理北京的红线反应。
### 凤展专案:从 A/B 到 V 的升级
**事实内容**:
- 时间:2011 年启动,2016 年开始执行
- 内容:将现有 141 架 F-16A/B 升级为 F-16V 规格
- 总价:约 38 亿美元(升级部分,不含后续武器弹药的另购案)
- 执行者:汉翔航空与洛克希德·马丁合作
- 进度:2023 年底宣布全数升级完成,但实际战力形成(飞行员换训、后勤建立)仍需时间
**脉络层**:
凤展专案的本质是**「存量优化」**。在马政府时期,美方不愿售予新机(F-16C/D),但同意升级现有战机。这是美方在「提供防卫能力(TRA 义务)」与「避免过度刺激北京」之间的典型平衡。
**分析如何用此事实**:
1. 凤展专案证明了美方对台军售的「底线思维」——可以让你变强,但不让你变多。
2. 升级案由汉翔参与,这不仅是技术转移,更是台湾维持本土航空工业能量的政治设计。分析国防自主叙事时,此点常被忽略。
3. 升级案的进度延迟(原订 2023 年前完成,实际延宕)是评估台湾后勤能量与美军工产能瓶颈的指标。
### 凤翔专案:2019 年新购 66 架
**事实内容**:
- 时间:2019 年 8 月川普政府批准,2020 年签署发价书
- 内容:新造 66 架 F-16V(单座 56 架、双座 10 架)
- 总价:约 80 亿美元(含飞机、弹药、训练、后勤)
- 交付时程:原订 2023 年首架交机,2026 年全数交付;实际因疫情与供应链问题延后,2024 年底首批抵台,预计 2027 年前后完成
- 部署地点:台东志航基地(接替 F-5E/F 战机)
**脉络层**:
这是自 1992 年老布希售台 150 架 F-16A/B 以来,台湾首度获得全新战机。其政治背景极为特殊:
1. **跨越党派的渴望**:KMT 从 2006 年起即争取 F-16C/D,马政府八年任内持续未获,成为蓝营对美外交失分的痛点。
2. **DPP 的突破**:蔡政府任内获得,不仅填补战力缺口,更在政治上宣示「DPP 也能稳住对美军购」。
3. **川普的战略转向**:2019 年正值美中贸易战高峰,川普政府将台湾作为印太战略支点,批准此案是整体对台政策升级的一环。
**分析如何用此事实**:
1. 80 亿美元是台湾单一军购案的历史天价。分析台湾国防预算排挤效应时,此案是核心变量——它吃掉了大量预算,直接压缩了其他不对称武器的采购空间。
2. 66 架新机的部署地点(志航基地)位于台湾东部,隐含了「战力保存」的战略想定——将主力置于中央山脉后方,抵御第一击。这与「战机在地面即被摧毁」的脆弱性辩论直接相关。
3. 双座型(10 架)的配置,不仅是训练需求,更具备「未来无人机控制母机」的潜在功能,这为下一代战力叙事埋下伏笔。
### 在台湾空军中的核心地位
**事实内容**:
- 升级后,台湾空军 F-16 总数将超过 200 架(141 架升级 + 66 架新购 + 若干备份机)。
- F-16V 将成为台湾唯一具备完整视距外作战(BVR)与精准打击能力的主力战机。
- 取代 F-5 战机(老旧教练/轻战机)与部分 Mirage 2000-5(法系战机,后勤昂贵)的职能。
- 详见 `entities/organizations/roc_air_force.md`。
**脉络层**:
台湾空军的结构是「美法混合」(F-16 + Mirage 2000 + IDF 经国号)。F-16V 的全面化,意味着空军**后勤与战力体系向美系单一靠拢**。这增强了与美军的互通性,但也加深了对美方零件与技术的绝对依赖。
**分析如何用此事实**:
1. 超过 200 架 F-16V 的规模,让台湾成为亚太地区 F-16 机队规模前列的用户。这不仅是战力指标,更是**政治筹码**——庞大的机队意味着美方有持续的商业利益(后续维修、升级、弹药),也意味着台湾对美方的依赖不可逆转。
2. 当 F-16V 成为唯一主力时,其弱点就是空军的弱点。分析者应关注「所有鸡蛋放在同一篮子」的系统性风险。
---
## 叙事层:F-16V 的多重政治诠释
同一架 F-16V,在不同政治行动者的叙事中扮演截然不同的角色。
### 叙事一:「制空权维持」的生存底线
**核心命题**:没有制空权,就没有防卫权。F-16V 是维持台海制空权不被中方彻底压倒的最低保障。
**论述逻辑**:
- 中方空军现代化速度极快(J-10C、J-16、J-20 大量服役)
- 台湾若不升级/增购战机,制空权将在数年内完全失衡
- 没有制空权,地面部队与海上舰艇皆为活靶,不对称作战也无法执行
- 因此,F-16V 不是「传统平台」,而是「不对称防御的前置条件」
**支持者**:台湾空军、KMT 与 DPP 中的国防鹰派、部分美方传统军事分析者
**反对者视角**:此叙事忽略了现代战争中,制空权的争夺已不单靠战机性能,更依赖长程防空飞弹、电子战与预警体系。F-16V 在战时能否从残存跑道起飞仍是问号。
### 叙事二:「红线管理」的妥协艺术
**核心命题**:F-16V 是美方在「提供防卫能力」与「避免跨越红线」之间精准计算的产物。
**论述逻辑**:
- 美方始终拒绝售台 F-35(第五代隐形战机),因为此举将被北京视为极度挑衅
- F-16V 透过旧机身升级与新造同型机,在不改变平台代际的前提下,大幅提升战力
- 这是一种「可管理的升级」——让台湾有防卫能力,但不致改变台海军力根本结构
- 详见 `narratives/us_taiwan/arms_sales_dynamics.md`
**支持者**:美方行政官员、部分务实派智库
**反对者视角**:此叙事隐含了「美国替北京顾虑」的让步逻辑,在台湾内部常被解读为「美方不愿提供真正需要的武器」,成为疑美论的温床。
### 叙事三:「昂贵标靶」的资源错置
**核心命题**:在豪猪战略框架下,80 亿美元的 F-16V 是台湾无力负担的战略豪赌,且在第一击下生存率堪忧。
**论述逻辑**:
- 现代战争中,机场是首要打击目标。中方拥有大量长程精准弹药,可瘫痪跑道与机堡
- F-16V 需要长跑道、复杂后勤、高妥善率才能升空,一旦基地受损即成废铁
- 80 亿美元若用于购买数千枚机动反舰/防空飞弹,对整体防卫的边际效用更高
- 这不是「制空 vs 不制空」的问题,而是「买得起制空权吗」的资源分配问题
- 详见 `narratives/us_taiwan/porcupine_strategy.md`
**支持者**:美方不对称战略倡导者(如 CSIS 兵棋推演团队)、台湾部分本土防卫论者
**反对者视角**:此叙事过度贬低战机的多用途弹性(拦截、反舰、纵深打击),且忽略了士气与主权象征的政治功能。若完全放弃制空,等于接受被封锁与单向轰炸。
### 叙事四:「打破现状」的挑衅
**核心命题**:F-16V 配备的长程精准弹药(如 SLAM-ER)已超越防御需求,是对北京的挑衅。
**论述逻辑**:
- 北京将任何对台重大军售视为违反「八一七公报」
- F-16V 的对地/对海打击能力,使台湾具备「先制打击」潜力
- 此举鼓励台独势力,破坏两岸军力平衡
**支持者**:北京官方、部分国际现实主义学者
**反对者视角**:此叙事将防卫能力等同于挑衅,且忽略了中方自身军力扩张(如 J-20 量产)才是打破平衡的源头。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:「战力保存」与「第一击存活」的悖论
F-16V 的战略想定高度依赖「战力保存」——即在开战前将战机分散至佳山基地等地下设施或东部基地,待第一波打击过后再升空反击。
**内在矛盾**:
- 要有效执行战力保存,需要**预警时间**(从决策到疏散的缓冲期)
- 但中方若采取无预警突击或灰色地带升压,预警时间可能趋近于零
- F-16V 越是依赖战力保存,就越需要在危机初期就疏散,但这等同于**提前示警**,可能被北京视为升级
- 因此,F-16V 的存活率与政治决策的「不示弱」需求之间存在根本冲突
**分析如何用此矛盾**:
当分析「台湾空军在危机中的反应」时,不要只算飞机数量,要算「决策时间」与「跑道修复能力」。战机在机库里的数量没有意义,升空的数量才有。
### 矛盾二:预算排挤与「全谱系防卫」的不可兼得
80 亿美元的采购费加上后续数十年的维持费,是台湾国防预算的沉重负担。
**内在矛盾**:
- 台湾试图同时维持「传统战力」(F-16V、潜舰)与「不对称战力」(飞弹、无人机)
- 但预算有限,大型平台会排挤小型武器的大量部署
- 美方要求台湾走不对称路线,却又批准 F-16V 这种传统大案
- 这使得台湾内部在资源分配上永远处于「挖东墙补西墙」的状态
**结构性命题**:
**F-16V 是台湾从「全谱系军队」向「豪猪军队」转型的最大阻力与最后锚点。** 只要 F-16V 存在,台湾军方就仍有维持传统军种规模与建制的正当性;若放弃大型平台,军种结构将面临根本重组。因此,F-16V 的存废不只是战术问题,而是**军种政治与国防身份**的问题。
### 矛盾三:对美依赖与国防自主的张力
**内在矛盾**:
- 凤展专案强调汉翔参与,带有「国防自主」的象征
- 但 F-16V 的核心(发动机、雷达、关键弹药)完全受制于美方
- 当美方因政治因素暂停供货(如过往的军售冻结期),这些战机将逐渐失去战力
- 这使得台湾在谈判桌上永远无法对美方强硬,因为「零件供应」就是最好的缰绳
---
## 对比分析:F-16V vs 中方主力战机
分析 F-16V 的威慑力,必须置于两岸军力对比的结构中。不过度技术化,但需理解「代差」的政治含义。
### 对比一:F-16V vs J-20(第五代隐形战机)
| 维度 | F-16V (4.5代) | J-20 (5代) | 分析含义 |
|---|---|---|---|
| 隐形性 | 无 | 具备低可观测性 | J-20 拥有「先发现、先开火」的优势,F-16V 在视距外空战中处于被动 |
| 雷达 | APG-83 (AESA) | 有源相控阵雷达 | F-16V 雷达性能优异,可在一定程度上抵消隐形优势,但非对称对抗 |
| 数量 | 台湾 200+ 架 | 中方预估 200-300+ 架且持续增加 | 数量优势在中方,且中方有完整体系支撑(预警机、加油机) |
| 体系 | 台湾孤岛作战 | 依托大陆完整防空与指挥体系 | 体系差距比单机差距更致命 |
**破除常见简化**:
「F-16V 无法对抗 J-20,所以没用」——这是错的。现代空战是体系对抗,F-16V 搭配长程防空飞弹(爱国者、天弓)与预警雷达,仍可在防空拦截任务中发挥作用。但 F-16V 无法在海峡中线以西争夺制空权,这是事实。其角色已从「攻势制空」退缩为「防卫拦截」。
### 对比二:F-16V vs J-16 / J-10C(4.5代同级别对手)
| 维度 | F-16V | J-16 / J-10C | 分析含义 |
|---|---|---|---|
| 航电 | 顶尖(美系 AESA) | 优秀(中系 AESA) | 单机性能差距不大,F-16V 甚至在雷达与电战上略优 |
| 武器 | AIM-120D / AIM-9X / SLAM-ER | PL-15 / PL-10 等 | 美系弹药实战验证较多,中系弹药进步快但缺乏实战验证 |
| 后勤 | 受制于美方供应链 | 自主生产 | 这是根本差异:中方战损可补充,台湾战损难以补充 |
**分析如何用此对比**:
1. 在「单机 vs 单机」的叙事中,F-16V 不落下风;但在「体系 vs 体系」与「消耗战」的叙事中,台湾处于绝对劣势。
2. 北京的威慑叙事强调「数量与体系优势」,台北的威慑叙事强调「质量与防御优势」。两者都在挑选对自己有利的指标。
3. F-16V 的威慑力不在于「打赢」,而在于「让攻击方付出不可接受的代价」——这是不对称威慑的核心逻辑。
---
## 豪猪战略辩论中的 F-16V
详见 `narratives/us_taiwan/porcupine_strategy.md`。
### F-16V 作为「大型平台」的典型
在豪猪战略的辩论光谱上,F-16V 是「大型、昂贵、脆弱」平台的代表:
**豪猪纯粹主义者的批判**:
- 80 亿美元可买 8000 枚标枪飞弹,或 4000 枚海马士火箭弹
- 机场是固定的,无法机动,第一波就被毁
- 飞行员培养需数年,战损无法补充
- 结论:应放弃昂贵战机,全力投资机动飞弹与无人机
**传统防卫者的反驳**:
- 没有制空权,机动飞弹车也是空军的猎物
- F-16V 可执行反舰、反登陆、纵深打击,是飞弹无法替代的多用途平台
- 放弃空军等于放弃主权象征与民心士气
- 结论:豪猪战略是「失败主义」的包装
### F-16V 在辩论中的「妥协位置」
实际上,F-16V 案的通过反映了**美台双方的妥协**:
- 美方批准 F-16V,但对 F-35 坚决说不,并在其他品项上强推不对称武器(如 2022 年的大批飞弹军售)
- 台方接受 F-16V 作为「次佳选择」,并被迫在预算中同时容纳传统与不对称两种路线
**分析含义**:
理解台湾当前的国防结构,不能只看「豪猪战略」的论述,必须看**预算的实际分配**。F-16V 占据了预算的大饼,使得台湾在名义上接受豪猪战略,在实际上仍维持传统兵力结构。这种**「话语上的不对称 vs 实践上的全谱系」**是台湾国防政策的核心张力。
---
## 未来叙事:F-35 的幽灵
### F-35 议题的多重可能
关于台湾是否应争取 F-35,存在三种叙事,且皆未定论:
**叙事一:「隐形代差弥补」论**
- 主张:只有 F-35 才能对抗 J-20,否则制空权将完全丧失
- 逻辑:F-16V 只是过渡,最终必须进入第五代
- 障碍:美方不可能卖(政治红线、技术保密)
**叙事二:「无人机替代」论**
- 主张:未来空战是无人机的时代,不需要昂贵的有人隐形战机
- 逻辑:将 F-35 的预算转移至大量忠诚僚机与攻击无人机
- 障碍:技术尚未成熟,且无人机仍需有人机(如 F-16V 双座型)指挥
**叙事三:「务实放弃」论**
- 主张:台湾永远拿不到 F-35,应务实投资防空与反舰飞弹
- 逻辑:接受制空权丧失的现实,将资源全投入「拒止登陆」
- 障碍:政治上难以接受,等同放弃空防主权
### F-35 议题的分析含义
1. **F-35 是「信任温度计」**:每当美台关系升温,台湾内部就会出现「美方可能售台 F-35」的期待;每当关系降温,此议题即沉寂。分析者不应将 F-35 讨论视为单纯的军事需求,而应视为**对美信任度的指标**。
2. **F-16V 双座型的伏笔**:10 架双座型的配置,隐含了台湾在 F-35 无望下,试图以 F-16V 作为「无人机控制母机」的替代路径。这可能是台湾空军的务实出路,也可能是下一代战机辩论的折衷点。
3. **2028 选举的潜在议题**:若中方 J-20 部署持续增加,KMT 或 DPP 候选人是否将「争取 F-35」列入政见,将是测试其国防路线与对美关系定位的关键指标。
---
## 关联分析
### 关联一:与美对台军售动态的结构性连结
详见 `narratives/us_taiwan/arms_sales_dynamics.md`。
- 功能:提供事实层与脉络层
- F-16V 案是 2019 年军售扩张期的标志性事件,理解此案的时间点(川普时期、美中贸易战)与美方「节奏与品项」的调整,必须置于军售动态的整体框架中。
- 此案也是「品项升级」(从防御性转向具打击潜力)的典型案例。
### 关联二:与台湾空军的组织命运连结
详见 `entities/organizations/roc_air_force.md`。
- 功能:提供结构层与用于交叉验证
- F-16V 决定了空军未来 20 年的兵力结构与组织文化。空军的预算争取、人才招募、战术发展,皆围绕此一核心平台。
- 交叉验证:若出现「空军预算遭排挤」或「飞行员流失」的新闻,应回溯检视 F-16V 的维持成本是否成为结构性负担。
### 关联三:与豪猪战略辩论的张力连结
详见 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:用于对照与提供内在矛盾
- F-16V 是豪猪战略的「反面教材」或「必要前提」,取决于分析者的立场。
- 分析时,应将 F-16V 视为豪猪战略辩论中的「常数」——无论论述如何,此平台已存在且已投入巨资,后续决策只能在既成事实上调整。
### 关联四:与北京反应类型学的互动
- 功能:提供脉络层
- 2019 年 F-16V 案批准后,北京的反应模式:官方强烈抗议(第二层)、制裁相关企业(第三层),但未触发大规模军演(第五层)。
- 这显示北京对「4.5 代战机军售」的红线反应已有惯例化趋势,除非未来跨越至 F-35,否则反应可能停留在既定层级。
- 详见 `primitives/frameworks/beijing_response_typology.md`(占位)。
### 关联五:与疑美论的复杂共生
- 功能:提供脉络层与用于交叉验证
- F-16V 案是疑美论的「双面刃」:
- 支持军售 = 美方仍支持台湾(反疑美论)
- 美方只给 F-16V 不给 F-35 = 美方仍有限制(疑美论)
- 交付延迟 = 美方不可靠(疑美论)
- 分析时应注意:同一事件被不同叙事挑选性引用。
### 关联六:与国防预算排挤效应的因果连结
- 功能:提供结构性命题
- 80 亿美元的承诺,意味着未来十年的国防预算中,有相当比例被「锁定」用于支付此案。
- 这直接排挤了其他军种(如陆军的不对称武器采购、海舰的维护)的预算空间。
- 分析台湾国防政策时,不能只看「买了什么」,要看「因为买这个,所以没买什么」。
---
## 常见的分析陷阱
### 陷阱一:将 F-16V 等同于制空权的恢复
F-16V 提升了单机战力,但不等于恢复了台海制空权。制空权是体系产物(预警、指挥、电战、补给)。将单机性能提升等同于战略优势恢复,是技术决定论的谬误。
### 陷阱二:在豪猪辩论中选边
将 F-16V 视为「完全必要」或「完全浪费」皆是简化。它是政治妥协的产物,兼具威慑价值与资源排挤风险。分析者应呈现其双重效应,而非加入其中一方阵营。
### 陷阱三:忽略预算的「锁定效应」
80 亿美元不是一次性支出,而是长达十多年的分期付款与后续维持费。分析台湾国防选项时,若不考虑此预算已被锁定,会高估台湾购买其他不对称武器的财务自由度。
### 陷阱四:低估交付延迟的政治影响
军购交付延迟(如 2023 年首架交机延至 2024 年)不仅是技术问题,更会被政治化。延迟会被疑美论者解读为「美方不可靠」,也会被北京解读为「台方战力空窗期」。分析时应将延迟视为政治事件,而非单纯的后勤问题。
### 陷阱五:假设 F-16V 在战时能维持高妥善率
和平时期的妥善率(约 70-80%)在战时将急剧下降。战损、跑道受损、零件耗尽、人员伤亡,都会使实际升空架次远低于纸面数字。以和平时期的数据推算战时战力,会严重高估威慑效果。
### 陷阱六:将 F-35 议题视为纯粹的军事决策
台湾能否获得 F-35,从来不是军事需求问题,而是政治红线问题。分析者若只从「军事需要」论证台湾应买 F-35,将忽略美方的政治计算与北京的反应门槛,导致推论失准。
---
## 仍不确定的部分
- **F-16V 在实战中的生存率**:取决于中方第一波打击的精准度、台湾战力保存的效率、以及跑道抢修能力。这些变量目前仅有兵棋推演的假设,无实战验证。
- **F-16V 挂载长程弹药的实战效用**:SLAM-ER 等武器赋予台湾纵深打击能力,但战时能否穿透中方防空网并成功发射,仍高度不确定。此举的政治红线(是否跨越「先制打击」门槛)也未有定论。
- **下一代战机的政治窗口**:2028 年后,美方是否会在 F-35 售台问题上松口,取决于美中关系走向、中方军事压力强度、以及台湾内部对不对称战略的接受度。目前无明确趋势。
- **无人机整合的进展速度**:F-16V 双座型作为无人机控制母机的概念,目前仍在理论阶段。其技术成熟度与战术可行性,将在未来 3-5 年内见真章。
- **中方 J-20 的实际数量与战力**:中方的军事透明度低,J-20 的实际部署数量、隐形效能与战备率,外界评估差异甚大,直接影响 F-16V 的威慑评估。
---
## 与其他文件的关系
- 美对台军售动态:`narratives/us_taiwan/arms_sales_dynamics.md`(提供 F-16V 案在军售历史中的定位与政治背景)
- 台湾空军:`entities/organizations/roc_air_force.md`(提供 F-16V 的使用军种结构与组织文化脉络)
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md`(提供 F-16V 所在的战略辩论框架与不对称论述)
- 疑美论:`narratives/cross_strait/skepticism_of_us.md`(提供 F-16V 延迟与限制被诠释为疑美论证据的脉络)
- 台湾关系法:`primitives/concepts/taiwan_relations_act.md`(提供 F-16V 军售的法律义务基础)
- 战略模糊:`primitives/concepts/strategic_ambiguity.md`(提供 F-16V「升级但不跨越红线」的政策逻辑基础)
- 北京反应类型学:`primitives/frameworks/beijing_response_typology.md`(提供预测北京对 F-16V 案反应的分析框架)
---
## 一句话浓缩
> F-16V 是台湾传统制空权叙事的**最后支柱**,也是美台在「不对称战略」与「传统战力」之间妥协的**具体结晶**。
> 它的政治意义远超其战术价值——既是跨越党派争取到的「安全保证符号」,也是预算排挤与生存率争议的「结构性强辩场」。
> 分析 F-16V,不是算它能不能打赢 J-20,而是看它如何**锚定台湾的国防身份、预算分配与对美依赖**。
---
last_updated: 2025-05-19
@@ -0,0 +1,404 @@
# 海馬士(HIMARS)多管火箭系統
---
**類型**: 政治敘事
**當前角色**: 台灣陸軍遠程精準打擊核心 / 豪豬戰略的攻勢防禦矛頭 / 美台紅線管理的標的物
**出生時間**: 2020(川普政府宣布軍售)/ 2024-2027(預估交運與成軍期)
**何時調用此檔案**: 分析台灣不對稱防衛戰力建構;評估美台軍售中的「攻勢武器」紅線爭議;解讀「源頭打擊」論述在台灣內部的政治效應;觀察烏克蘭戰爭經驗對台海防衛敘事的移植與變形;評估解放軍兩棲登島後勤節點的脆弱度;分析陸軍在國防預算與戰略地位上的結構性轉變
---
## 一句話濃縮
海馬士是台灣「防禦性豪豬」身上的「攻勢性利刺」——它同時承載了「不對稱拒止」與「源頭反擊」兩種互有張力的戰略敘事,而其飛彈射程(ATACMS)的邊界,正是美國衡量「賦予台灣多少反擊能力」與「避免過度刺激北京」這條紅線的精準量尺。
---
## 事實層:規格、進程與結構
### HIMARS 的核心規格與政治意涵
HIMARS(High Mobility Artillery Rocket System)是輪式機動多管火箭系統。理解其規格不需深入彈道學,而需掌握**哪些規格改變了台海威懾的政治計算**:
**關鍵規格與政治意涵**:
| 規格 | 事實內容 | 政治與戰略意涵 |
|---|---|---|
| 機動性 | 5噸6x6輪型底盤,可由C-130運輸 | 高度戰略機動性,符合「分散、機動、存活」的不對稱原則;能迅速變換陣地,降低被鎖定風險 |
| 彈藥模組化 | 可發射GMLRS(導向火箭)與ATACMS(陸軍戰術飛彈) | 同一平台兼具「戰術壓制」與「戰略打擊」潛力,彈藥選擇即為政治信號的展現 |
| GMLRS射程 | 約 70-85 公里 | 覆蓋海峽中線以東及部分近岸水域,用於反登陸與灘岸殲滅,屬「無爭議的防禦性武器」 |
| ATACMS射程 | 約 300 公里 | 從台灣本島發射可涵蓋福建沿海(廈門、福州、泉州)及部分內陸軍事設施,跨越了「防禦」的模糊界線,觸碰「源頭打擊」的紅線 |
| 精準度 | 圓機率誤差(CEP)約 5-10 公尺 | 能精準打擊點目標(指揮所、雷達站、港口設施),使台灣具備「斬首」與「癱瘓」的實質選項,而不只是面積轟炸 |
| 裝填速度 | 彈藥模組整體吊裝,約 5-10 分鐘 | 「打了就跑」(Shoot and Scoot)戰術的物質基礎,決定了在解放軍反砲兵火力下的存活率 |
**分析含義**:HIMARS 的核心邏輯是「防禦性載台與攻勢性彈藥的結合」。輪型底盤讓它看起來像防禦性的機動砲兵,但 ATACMS 讓它具備戰略打擊能力。這種「低調載台、高調彈藥」的組合,是美方軍售的典型妥協藝術——給台灣足以威懾的長拳,但將其裝在不起眼的卡車上,以管理北京的政治反應。
### 台灣採購數量與時程
**事實內容**:
- 首次軍售:2020 年 10 月,川普政府宣布售台 11 套 HIMARS 系統,含 64 枚 ATACMS 與 135 枚 GMLRS,總價約 4.36 億美元。
- 擴增採購:2022 年,台灣增購 18 套,使總數達到 29 套。
- 交付時程:原預計 2024-2026 年間交付,但受烏克蘭戰爭排擠美國軍工產能影響,部分交付可能延遲至 2027 年。
- 使用單位:陸軍砲兵指揮部,預計編成遠程打擊連/營。詳見 `entities/organizations/roc_army.md`。
**脈絡層**:
29 套系統的數量,在軍事上不足以對中國東南沿海形成飽和打擊,但在政治上足以構成「可信的懲罰威懾」。這是一個典型的「象徵性實質戰力」——數量不多,但每一套都具備改變戰局的潛力。增購決策發生在裴洛西訪台後的圍台軍演期間,其政治信號(台灣不受脅迫而退縮)大於純軍事需求。
**分析如何用此事實**:
1. 29 套 HIMARS 無法覆蓋所有登陸灘頭,分析時不應將其視為「反登陸主力」,而應視為「關鍵節點打擊器」——專打指揮艦、集結港與機場。
2. 交付延遲不僅是後勤問題。2027 年前後是美方評估解放軍具備全面犯台能力的「窗口期」,HIMARS 若未能在此窗口期前形成戰力,其威懾價值將大打折扣,這會成為疑美論的操作素材。
### ATACMS 飛彈的搭配與射程問題
**事實內容**:
- 台灣採購的 ATACMS(M57 系列)射程約 300 公里。
- 從台灣西岸發射,可涵蓋福建全省、廣東東部及浙江南部。
- 主要威脅目標:廈門港、福州長樂機場、龍田軍用機場、東部戰區陸軍指揮部、沿海雷達站與飛彈陣地。
- 美方目前未售台更長射程的精準打擊飛彈(PrSM,射程 500+ 公里)。
**脈絡層**:
ATACMS 是台灣陸軍有史以來射程最遠的打擊武器,超越了「防衛固守」的地理界限,進入「重層嚇阻」的戰略範疇。300 公里射程的意義在於:它迫使解放軍必須將集結地、後勤樞紐與指揮所向內陸後撤。這種「後撤效應」直接增加了兩棲登陸的後勤複雜度與風險。
**分析如何用此事實**:
1. ATACMS 的實際作用不在於「開戰首日摧毀多少目標」,而在於「強迫敵方改變部署」。分析時應計算「後撤距離」對解放軍登陸波次間隔的延遲效應,而非單純計算殺傷半徑。
2. 美方售台 ATACMS,打破了過去「不提供能打到大陸內陸的攻勢武器」的默契。這是美方紅線管理的微調,也是北京極度關注的指標。分析未來軍售品項時,ATACMS 的批准是重要的先例。
---
## 烏克蘭參照:經驗素材的移植與變形
烏克蘭戰爭是 HIMARS 從「帳面規格」轉變為「神話敘事」的關鍵催化劑。但分析者必須嚴格區分「經驗素材」與「預測模型」——烏克蘭的經驗不能直接套用於台海。
### 烏克蘭戰場上的 HIMARS 效應
**事實內容**:
- 2022 年夏季,烏克蘭獲得 HIMARS 後,成功打擊俄軍前線後方的彈藥庫、指揮所與後勤節點。
- 迫使俄軍將彈藥庫後撤至 HIMARS 射程外(80-90 公里以上),嚴重降低俄軍前線砲兵的補給效率。
- 俄軍隨後適應:加強電子干擾(導致 GMLRS 命中率下降)、部署假目標、利用衛星間隙機動。
- 烏克蘭的 HIMARS 從未獲得 ATACMS(直至 2023 年底才少量提供),且數量始終有限,無法形成戰略級別的飽和打擊。
**脈絡層**:
HIMARS 在烏克蘭的「高光時刻」是短暫的。它更像是一種「戰術級的摩擦製造器」,而非決定性武器。俄軍的適應期約為 3-6 個月。一旦對手調整部署與戰術,HIMARS 的邊際效用便遞減。
**分析如何用此事實**:
1. **破除常見簡化**:將 HIMARS 等同於「扭轉戰局的銀彈」是錯的。它在烏克蘭的效力建立在俄軍初期後勤極度混亂的基礎上。解放軍若從俄軍經驗中學習,戰時後勤節點將預先疏散,HIMARS 在台海的「首擊紅利」可能遠低於烏克蘭。
2. 烏克蘭是陸地相連的戰場,目標豐富且後撤空間有限;台海是跨海作戰,解放軍的集結必須在港口與灘頭進行,這些是無法移動的「硬節點」。因此,HIMARS 在台海的打擊邏輯應從「打擊彈藥庫」轉向「癱瘓港口與登陸船團」。
### 烏克蘭經驗的台海過濾:地形與 ISR 的根本差異
| 維度 | 烏克蘭戰場 | 台海戰場 | 分析含義 |
|---|---|---|---|
| 地形 | 廣袤陸地,有森林與城鎮掩護 | 狹長島嶼,平原窄小,缺乏縱深 | 台灣的 HIMARS 機動空間遠小於烏克蘭,隱蔽困難 |
| 戰場監視(ISR) | 俄軍無人機與衛星覆蓋有限 | 解放軍擁有高密度衛星、無人機與雷達 | 解放軍對台灣本島的 ISR 覆蓋遠優於俄軍,HIMARS 的「打了就跑」窗口極短 |
| 彈藥補給 | 透過北約陸路源源不絕輸入 | 戰時海空封鎖,幾乎無法補充 | 台灣的 HIMARS 是「消耗性資產」,打完即止,無法消耗戰 |
| 打擊目標 | 陸上彈藥庫與指揮所 | 兩棲船團、港口設施、沿海機場 | 目標性質不同,台海更依賴反艦與破壞基礎設施 |
**分析含義**:將「烏克蘭 HIMARS 神話」移植到台灣,是一種危險的認知偏誤。台灣的 HIMARS 更像是一把「單發狙擊槍」,而非「全自動機槍」。它必須在極短的時間窗口內,精準命中無法替換的高價值目標(如登陸艦或港口吊車),否則其戰略價值將迅速歸零。
---
## 敘事層:HIMARS 的多重政治詮釋
同一套 HIMARS,在不同政治行動者的敘事中扮演截然不同的角色。
### 敘事一:「豪豬戰略」的完美結晶
**核心命題**:HIMARS 是不對稱作戰的典範——機動、精準、致命,完美契合台灣「讓侵略者付出慘痛代價」的防衛需求。
**論述邏輯**:
- 傳統大型平台(戰機、軍艦)在第一擊下生存率低,且造價昂貴
- HIMARS 價格相對低廉(單套約 350 萬美元),可分散部署,存活率高
- 其精準打擊能力讓台灣用少量彈藥即可癱瘓關鍵節點,符合「以小博大」邏輯
- 詳見 `narratives/us_taiwan/porcupine_strategy.md`
**支持者**:美方不對稱戰略推動者、台灣國防部部分決策層、國際智庫
**反對者視角**:此敘事忽略了 HIMARS 的彈藥(特別是 ATACMS)極其昂貴且數量有限,在缺乏補充的封鎖情境下,其「不對稱」優勢極易轉為「一次性」消耗。
### 敘事二:「源頭打擊」的政治兌現
**核心命題**:HIMARS 配備 ATACMS,代表台灣具備將戰火燒向敵方本土的能力,這是「防衛固守」向「重層嚇阻」轉型的實質證明。
**論述邏輯**:
- 純防禦無法嚇阻,必須讓對手知道攻擊台灣會招致本土反擊
- ATACMS 的 300 公里射程,讓台灣不再是被動挨打的靶子
- 這種反擊能力是維持民心士氣的關鍵——「我們有還手之力」
**支持者**:台灣執政黨(作為國防自主的政績)、陸軍高層、部分戰略鷹派
**反對者視角**:此敘事在國內是政治強心針,但在北京眼中是挑釁。具備「源頭打擊」能力可能降低北京動武的門檻,因為解放軍可能主張必須「先發制人」摧毀這些威脅。此外,29 套系統的實際反擊能力,是否足以構成「嚇阻」還是僅為「激怒」,存在爭議。
### 敘事三:「刺蝟台灣」的攻勢防禦升級
**核心命題**:HIMARS 的引進,標誌著台灣防衛構想從「灘岸決勝」向「擊敵於半渡」甚至「擊敵於集結地」的升級。
**論述邏輯**:
- 等敵軍登陸灘頭再反擊,傷害已造成
- HIMARS 允許台灣在敵軍集結於港口、或船團剛出海時即行打擊
- 這是防禦縱深的向外延伸,本質上仍是防禦,但手段具備攻勢特徵
**支持者**:台灣陸軍(爭取預算與戰略地位的論述)、部分美方戰略學者
**反對者視角**:此敘事模糊了防禦與進攻的界線。在實戰中,判斷敵軍是「集結」還是「演習」極度困難。若台方因誤判而以 ATACMS 攻擊集結中的解放軍,將構成事實上的「第一擊」,引發全面戰爭。
### 敘事四:「脆弱的高價值目標」
**核心命題**:HIMARS 在台灣狹小且被嚴密監視的環境中,可能成為解放軍首輪打擊的焦點,其存活率存疑。
**論述邏輯**:
- 解放軍深知 HIMARS 的威脅,戰時必以高密度衛星與無人機鎖定
- 台灣西岸適合 HIMARS 機動的道路網有限,且缺乏烏克蘭式的森林掩護
- 一旦開戰,HIMARS 發射車將是東部戰區火箭軍的首要獵殺目標
- 這是一種「高利潤高風險」的賭注,而非穩妥的不對稱資產
**支持者**:部分悲觀派軍事分析者、北京官方媒體(用於貶低台軍戰力)
**反對者視角**:此敘事低估了陸軍在偽裝、欺敵與機動戰術上的適應能力。HIMARS 的存活率取決於戰術素養而非單純地形,且解放軍的「殲滅」宣傳與其精準打擊能力之間存在落差。
---
## 深層結構:內在矛盾與結構性命題
### 矛盾一:「射程」與「情報」的依賴陷阱
HIMARS 的長射程(ATACMS 300km)在物理上賦予了台灣打擊大陸沿海的能力,但在實戰中,**射程的兌現完全依賴目標獲取(ISR)能力**。
**內在矛盾**:
- 台灣自身的雷達與偵察機,受限於地球曲率與對方防空網,難以持續掌握內陸 300 公里處的動態目標
- 要充分發揮 ATACMS 的效能,隱含著需要美方提供即時的衛星或戰略偵察情報
- 這意味著 HIMARS 的「戰略打擊」功能,在結構上與美國的情報共享深度綁定
- 若美方在危機中選擇不提供即時目標數據(以避免被視為共同交戰),ATACMS 將只能打擊已知座標的固定目標(如建築物),而無法打擊機動目標(如指揮車或機動飛彈發射車)
**分析如何用此矛盾**:
分析者不應將 HIMARS 視為台灣可獨立操作的戰略資產。購買 HIMARS 是購買硬體,但硬體的效用取決於軟體(情報網路)。這創造了一種結構性依賴:**台灣的「源頭打擊」能力,實際上是美國情報網路的延伸**。這既是保證(美國若願意提供情報),也是枷鎖(美國若不提供,長程飛彈即為瞎子)。
### 矛盾二:陸軍的「戰略化」與軍種政治
HIMARS 隸屬陸軍,卻執行過去屬於空軍(深入打擊)或海軍(反艦)的戰略任務。這改變了台灣軍種間的權力平衡。
**內在矛盾**:
- 傳統上,台灣陸軍是「防衛固守」的執行者,角色被動;空軍與海軍則是「有效嚇阻」的載體
- HIMARS 讓陸軍首次掌握最長的打擊之矛,成為「重層嚇阻」的關鍵軍種
- 這導致陸軍在預算分配與戰略話語權上大幅提升,可能引發軍種間的資源競爭
- 詳見 `entities/organizations/roc_army.md`
**結構性命題**:
**HIMARS 是台灣陸軍從「消耗性絆索」向「決定性打擊者」身份轉型的結構性錨點。** 只要陸軍掌握這項長程精準火力,它在國防體系中的地位就從「守灘頭」上升為「控節點」。這意味著未來任何試圖削減陸軍遠程火力預算的嘗試,都將面臨軍種政治的強烈抵抗。
### 矛盾三:「防衛性武器」的政治修辭與「攻勢本質」
**內在矛盾**:
- 美方與台方在軍售宣傳上,始終將 HIMARS 定調為「防衛性武器」,強調其「不對稱防禦」性質
- 但 ATACMS 的射程與精準度,使其具備對敵方指揮中樞與基礎設施的先制打擊能力
- 北京則將其定性為「進攻性武器」,指責美方武裝台灣進行挑釁
- 同一系統,在國際法與政治修辭上存在不可調和的定義衝突
**分析如何用此矛盾**:
分析軍售案時,不要陷入「這算不算攻勢武器」的定義之爭,而應觀察**此武器的存在如何改變各方的行為計算**。HIMARS 的「攻防模糊性」本身就是威懾——它讓北京不確定台灣是否會在危機升級時進行先制打擊,這種不確定性增加了北京決策的成本。
---
## 對解放軍的威脅評估與目標邏輯
HIMARS 對解放軍的威脅,不在於殲滅多少兵力,而在於**破壞兩棲作戰的時間表與後勤節奏**。
### 對集結地與港口的威脅
**事實與邏輯**:
- 兩棲登陸需要大量人員與物資在港口集結,且登陸艦必須靠泊裝載
- 這些港口(如廈門、寧德)是無法移動的固定目標
- HIMARS 可在登艦階段以子母彈或高爆彈攻擊港口設施與集結部隊,造成混亂與延遲
- 港口吊車、油庫若被摧毀,將嚴重影響重型裝備的裝載速度
**分析含義**:攻擊港口是「拒止性威懾」的核心。HIMARS 不需要殲滅整個集團軍,只需讓登陸艦無法按時出航,打亂潮汐窗口與多波次登陸的協同,即可使整個作戰計畫崩潰。
### 對機場與航空兵的威脅
**事實與邏輯**:
- 福建沿海的軍用機場(如龍田、惠安)距台灣極近,是奪取制空權的前進基地
- ATACMS 可攻擊機場跑道、停機坪與油彈庫
- 雖無法長期癱瘓機場(跑道可修復),但能在關鍵時段(如登陸船團航渡時)壓制起降,為台灣爭取制空反擊的窗口
**分析含義**:HIMARS 對機場的威脅是一種「時間交換」——用飛彈換取數小時的空防喘息空間。這在戰術上是划算的,但前提是飛彈數量足夠且不被攔截。
### 對登陸船團的威脅
**事實與邏輯**:
- GMLRS 雖射程僅 70 公里,但足以涵蓋海峽中線以東的航渡區域
- 若能整合無人機與海軍雷達提供目標座標,HIMARS 可對登陸艦進行飽和攻擊
- 多管火箭的覆蓋面積大,對甲板薄弱的登陸艦威脅顯著
**分析含義**:這是 HIMARS 最具「不對稱效益」的應用場景。用相對便宜的火箭彈攻擊昂貴且載員眾多的登陸艦,是典型的成本強加策略。
---
## 豪豬戰略辯論中的 HIMARS
詳見 `narratives/us_taiwan/porcupine_strategy.md`。
### HIMARS 作為「戰略不對稱」的標竿
在豪豬戰略的辯論光譜上,HIMARS 佔據了一個獨特的位置:它既是不對稱武器(機動、廉價相對於戰機),又具備戰略打擊能力(ATACMS)。
**豪豬純粹主義者的質疑**:
- ATACMS 單價極高(約 150-200 萬美元/枚),台灣採購數量有限(約 64 枚)
- 這種「昂貴的不對稱武器」在戰時極易耗盡,不符合豪豬戰略「大量、廉價、持久」的原則
- 64 枚 ATACMS 在高強度衝突中可能僅夠數波齊射,之後 HIMARS 即退化為射程 70 公里的普通火箭砲
**傳統防衛者的擁護**:
- HIMARS 是陸軍版「長程精準打擊」,填補了放棄攻勢空防後的火力空白
- 它讓台灣在不依賴戰機穿越海峽的情況下,仍能威脅敵方本土
- 這是維持「重層嚇阻」信譽的最低成本方案
### HIMARS 在辯論中的「橋樑位置」
HIMARS 案反映了美台雙方在「純粹不對稱」與「傳統嚇阻」之間的妥協:
- 美方願意售台長程飛彈,但控制數量與射程(300km 而非 500+km)
- 台方接受了數量限制,但藉此取得了「源頭打擊」的政治象徵
**分析含義**:
HIMARS 是豪豬戰略與傳統嚇阻的「最大公約數」。分析台灣國防結構時,不應將 HIMARS 歸類為純粹的不對稱資產,而應視其為**「戰略級不對稱武器」**——它用不對稱的載台,執行戰略級的任務。這種定位使其在預算分配與戰術運用上,都處於內部爭議的焦點。
---
## 未來敘事:更長射程的幽靈
### PrSM 與下一代遠程打擊的議題
關於台灣是否應爭取射程更遠的精準打擊飛彈(如美軍正在部署的 PrSM,射程 500+ 公里),存在三種敘事:
**敘事一:「縱深擴張」論**
- 主張:300 公里僅及福建沿海,解放軍後方基地毫髮無傷;必須具備 500+ 公里打擊能力,才能真正威脅東部戰區深層指揮與後勤
- 邏輯:隨著解放軍防空網密化,發射陣地需更遠離海岸,需更長射程補償
- 障礙:美方極不可能售台此類「縱深打擊」武器,因這跨越了「台海無關聯打擊能力」的紅線
**敘事二:「數量優先」論**
- 主張:不應追求更長射程,而應將預算用於購買更多 ATACMS 與 GMLRS
- 邏輯:現有 64 枚 ATACMS 遠遠不足,戰時消耗極快;「足夠的 300km」比「稀少的 500km」更具威懾力
- 障礙:國防工業產能有限,且台灣預算排擠嚴重
**敘事三:「國造取代」論**
- 主張:台灣應自主研發更長射程的飛彈(如增程型雄昇飛彈),不依賴美方
- 邏輯:國造不受美方法規與政治限制,可突破射程籠罩
- 障礙:技術門檻高、研發周期長、且同樣面臨北京壓力與試射場地受限
### 射程議題的分析含義
1. **射程是「信任溫度計」**:美方是否願意售台更長射程的飛彈,是檢驗美台軍事互信與美方紅線調整的最精確指標。詳見 `narratives/us_taiwan/arms_sales_dynamics.md`。
2. **「國造增程」的虛實**:台灣內部關於「自製長程飛彈」的宣傳,常掩蓋了關鍵零組件(如導引晶片、固推藥柱)仍受制於人的事實。分析者應區分「組裝能力」與「自主量產能力」。
3. **2028 選舉的潛在議題**:若解放軍持續向內陸疏散高價值目標,台灣內部是否會出現「射程不足致無法嚇阻」的辯論?候選人對「源頭打擊」的承諾是否會升級為「縱深打擊」?這將是測試國防路線鷹鴿光譜的關鍵。
---
## 關聯分析
### 關聯一:與美對台軍售動態的結構性連結
詳見 `narratives/us_taiwan/arms_sales_dynamics.md`。
- 功能:提供事實層與脈絡層
- HIMARS 案是 2020 年川普政府大規模軍售包的標誌性事件,從「防禦性平台」轉向「具反擊潛力的精準火力」。
- ATACMS 的批准是美國對台軍售「品項升級」的里程碑,後續軍售(如 2022 年的魚叉飛彈)皆延續此邏輯。
- 交叉驗證:若出現美方暫緩或拒絕售台特定型號 ATACMS 的新聞,應回溯檢視美中關係是否處於修復期。
### 關聯二:與台灣陸軍的組織命運連結
詳見 `entities/organizations/roc_army.md`。
- 功能:提供結構層與用於交叉驗證
- HIMARS 決定了陸軍未來十年的火力核心地位。陸軍的預算爭取、人才招募與戰術發展,皆圍繞此一系統。
- 交叉驗證:若出現「陸軍遠程火力編裝擴增」或「砲兵軍官晉升比例上升」的趨勢,應視為 HIMARS 效應向組織文化滲透的指標。
### 關聯三:與豪豬戰略辯論的張力連結
詳見 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:用於對照與提供內在矛盾
- HIMARS 是豪豬戰略的「攻勢邊界」——測試不對稱作戰在何種程度上可以容忍攻勢能力。
- 分析時,應將 HIMARS 視為豪豬戰略內部「防禦純粹派」與「嚇阻務實派」爭論的焦點案例。
### 關聯四:與北京反應類型學的互動
- 功能:提供脈絡層
- 2020 年 HIMARS 軍售後,北京的反應模式:官方強烈抗議(第二層)、制裁相關企業(第三層),並加大對台軍事施壓(第四層)。
- 北京將 ATACMS 視為「踩線」行為,但未觸發大規模軍演(第五層),顯示其對「售台長程飛彈」的紅線反應仍處於可控範圍,但已較 F-16V 升級案更為強烈。
- 詳見 `primitives/frameworks/beijing_response_typology.md`(占位)。
### 關聯五:與疑美論的複雜共生
- 功能:提供脈絡層與用於交叉驗證
- HIMARS 案是疑美論的「雙面刃」:
- 售台 ATACMS = 美方支持台灣反擊能力(反疑美論)
- 僅售 64 枚且延遲交付 = 美方留一手,不讓台灣具備持久戰力(疑美論)
- 彈藥受控 = 台灣無法自主決定打擊時機與目標(疑美論)
- 分析時應注意:同一軍售案在台灣內部被不同政治陣營選擇性引用,作為「美國可靠」或「美國不可靠」的證據。
### 關聯六:與烏克蘭戰爭經驗移植的風險
- 功能:提供結構性命題
- 烏克蘭戰爭是 HIMARS 神話的源頭,但台海環境與烏克蘭存在根本差異(地形、補給、ISR 密度)。
- 分析台灣國防政策時,必須識別哪些決策是基於「烏克蘭經驗的過度類比」,哪些是基於「台海實際的結構計算」。
- 詳見 `narratives/us_taiwan/ukraine_reference_discourse.md`(占位)。
---
## 常見的分析陷阱
### 陷阱一:將 HIMARS 等同於台海勝利的保證
受烏克蘭戰爭初期效應影響,極易將 HIMARS 神話化。HIMARS 是高效率的節點破壞器,但無法獨力改變兩岸軍力總量差距。將單一武器系統的戰術成功等同於戰略勝利,是技術決定論的謬誤。
### 陷阱二:忽略彈藥的「消耗性本質」
分析時常計算「台灣有 29 套 HIMARS」,卻忽略「台灣只有 64 枚 ATACMS」。在高強度衝突中,64 枚飛彈可能在數天內耗盡。以發射器數量推算持續戰力,會嚴重高估威懾持久度。
### 陷阱三:假設 HIMARS 在台灣的存活率與烏克蘭相當
烏克蘭的 HIMARS 在北約情報支援與廣袤地形掩護下存活率較高。台灣本島面積有限且處於解放軍不間斷監視下,發射後的陣地轉移窗口極短(可能僅有數分鐘)。以烏克蘭經驗推算台灣 HIMARS 的存活率,會低估其脆弱性。
### 陷阱四:將「源頭打擊」能力等同於「先制打擊」意圖
具備 ATACMS 能力,不代表台灣會在危機中主動攻擊大陸沿海。北京常將「能力」與「意圖」混為一談,以此作為升級衝突的藉口。分析者若跟隨此邏輯,會誤判危機升級的動力學——事實上,台灣的防衛準則仍以防禦為主,源頭打擊僅在確認敵軍集結且攻勢迫在眉睫時才會啟動。
### 陷阱五:低估「情報依賴」的戰略後果
ATACMS 的長射程在缺乏即時目標情報下毫無意義。分析者若假設台灣可獨立發揮 HIMARS 的全部效能,將忽略其對美國 ISR 體系的結構性依賴。這種依賴意味著 HIMARS 的戰略價值受制於美國的危機決策,而非台灣的單方面意志。
### 陷阱六:將未來增程飛彈視為必然趨勢
假設台灣未來必然獲得射程 500 公里以上的飛彈,是忽略了美方紅線管理的持續性。ATACMS 的批准已屬突破,進一步放寬射程限制需極高的政治互信與戰略需求驅動,目前並非必然。
---
## 仍不確定的部分
- **HIMARS 在台灣的實際存活率**:取決於陸軍的偽裝欺敵戰術、機動路線規劃、以及解放軍「發現即摧毀」的殺傷鏈閉合速度。這些變量僅有兵棋推演的假設,無實戰驗證。
- **ATACMS 穿透解放軍防空網的概率**:福建沿海是解放軍防空密度最高的區域之一,ATACMS 雖具備一定機動能力,但面對多層次防空系統(S-400、HQ-9 等)的攔截率仍高度不確定。
- **美國戰時情報共享的實際級別**:美方是否會在危機中向台灣提供即時衛星目標數據,涉及複雜的法律與政治風險評估,目前無明確承諾,僅有模糊的「強化合作」宣示。
- **陸軍的戰術適應速度**:HIMARS 是全新作戰概念,陸軍能否從傳統「陣地砲兵」思維轉向「高機動游獵砲兵」思維,需要數年時間驗證。
- **解放軍的後勤適應性**:解放軍是否已根據 HIMARS 的威脅,將關鍵後勤與指揮節點向內陸疏散?若是,HIMARS 的打擊邏輯需重新修正。
- **彈藥補充的可能性**:戰時台灣能否獲得 ATACMS 與 GMLRS 的補充?在封鎖情境下幾乎不可能,但若美國選擇強力介入,通過空運補充少量關鍵彈藥並非完全排除。此變量直接決定 HIMARS 是「一次性武器」還是「持久威懾」。
---
## 與其他文件的關係
- 美對台軍售動態:`narratives/us_taiwan/arms_sales_dynamics.md`(提供 HIMARS 案在軍售歷史中的定位與政治背景)
- 台灣陸軍:`entities/organizations/roc_army.md`(提供 HIMARS 的使用軍種結構與組織文化脈絡)
- 豪豬戰略:`narratives/us_taiwan/porcupine_strategy.md`(提供 HIMARS 所在的戰略辯論框架與不對稱論述)
- 疑美論:`narratives/cross_strait/skepticism_of_us.md`(提供 HIMARS 數量限制與交付延遲被詮釋為疑美論證據的脈絡)
- 北京反應類型學:`primitives/frameworks/beijing_response_typology.md`(提供預測北京對 HIMARS/ATACMS 軍售反應的分析框架)
- 戰略模糊:`primitives/concepts/strategic_ambiguity.md`(提供 ATACMS 射程限制作為美方紅線管理工具的政策邏輯基礎)
---
## 一句話濃縮
> 海馬士是台灣「防禦性豪豬」身上的「攻勢性利刺」——它同時承載了「不對稱拒止」與「源頭反擊」兩種互有張力的戰略敘事。
> 它的政治意義遠超其戰術價值——ATACMS 的射程邊界,正是美國衡量「賦予台灣多少反擊能力」與「避免過度刺激北京」這條紅線的精準量尺。
> 分析 HIMARS,不是算它能不能摧毀廈門港,而是看它如何**重定義陸軍的戰略角色、暴露情報依賴的深層結構、並測試美台中三方的紅線彈性**。
---
last_updated: 2025-05-19
@@ -0,0 +1,367 @@
# 国造潜舰(IDS)
---
**类型**: 政治叙事
**当前角色**: 台湾国防自主核心象征 / 海军不对称威慑支柱 / 豪猪战略辩论焦点 / 对外技术整合测试案
**出生时间**: 2014(计划评估启动)/ 2019(原型舰开工)/ 2023(海鲲号下水)
**何时调用此档案**: 分析台湾海军战略转型与建军逻辑;评估国防自主的政治图腾意义与实质战力落差;解读「传统大平台 vs 不对称作战」的战略辩论;观察美台欧军技合作红线与多国供应链风险;预测2028选战国防预算与军购国造排挤效应;分析北京对台水下战力提升的反应模式
---
## 一句话浓缩
国造潜舰是台湾「以小博大」不对称威慑叙事的**最高仪式**,也是传统大平台在豪猪战略框架下最刺眼的**异类**——它既是蔡英文时期国防自主不可动摇的政治图腾,也是多国技术暗助与台湾内部资源排挤争议的**结构性强辩场**。
---
## 事实层:规格、进程与结构
### 国造潜舰的政治起点与源流
**事实内容**:
潜舰国造并非蔡英文政府的原创构想,其政治起点可追溯至2014年。当时马英九政府在面对海军现役剑龙级潜舰老旧、且向外采购(如美国)屡屡受挫的困境下,由国防部展开「潜舰国造可行性评估」。2016年蔡英文就任后,将「国机国造」与「潜舰国造」并列为核心国防自主政策,并于2017年正式编列预算启动。2019年5月,原型舰于台湾国际造船公司(台船)高雄厂区正式开工;2023年9月,首艘原型舰「海鲲号」(舷号SS-711)举行下水典礼;2024年初移交海军进行泊港测试(HAT)。
**脉络层**:
从2014年的评估到2023年的下水,这九年间跨越了马政府末期与蔡政府两任。此时间线揭示了IDS的推动并非单一政党的意志,而是台湾面对长期军购困境的**跨党派战略共识**,但在执行节奏与政治符号的运用上,则被蔡英文政府高度专属化。马政府的「评估」是试探底线,蔡政府的「开工」是政治宣示——两者反映了不同政党对「如何处理北京压力与美方限制」的策略差异。
**分析如何用此事实**:
1. 当在野党批评IDS是「绿营意识形态产物」时,分析者应指出其源头在蓝营执政期,这有助于剥离选举语言,还原政策延续性。
2. 2023年下水时程恰逢台湾大选前一年,这不仅是工程进度,更是政治时程的产物。分析时需区分「工程里程碑」与「政治里程碑」,下水仪式的政治展示意义大于战力形成意义。
### 海鲲号的核心规格与战略意涵
**事实内容**:
海鲲号属柴电动力潜舰,排水量约2500至3000吨级,采用X型尾舵设计。其战斗系统与声纳系统为主要关键技术(即所谓的「红区装备」),分别由美国洛克希德·马丁与雷神公司等美商主导提供;鱼雷管设计相容于美制MK-48重型线导鱼雷,可搭载潜射型鱼叉反舰飞弹。
**脉络层**:
海鲲号的规格并非台湾完全独立研发,而是典型的「系统整合」产物。船体设计与制造由台船主导,但内部的「大脑」(战斗系统)与「感官」(声纳)完全依赖美系技术,部分零组件据信亦有欧洲(如英国、韩国等)厂商的暗中参与。这种「台壳美心欧骨」的结构,是台湾在国际政治限制下所能取得的最优解,但也埋下了后续后勤维修与战时断链的深层风险。
**分析如何用此事实**:
1. 评估海鲲号战力时,不应仅看吨位与鱼雷数,而应关注「战斗系统整合成熟度」与「声纳在台海水文中的效能」。这两项才是决定其能否发挥不对称威慑的关键。
2. X型尾舵的设计意味着较佳的浅水机动性,这与台海周边尤其是西部海峡的浅水水文特性有关,反映了IDS的战术想定并非远洋决战,而是近海伏击。
### 原计划8艘的规模与预算结构
**事实内容**:
IDS整体计划包含1艘原型舰与7艘量产舰,共计8艘。原型舰预算约493亿新台币,全案(含8艘与后续弹药设施)预估总预算超过3000亿新台币,是台湾有史以来最昂贵的单一国防自主项目。
**脉络层**:
8艘的规模并非军事上的最优解,而是政治与预算的妥协结果。海军内部评估曾指出,要维持随时有2艘潜舰在航执行战备,至少需要8至10艘的规模(考虑轮修与训练)。然而,3000亿的预算规模已对年度国防预算造成极大排挤。量产舰的预算将分年编列,这意味着未来十余年,台湾国防预算的资本门支出将被IDS深度锁定。
**分析如何用此事实**:
1. 分析2028年后的国防政策时,IDS后续7艘的量产预算将是最大的财政变数。任何试图增加不对称武器(如机动飞弹、无人机)采购的规划,都必须先回答「钱从哪里来」,而IDS是最大的预算黑洞。
2. 8艘的规模在解放军日益增长的反潜能力面前,其边际威慑效应是否递减,是战略评估的盲区。分析者应避免线性推论(8艘比4艘强一倍),而应考量战场环境的变化。
### 国际技术合作:多国零件来源与红线管理
**事实内容**:
IDS的建造成功,关键在于突破了长达数十年的国际技术封锁。台湾透过驻外单位、民间中介与友好国家厂商的灰色地带合作,取得了战斗系统、声纳、柴油主机、潜望镜等关键装备。其中,美国的「营销核准证」(TA)是整个计划的法律基础,允许美商输出关键军规技术;欧洲部分厂商则利用民用规格或第三地转运的方式规避本国对台军售限制。前海军顾问郭玺在此过程中扮演了关键的民间掮客角色,但也因此卷入后续的政治争议。
**脉络层**:
IDS的国际技术合作,本质上是各方红线管理的产物。美国提供TA,是在「支持台湾自卫能力」与「不提供完整成品潜舰」之间画线;欧洲厂商的暗中参与,是在「商业利益」与「避免北京制裁」之间走钢丝。这种多国拼装的模式,使得IDS的供应链极度脆弱且不透明。
**分析如何用此事实**:
1. 当分析「国防自主」论述时,必须拆解其内涵。IDS的「自主」是「组装与船体建造的自主」,而非「技术源头的自主」。将IDS等同于「台湾独立造出潜舰」是政治宣传,将之贬为「只是拼装」则忽略了系统整合的极高难度。真实位置在两者之间。
2. 郭玺等中介角色的存在,反映了台湾在非正式军购管道中的高风险操作。分析者应关注合约透明度与后续维修保固的责任归属,这些是潜在的政治地雷。
### 成本与时程的争议
**事实内容**:
原型舰预算493亿新台币,但实际执行中面临物料上涨、疫情导致工期延误、以及厂商报价超支等问题。从2019年开工至2023年下水,时程虽在官方掌控内,但下水后的测试进度(HAT与SAT)屡遭外界质疑是否为了配合政治时程而压缩安全余裕。
**脉络层**:
国防研发的成本超支与时程延误是全球普遍现象,非IDS独有。然而,IDS的特殊性在于其极高的政治关注度——蔡政府需要IDS在2024大选前下水作为政绩,这种政治压力不可避免地传导至工程端。当「政治时程」与「工程安全」发生冲突时,如何取舍,是评估IDS风险的核心。
**分析如何用此事实**:
1. 评估海鲲号测试进度时,应区分「泊港测试(HAT)」与「海上测试(SAT)」。HAT可在控制环境下进行,但SAT(尤其是潜航、深度测试与鱼雷试射)才是真正的鬼门关。分析者不应将「HAT完成」等同于「战力形成」。
2. 成本争议在选举中会被无限放大。分析时需剥离合理的工程超支与不合理的利益输送指控,但这在资讯极度不对称的情况下极难做到。
---
## 叙事层:IDS的多重政治诠释
同一艘海鲲号,在不同政治行动者的叙事中承载着截然不同的战略想象与政治功能。
### 叙事一:「水下长城」的生存底线与不对称威慑
**核心命题**:潜舰是台湾防卫的终极保险,是让北京无法承受登陆代价的「撒手锏」。
**论述逻辑**:
- 台湾是海岛,对方若要武力改变现状,必须跨海进行两栖登陆。
- 潜舰具备隐蔽性与奇袭性,可对登陆船团与水面护航舰艇造成毁灭性打击。
- 即使只有少数潜舰在航,其「存在本身」就会迫使对方必须分拨大量反潜资源,从而打乱其作战节奏。
- 因此,IDS不是传统大平台,而是最极致的不对称威慑——它不争制海权,而是让对方「不敢跨海」。
**支持者**:台湾海军、DPP内的国防鹰派、部分传统军事战略学者
**反对者视角**:此叙事高估了柴电潜舰在现代化广域反潜网(包含卫星、海底声纳阵列、反潜机与无人载具)前的生存能力。若台海战争爆发,解放军必先以海量反潜兵力清场,8艘老旧与新手混合的潜舰部队能否发挥预期战力,不无疑问。
### 叙事二:「国防自主」的政治图腾
**核心命题**:能够自己建造潜舰,是台湾作为主权独立国家/实质政治实体的终极证明。
**论述逻辑**:
- 潜舰是所有常规武器中技术门槛最高、最难以取得的项目。
- 台湾能在国际封锁下造出潜舰,证明台湾的工业能力与战略意志。
- 这是打破「依赖外购」困境的里程碑,具有不可逆的政治意义。
- 详见 `narratives/domestic/indigenous_defense_industry.md`
**支持者**:DPP执政团队、台船与中科院等产业界、深绿支持者
**反对者视角**:此叙事将「船体建造」等同于「国防自主」,忽略了核心技术与零组件仍受制于人。若战时断链,自主建造的潜舰同样面临无法修复的窘境。过度拔高其象征意义,可能导致为维护图腾而无法进行客观的战术检讨。
### 叙事三:「大而无当」的资源黑洞与豪猪异端
**核心命题**:在豪猪战略框架下,3000亿的IDS是台湾无力负担的战略错误,严重排挤了真正需要的不对称资源。
**论述逻辑**:
- 3000亿可购买数千枚机动反舰飞弹或数万架自杀式无人机。
- 潜舰建造期长,远水救不了近火,且战时存活率堪忧。
- 美方多次明示暗指台湾应投资「大量、廉价、致命」的不对称武器,IDS是反其道而行。
- 这不是防卫,而是海军为了维持军种地位与预算份额的「本位主义」。
- 详见 `narratives/domestic/military_service_branches_tensions.md`
**支持者**:美方不对称战略倡导者、台湾部分本土防卫论者、TPP与KMT中的财政保守派
**反对者视角**:此叙事将「不对称」简化为「只有飞弹和无人机」。缺乏潜舰的水下威胁,对方的两栖舰队将能毫无顾忌地集结与渡海,陆基反舰飞弹在对方第一击后的存活率同样存疑。潜舰是让飞弹防御网出现破口的关键诱饵与杀手。
### 叙事四:「机密外泄」与「黑箱分赃」的政治斗争
**核心命题**:IDS是执政党用来绑定外购利益、打压监督的提款机,且存在国安泄密风险。
**论述逻辑**:
- 采购过程极不透明,民间中介角色(郭玺等)涉及庞大利益。
- 国民党立委马文君等人指控潜舰国造有泄密疑虑,并质疑预算使用不当。
- 执政党以「机密」为由拒绝立法院实质审查,剥夺国防监督。
- 这是「抗中保台」大旗下掩饰的财政黑洞。
**支持者**:KMT部分立委、特定媒体与名嘴
**反对者视角**:此叙事将合理的预算监督与恶意的政治抹红混为一谈。潜舰涉及多国红线,高度机密是国际合作的先决条件。在开工与测试阶段大肆炒作泄密案,客观上正中北京下怀,意在瓦解IDS的国际信任链与内部共识。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:政治象征的刚性 vs 技术事实的弹性
**内在矛盾**:
IDS作为蔡英文政府的最高政绩,其政治象征意义已被固化至不可动摇的程度。然而,潜舰的工程与测试却充满高度不确定性。当政治承诺(2024前下水、2025成军)与工程现实(测试发现问题需要修改)发生冲突时,政治压力可能迫使技术团队压缩安全余裕或隐瞒瑕疵。
**分析如何用此矛盾**:
分析者应密切关注海鲲号海上测试(SAT)的实际进度。若出现长时间延宕或缩减测试科目,不应仅视为「工程不顺」,而应解读为「政治时程绑架工程安全」的征兆。一艘带有未解决潜航瑕疵的潜舰,其政治威慑力或许满分,但实战价值可能归零,甚至成为自我毁灭的陷阱。
### 矛盾二:大型平台 vs 豪猪战略的伪装与妥协
**内在矛盾**:
表面上,IDS被包装为「不对称威慑」(打击登陆船团);实质上,它是台湾海军在豪猪战略浪潮中保全自身军种建制与预算份额的**终极锚点**。如果完全按照豪猪战略的纯粹逻辑,台湾应放弃水面与水下大舰,全力发展岸置飞弹与机动部队。
**结构性命题**:
**IDS是台湾「传统军种惯性」与「不对称战略转型」之间的终极妥协物。** 它用「不对称威慑」的战术包装,保全了海军的「大平台」组织建制。只要IDS存在,台湾海军就不会降格为「岸置飞弹指挥部」。因此,IDS的存废与规模,不仅是战术问题,更是**军种政治与国防身份**的核心战场。
### 矛盾三:国防自主 vs 多国拼装的悖论
**内在矛盾**:
IDS论述的基石是「自主」,但其实践的基石是「多国暗助」。红区装备的来源涵盖美、欧、亚洲等多地,这创造了两种截然相反的推论:
1. 正面推论:多国参与代表国际社会对台湾的隐性支持,IDS是外交突破的证明。
2. 负面推论:多国拼装代表供应链极度脆弱,任何一国在政治压力下断供,IDS即成废铁。
**分析如何用此矛盾**:
评估IDS的战时可持续性时,不能仅算油料与鱼雷库存,必须算「关键零组件更换的来源稳定性」。和平时期的多国合作,在战时或封锁状态下可能瞬间瓦解。这与其「战时可持续作战」的论述初衷形成根本悖论——越是依赖多国暗助取得的自主,越是难以在战时自主维系。
### 矛盾四:威慑北京 vs 激怒北京的微距红线
**内在矛盾**:
IDS的战略目的是威慑北京,但IDS的建造过程与成军事实本身,就是北京眼中的严重挑衅。北京对台湾获取潜舰的敏感度远高于F-16,因为潜舰的隐蔽攻击属性增加了台海冲突的不可预测性,且具备威胁南海舰队与航舰战斗群的潜力。
**分析如何用此矛盾**:
分析北京对台军事反应时,应将IDS视为潜在的「红线触发器」。若未来IDS量产舰配备更具攻势性的潜射巡航飞弹,或美商提供更先进的匿踪技术,北京可能采取超越军演的实体反制(如对台船实施网络攻击、对相关跨国企业进行次级制裁)。IDS的威慑力与被报复的风险是同步上升的。
---
## 对比分析:IDS在豪猪战略光谱中的位置
详见 `narratives/domestic/military_service_branches_tensions.md` 与 `narratives/us_taiwan/porcupine_strategy.md`(占位)。
### IDS vs 陆基反舰飞弹(雄风/鱼叉)
| 维度 | IDS(潜舰) | 陆基反舰飞弹 | 分析含义 |
|---|---|---|---|
| 生存性 | 极高(水下隐蔽) | 中等(依赖机动与掩蔽,受制于空侦) | 潜舰在第一击后存活率较高,飞弹车需承受首波打击 |
| 成本效益 | 极低(单舰数百亿) | 极高(单枚数千万至亿) | 飞弹可用海量淹没防御,潜舰数量太少易被反潜网耗尽 |
| 战术弹性 | 高(反舰/布雷/侦察/特攻) | 低(仅能反舰/打陆) | 潜舰是多用途平台,飞弹是单一用途消耗品 |
| 战时补充 | 不可能(建造需数年) | 较易(量产周期短) | 这是致命差异:飞弹战损可补,潜舰战损即永久减员 |
**破除常见简化**:
「潜舰太贵不如全买飞弹」——这是错的。现代反舰作战并非简单的算术题。若缺乏潜舰的水下威胁,对方的两栖舰队可以集中资源应对空中的飞弹威胁(部署更多防空舰与电子战机)。潜舰的不可见性,迫使对方必须分拨大量反潜兵力,从而削弱其防空网,为陆基飞弹创造攻击窗口。两者是**互补的体系**,非零和的替代品。
### IDS vs 解放军反潜能力
| 维度 | 台湾IDS | 解放军反潜能力 | 分析含义 |
|---|---|---|---|
| 数量 | 8艘(未来) | 数十架反潜机 + 大量反潜舰 + 海底声纳阵 | 数量绝对劣势 |
| 技术 | 4.5代柴电潜舰 | 持续现代化的广域反潜体系 | 技术代差存在,但台海浅水水文对柴电潜舰静音有利 |
| 经验 | 仅有剑龙级操作经验,新舰磨合期长 | 持续在南海与西太平洋演练反潜 | 经验差距比技术差距更致命 |
**分析如何用此对比**:
1. IDS的威慑力不在于「单舰对抗整个反潜网」,而在于「让对方无法确认哪里有潜舰」。8艘潜舰在广阔的西太平洋与南海是微不足道的,但在狭窄的台海周边浅水区,却是难以清除的暗雷。
2. 北京的威慑叙事强调「反潜网的无死角」,台北的威慑叙事强调「潜舰的不可预测」。两者都在挑选对自己有利的情境。分析者应关注台海水文对声纳效能的衰减作用,这是柴电潜舰对抗核潜舰与反潜机的天然屏障。
---
## 叙事的版本演变与传播载体
### 版本演变
**弱版论述**:IDS是填补老旧潜舰退役空缺的必要投资,维持基本水下防卫能力。此版本多见于马政府时期的评估报告,强调「维持现状」。
**强版论述**:IDS是打破国际封锁、实现国防自主的政治奇迹,是「正常国家」的军事基石。此版本多见于蔡政府执政后的官方宣传与绿营媒体。
**极端版论述**:IDS是台湾反击中国的终极武器,未来应发展具长程打击能力的潜射巡航飞弹,对敌形成「源头打击」威慑。此版本多见于深绿独派与部分军事狂热社群。
### 传播载体
- **政治人物**:蔡英文是IDS最强力的传播者,多次视察台船并将之下水仪式作为重大政治议程。详见 `entities/people/tsai_ing_wen.md`。赖清德则延续此路线,将其视为国安基石。
- **军方与产业**:海军与台船是利益共同体,通过媒体放话与展示活动(如海鲲号下水公开)强化其正当性。
- **媒体与名嘴**:亲绿媒体侧重「自主突破」与「北京跳脚」的叙事;亲蓝媒体则聚焦「预算黑洞」与「黑箱作业」,形成平行宇宙。
- **网络社群**:PTT军事板与低卡军事板是辩论主战场,支持者以「技术解析」证明可行,反对者以「成本分析」论证浪费,双方常陷入各说各话。
---
## 在2028选战与政治决策中的具体应用
### 选举攻防的预演
2024年大选前,海鲲号赶在9月下水,KMT立委马文君随即抛出「潜舰泄密案」与「预算黑箱」指控,双方激烈交锋。这已是IDS在选举中应用的前哨战。
**2028年可能的应用场景**:
1. **DPP的主攻**:强调IDS已完成海上测试甚至成军,是「台湾自主防卫不可逆转的成就」。攻击对手「配合北京唱衰台湾」。
2. **KMT/TPP的反制**:聚焦量产舰的预算审查,要求「一分钱一分货」,质疑「测试进度是否造假」,主张「国防预算应优先用于民生与不对称武器」。
3. **北京的外部干预**:可能透过外交途径施压相关国家断供零组件,或在选前制造台海紧张,证明「潜舰无法带来安全」。
### 政治决策中的锁定效应
无论2028谁执政,IDS都具备极强的「锁定效应」。首舰已下水,后续7艘的合约已部分签订,废弃计划的违约金与沉没成本将高达数百亿。因此,在野党即使强烈批评,执政后也几乎不可能停建IDS,顶多在量产节奏与技术规格上做文章。
**分析含义**:分析2028后的国防政策时,不应问「IDS会不会停」,而应问「量产舰的数量会不会缩水(从7艘变4艘)」以及「省下的预算会转投到哪里(飞弹、无人机还是美国军购)」。
---
## 关联分析
### 关联一:与国防自主论述的核心连结
详见 `narratives/domestic/indigenous_defense_industry.md`。
- 功能:提供脉络层
- IDS是国防自主论述的「王冠宝石」。若IDS失败,整个国防自主论述将面临信用破产;若成功,则连带提升国机国造、国舰国造等其他项目的正当性。两者是命运共同体。
### 关联二:与海军组织命运的绑定
详见 `entities/organizations/roc_navy.md`(占位)。
- 功能:提供结构层与用于交叉验证
- 海军的未来二十年建制系于IDS。若无IDS,海军将面临仅剩水面舰(且在第一击下极度脆弱)的窘境,其军种地位与预算份额将被陆军(飞弹指挥部)与空军大幅挤压。IDS是海军维持组织存续的续命丹。
### 关联三:与军种张力及豪猪战略辩论的连结
详见 `narratives/domestic/military_service_branches_tensions.md`。
- 功能:用于对照与提供内在矛盾
- IDS是军种张力最直观的体现。陆军与部分美方顾问认为IDS抢走了飞弹的预算;海军则认为没有潜舰的飞弹战只是坐以待毙。分析国防预算分配时,必须透过军种利益的视角,才能看懂数字背后的权力博弈。
### 关联四:与蔡英文政治遗产的连结
详见 `entities/people/tsai_ing_wen.md`。
- 功能:提供事实层与脉络层
- IDS是蔡英文「坚定自信」外交路线的物质化体现。她亲自命名海鲲号,并在下水典礼中将其定位为历史性突破。理解蔡英文的政治遗产计算,才能理解为何IDS在2023-2024年被赋予如此高的政治优先级。
### 关联五:与美对台军售动态的连结
- 功能:提供交叉验证
- 美方对IDS的态度是「有限度支持」。提供TA与关键红区装备,但不提供完整成品或最尖端技术。分析者可交叉比对:当美方批准更多陆基不对称武器(如鱼叉飞弹)时,是否意在平衡IDS对预算的排挤?这反映了美方对台湾战略路线的暗中引导。
### 关联六:与北京对台威慑叙事的连结
- 功能:用于对照
- 北京对IDS的定性是「以武谋独的挑衅」。分析北京对台军演的内容(如强化反潜演练、模拟封锁高雄港),可反向验证IDS在解放军战略评估中的真实威胁等级。北京反应越激烈,越证明IDS的威慑逻辑在对方认知中成立。
---
## 实务分析:用IDS框架解读
### 应用一:识别「成军」vs「具备战力」的落差
海鲲号预计2025年成军,但「成军」只是编制与行政程序。「具备初始作战能力」(IOC)则需完成全舰系统联试、鱼雷实弹射击、与海军舰队联合战术演练。分析者应追踪IOC的达成时间,这才是实质战力指标。
### 应用二:评估量产舰的政治风险
原型舰是政治图腾,量产舰才是预算黑洞。7艘量产舰的预算将在2026-2032年间陆续编列,这正是2028后新总统的任期。分析者应关注新总统的国防预算报告,观察量产舰的数量是否被技术性调降(如从7艘变5艘),这是战略路线微调的先行指标。
### 应用三:监测国际供应链的脆弱点
当欧洲某国与北京发生外交摩擦时,其厂商对台零组件供应可能突然「顺畅」;当两国关系改善时,供应可能因「民用规格审查」而延迟。分析者应将国际政治动态与IDS零组件交期交叉比对,评估后勤断链的机率。
---
## 常见的分析陷阱
### 陷阱一:把「海鲲号下水」等同于「潜舰国造成功」
错。下水只是船体浮力验证,后续的HAT与SAT(尤其是战斗系统与武器联调)才是决定成败的鬼门关。历史上多国潜舰在测试阶段发现严重瑕疵而大修的案例比比皆是。以政治里程碑替代工程里程碑,会严重高估当前战力。
### 陷阱二:在豪猪辩论中将潜舰与飞弹视为零和替代
错。两者在战术上是互补体系。潜舰制造反潜压力,为飞弹突防创造窗口;飞弹制造防空压力,为潜舰渗透创造机会。将其视为「二选一」,是陷入了资源排挤的线性思维,忽略了体系作战的乘数效应。
### 陷阱三:把「国造」等同于「技术自主」
错。IDS是系统整合的胜利,不是技术源头的自主。关键装备受制于人,意味着「会造船」不等于「能维修与升级」。忽略这一层,会误判台湾在战时维持潜舰战力的能力。
### 陷阱四:低估政治时程对工程安全的侵蚀
错。当政治承诺(大选前下水/成军)与工程现实冲突时,官僚体系倾向于满足政治要求。测试科目可能被简化、风险可能被低估。分析者若只看官方进度表,会忽略潜藏的安全隐患与战力水分。
### 陷阱五:假设北京的反制仅限于口头抗议
错。北京对IDS的忌惮极深,可能采取非军事手段反制(如对参与厂商实施制裁、对台船发动网络攻击窃取机密、施压第三国切断供应链)。将IDS的安全评估局限于军事层面,会忽略其作为政治经济综合战场的脆弱性。
### 陷阱六:将IDS泄密争议纯粹视为政党恶斗
错。虽然泄密案充满选举算计,但潜舰涉及极机密的多国合作,任何未经管制的资讯揭露确实可能动摇国际厂商信任。分析者不应因人废言,需同时检视监督的正当性与泄密的国安风险,这并非非黑即白。
---
## 仍不确定的部分
- **海鲲号的实战声纳效能**:台海水文复杂,浅水与跃温层对声纳影响极大。美制声纳在台海环境下的实际侦搜距离与抗干扰能力,需待实测验证,目前无确切数据。
- **量产舰的最终数量与规格**:7艘量产舰是否会因预算排挤或技术瓶颈而缩减?是否会换装更先进的战斗系统?这取决于2028后的政治格局与预算分配。
- **多国供应链的战时韧性**:和平时期的零组件供应无虞,但战时或封锁状态下,各国厂商是否能在政治压力下持续提供维修零件,仍是不确定的黑天鹅。
- **与美日联合作战的互通性**:IDS战斗系统虽为美系,但战时能否与美日反潜体系(如声纳数据链)无缝接轨,涉及政治决策与战术机密,外界难以确知。
- **北京的反制升级门槛**:当IDS后续舰配备潜射攻陆巡航飞弹时,北京是否会将其视为跨越红线,从而触发更大规模的军事或经济反制,目前仍在推演阶段。
---
## 与其他文件的关系
- 国防自主核心论述:`narratives/domestic/indigenous_defense_industry.md`(提供IDS在国防自主宏大叙事中的定位与功能)
- 台湾海军组织:`entities/organizations/roc_navy.md`(提供IDS使用军种的组织文化、预算逻辑与军种利益脉络)
- 军种张力与豪猪战略:`narratives/domestic/military_service_branches_tensions.md`(提供IDS在不对称战略辩论中的对立位置与军种博弈框架)
- 蔡英文:`entities/people/tsai_ing_wen.md`(提供IDS政策推动者的政治意志、遗产计算与决策风格脉络)
- 美对台军售动态:`narratives/us_taiwan/arms_sales_dynamics.md`(提供IDS红区装备取得的外部政治环境与美方红线管理逻辑)
- 北京反应类型学:`primitives/frameworks/beijing_response_typology.md`(占位,提供预测北京对IDS成军与升级反应的分析框架)
- 2028选战分析:`analysis/elections/2028_election_outlook.md`(占位,提供IDS作为选举攻防议题的政党策略与选民反应脉络)
---
## 一句话浓缩
> 国造潜舰是台湾不对称威慑叙事的**最高仪式**,也是传统大平台在豪猪战略框架下最刺眼的**异类**。
> 它的政治图腾意义远超其战术数字——既是蔡英文时期打破国际封锁的意志结晶,也是多国拼装脆弱性与预算排挤争议的**结构性强辩场**。
> 分析IDS,不是算8艘潜舰能击沉几艘敌舰,而是看它如何**锚定台湾的国防身份、重塑海军的组织命运,以及在国际红线边缘测试外交与技术的极限**。
---
last_updated: 2026-05-20
@@ -0,0 +1,273 @@
# 爱国者飞弹(PAC-3)
---
**类型**: 政治叙事
**当前角色**: 台湾防空体系核心锚点 / 美对台军售常项
**出生时间**: 1981(初代 Patriot);1997(PAC-3 导入);2000年代(台湾首获)
**何时调用此档案**: 分析台湾防空能力与脆弱性;评估美对台军售中的防空品项;解读北京飞弹威胁与台北威慑论述;检视「不对称防御」中的高价值平台定位;分析两岸军备竞赛的经济消耗逻辑
---
## 一句话浓缩
爱国者飞弹(PAC-3)是台湾防空的**物理锚点与心理盾牌**——它在结构上无法独立抵御北京的饱和攻击,但却构成了台湾「存续信心」与「不对称防御」的必要核心;分析时必须同时看穿其「拦截效能」与「消耗战陷阱」的双重性。
---
## 叙事的核心命题:盾牌的刚性与其边界
### 核心主张
爱国者飞弹(PAC-3)在台湾的政治与军事叙事中,主张一种**「技术抵消」的逻辑**:即透过高精度的末端防空与反飞弹能力,使得北京以飞弹进行「首战决胜」或「瘫痪打击」的成本与不确定性大幅上升,从而维系台湾的战时持续力与政治抵抗意志。
### 叙事的支撑论据
此叙事依赖三个相互支撑的论据:
1. **物理拦截事实**:PAC-3 具备击杀弹道飞弹(hit-to-kill)的能力,这是经美方测试与实战(如波湾战争,尽管早期拦截率有争议)验证的硬事实。
2. **高价值保护**:台湾将爱国者集中部署于政军中枢(大台北地区)与关键基础设施,确保国家指挥体系与战争机器核心在第一击后仍能运作。
3. **政治信号**:持续采购与升级爱国者,是向北京表明「无法以短促飞弹攻击达成政治屈服」,同时向华盛顿展示「自我防卫决心」。
### 分析含义
分析者在处理爱国者飞弹时,**不能仅将其视为武器系统的技术参数集合**,而必须将其视为一个「信心乘数」。当分析台湾的抵抗意志或美国的军售政策时,爱国者的存在与否、数量多寡、部署位置,直接连动到台湾内部对「首战即终战」论述的接受度。失去爱国者叙事的支撑,台湾防空体系将只剩纵深消耗,缺乏核心保护的安全感。
---
## 事实层与源流:台湾爱国者部队的发展史
### 从 PAC-2 到 PAC-3:需求与军售的演变
台湾爱国者系统的获取,完全镶嵌在美对台军售的周期与战略脉络中(详见 `narratives/us_taiwan/arms_sales_dynamics.md`)。
| 时期 | 系统 / 军购案 | 战略脉络 | 功能定位 |
|---|---|---|---|
| 1990s | PAC-2 GEM+ | 1996 台海飞弹危机后,台湾寻求反飞弹能力 | 点防御,具有限反飞弹能力 |
| 2000s | PAC-3 采购案(3套) | 陈水扁时期,应对北京短程弹道飞弹(SRBM)激增 | 末端反飞弹,击杀拦截 |
| 2010s | PAC-3 升级与增购(6套) | 马英九时期,维持战力平衡 | 扩大防护网,覆盖中南部 |
| 2020s | PAC-3 MSE 采购与换装 | 蔡英文时期,应对北京精准打击与高超音速威胁 | 增程拦截,提升饱和攻击应对力 |
**关键技术细节与政治意涵**:
- **PAC-2 到 PAC-3 的跃迁**:PAC-2 依赖破片杀伤,拦截战机效率高但反飞弹不可靠;PAC-3 采用碰撞击杀,是真正的反弹道飞弹武器。此一跃迁标志着台湾防空任务从「反制战机」转向「反制飞弹」,反映了北京威胁形态的结构性转变。
- **6108 亿特别预算(2004-2008)**:陈水扁政府提出的三大军购案中,爱国者三型是反飞弹核心。此案在立法院遭在野党(KMT)长期杯葛,历时数年才通过。**分析含义**:爱国者军购在台湾内部常被政党竞争绑架——支持者视为「国安刚需」,反对者视为「凯子军购」或「挑衅北京」,这种两极化是分析台湾国防预算政治时的固定模式。
### 近年军购案与升级:MSE 的引入
2020 年代,台湾采购了 PAC-3 MSE(Missile Segment Enhancement)。MSE 拥有更大的双脉冲火箭发动机和更大的控制翼,拦截距离与高度均显著提升。
**结构意义**:
- 应对北京 DF-17 等高超音速滑翔载具(HGV)的威胁。MSE 提供了稍大的拦截窗口,但对抗 HGV 仍属勉强。
- MSE 的采购标志着台湾防空从「应对传统弹道飞弹」向「应对多重空中威胁」的过渡,但本质上仍受限于爱国者系统的物理极限。
---
## 结构层:在台湾防空体系中的位置
### 防空网格的层级与分工
台湾防空并非单一系统,而是由不同射程与高度的网格构成。爱国者位于金字塔顶端。
| 系统 | 射程 / 高度 | 任务 | 与爱国者的关系 |
|---|---|---|---|
| 爱国者(PAC-3) | 长程 / 高空 | 末端反飞弹、反战机核心 | **锚点**:保护高价值目标,拦截漏网飞弹 |
| 天弓三型 | 长程 / 中高空 | 区域防空、反飞弹 | **互补**:分担长程防空,提供拦截纵深 |
| 捷羚 / 陆剑二 | 中程 / 中低空 | 野战防空、反巡弋飞弹 | **下层填补**:爱国者无法有效应对低空巡弋飞弹 |
| 刺针 / 雄风 / 游荡弹药 | 短程 / 极低空 | 点防御、不对称打击 | **最后防线**:穿透高空防御网的残存目标 |
**分析含义**:爱国者并非孤立存在。分析时必须将爱国者视为「防空漏斗的底端」——天弓等系统提供区域罩护与第一层拦截,漏网的弹道飞弹最终由爱国者处理。若天弓系统失效或遭压制,爱国者的拦截负荷将瞬间饱和。
### 指挥与隶属:空军防空暨飞弹指挥部
爱国者部队隶属于空军防空暨飞弹指挥部(详见 `entities/organizations/roc_air_force.md`)。此一编制结构反映了:
1. **集中指挥**:全台爱国者连由单一指挥部调度,确保雷达情报共享与拦截资源优化。
2. **脆弱性**:集中指挥意味着指挥中心本身是高价值目标。北京若成功打击防挥部,即使爱国者发射器完好,也将失去作战能力。
---
## 结构性内在矛盾:拦截 vs 饱和攻击
这是爱国者叙事中最核心的张力,也是分析者必须时刻把握的结构性命题。
### 矛盾一:经济消耗的不对称性
**事实**:一枚爱国者 PAC-3 拦截飞弹成本约 400-500 万美元;北京一枚东风-11 或东风-15 短程弹道飞弹成本约 100-200 万美元。
**结构嵌入**:
- 北京可以透过发射大量廉价飞弹,消耗台湾昂贵的拦截弹。
- 台湾的国防预算无法无限制采购爱国者飞弹。
- 这构成了一个**「消耗陷阱」**:只要北京持续制造威胁,台湾就必须不断投入资源维持拦截库存,形成单向的经济消耗。
**分析含义**:评估台湾防空能力时,不能只看「发射器数量」或「编制连数」,必须看「拦截弹库存量」与「战时补充能力」。一旦拦截弹耗尽,发射器即成废铁。这是豪猪战略中常被忽略的脆弱性。
### 矛盾二:饱和攻击下的物理极限
**事实**:一个爱国者连通常配备 4-8 个发射器,每个发射器 16 枚 PAC-3 飞弹(MSE 为 12 枚)。雷达同时追踪与导引目标数量有上限。
**结构嵌入**:
- 北京的作战想定中,首波打击必然是饱和攻击——同时发射数十甚至上百枚飞弹攻击同一目标区。
- 爱国者系统虽具备同时拦截多目标能力,但在数量级悬殊的饱和攻击下,必然产生「漏网之鱼」。
- 「射击-观效-射击」的传统拦截模式在饱和攻击下不可行,必须改为「射击-射击-射击」,进一步加速弹药消耗。
**破除常见简化**:
把「台湾拥有爱国者」等同于「台湾能够防御飞弹攻击」是错误的。爱国者提供的是**「概率防护」**——降低被摧毁的概率,而非绝对免疫。在饱和攻击情境下,爱国者的功能从「全面拦截」降级为「确保核心生存」,让部分拦截弹专门保护指挥所与关键基础设施,放弃对一般目标的防护。
### 矛盾三:雷达的致命吸引力
**事实**:爱国者系统的 MPQ-65 雷达是全系统的「眼睛」,一旦雷达被摧毁,整个连即丧失作战能力。
**结构嵌入**:
- 北京发展了反辐射飞弹与专门针对雷达的打击手段。
- 雷达本身是一个强电磁辐射源,在战时极易被定位。
- 这使得爱国者雷达成为北京首波打击的优先目标,甚至高于爱国者发射器本身。
**中等模型自己想不到的洞察**:
爱国者的存在本身就改变了北京的攻击序列。为了确保后续战机与登陆部队的安全,北京必须先消耗资源拔除爱国者雷达。因此,爱国者的最大战略价值可能不在于它能拦截多少飞弹,而在于它**迫使北京将高价值弹药与时间耗费在反辐射任务上**,从而迟滞了北京的整体作战节奏。爱国者是「时间购买者」,而非「绝对盾牌」。
---
## 在豪猪战略中的角色定位与内在冲突
### 作为「不对称防御」之一的矛盾
详见 `narratives/us_taiwan/porcupine_strategy.md`。
豪猪战略的核心原则是:大量、廉价、机动、致命,使侵略者付出难以承受的代价。爱国者飞弹在此战略中的定位充满矛盾:
| 豪猪战略原则 | 爱国者的特性 | 矛盾程度 |
|---|---|---|
| 低成本、可大量部署 | 极高成本、数量有限 | **高**——完全违反低成本原则 |
| 高机动、难以发现 | 需要固定阵地或预 Prepared 阵地,雷达暴露 | **中高**——机动性受限 |
| 专注于杀伤人员与登陆载具 | 专注于拦截高空高速目标 | **中**——任务不同但互补 |
| 防御纵深大、分散部署 | 集中部署于高价值目标周边 | **高**——与分散原则冲突 |
**结构意义**:
爱国者是豪猪身上的「硬甲」而非「尖刺」。豪猪战略承认台湾无法在传统对称作战中取胜,但爱国者是少数必须保留的「对称元素」——因为没有反弹道飞弹能力,台湾的政军中枢将在开战首日被瘫痪,豪猪的其他尖刺(刺针、海马士)将失去指挥与控制。
**分析含义**:
当美方要求台湾增加不对称防御预算时,爱国者的采购与维护费用常成为内部辩论焦点。分析者应观察台湾国防预算中「爱国者(对称/高端)」与「刺针/海马士(不对称/低端)」的比例变化——这是台湾是否真正贯彻豪猪战略的量化指标。
---
## 政治叙事维度:军购、信任与威慑
### 北京视角:「以武拒统」的象征
北京将台湾采购爱国者视为「以武拒统」与「倚美谋独」的具体表现。
- **叙事功能**:北京将爱国者描绘为「无效且挑衅」的武器——无效是因为无法抵挡饱和攻击,挑衅是因为增强了台湾的独立信心。
- **反应模式**:每次爱国者军售或升级,北京必循标准反应阶梯(政治表态 + 制裁参与企业),借此重申对台主权主张。
### 台北视角:「保命符」与「决心展示」
台北将爱国者视为生存保障。
- **叙事功能**:对内,爱国者是「政府保护人民」的物质证明;对外,是「台湾有能力抵抗」的信号。
- **内在张力**:台湾内部存在「爱国者无用论」(挡不住饱和攻击)与「爱国者必需论」(没有它更惨)的争论。这种争论在选举期间常被政治化。
### 华盛顿视角:「责任分担」与「技术控制」
华盛顿的视角最为复杂。
- **叙事功能**:军售爱国者是履行 TRA 义务,也是确保台湾不致在首击下崩溃的必要手段。
- **技术控制**:美方对爱国者核心代码与远端维护的掌控,确保了台湾在操作上的依赖。这是「给武器但不给完全自主权」的结构性安排。
- **与军售动态的关联**:爱国者是军售的「常项」,其节奏与品项变化直接反映美台关系的温度(详见 `narratives/us_taiwan/arms_sales_dynamics.md`)。
---
## 未来防空体系展望:爱国者后继者?
### 威胁演变:高超音速与巡弋飞弹
北京的新型威胁(DF-17 高超音速滑翔载具、长剑系列巡弋飞弹)正在压缩爱国者的效能空间。
- **高超音速**:速度与不规则弹道使爱国者拦截窗口极小,现有 PAC-3 MSE 仅能提供边缘拦截能力。
- **巡弋飞弹**:低空飞行使爱国者雷达受地物遮蔽影响,需依赖其他系统(如陆剑二)提供低空补网。
### 可能的后继或补充方案
1. **雷神标准六型(SM-6)陆基版**:若台湾获得,可提供海基/陆基一体的长程防空,但涉及更敏感的政治升级。
2. **定向能武器(激光)**:仍在早期发展阶段,短期内无法替代爱国者。
3. **强化 C2 系统与联合接战能力(CEC)**:将雷达与发射器分离部署,提升生存性,这是比换装新飞弹更务实的方向。
**分析含义**:
短期内(至 2028),台湾防空体系仍将以爱国者为绝对核心。任何关于「后继者」的讨论都受限于美对台军售的政治意愿与北京的红线。分析时应将「爱国者是否升级/增购」视为美台互信的晴雨表,而非纯粹的技术选择。
---
## 关联分析
### 关联一:与美对台军售动态的连动
- **功能**:提供事实层与脉络层
- 爱国者是军售中的「昂贵常项」,其进度常受美方产能与政治考量排挤。分析军售公告时,爱国者的数量与升级内容是判断美方对台防空承诺强度的最直接指标。
- 详见 `narratives/us_taiwan/arms_sales_dynamics.md`
### 关联二:与 ROC 空军的组织逻辑
- **功能**:提供结构解释
- 爱国者隶属空军,这决定了其指挥链路与资源分配逻辑。空军的文化偏好(制空权优先)可能影响爱国者与陆军野战防空系统的协同效率。
- 详见 `entities/organizations/roc_air_force.md`
### 关联三:与豪猪战略的张力
- **功能**:用于对照与内在矛盾分析
- 爱国者是豪猪战略中的「非典型元素」。分析台湾是否真正转向不对称防御,需观察爱国者预算是否排挤了更符合豪猪原则的武器。
- 详见 `narratives/us_taiwan/porcupine_strategy.md`
### 关联四:与北京飞弹威胁论述的共生
- **功能**:提供交叉验证
- 北京飞弹威胁论述的升级(如 DF-17 的展示)直接为台湾采购爱国者提供正当性;反之,台湾采购爱国者又为北京升级飞弹威胁提供借口。两者形成军备竞赛的叙事闭环。
### 关联五:与台湾内部选举政治的挂钩
- **功能**:提供脉络层
- 爱国者预算的通过与否、部署位置(如是否部署于南部),常受选举政治与地方利益影响。分析国防政策时不能忽略这种本土政治的干扰。
### 关联六:与「疑美论」的交互
- **功能**:用于对照
- 爱国者交货延迟或性能缩水,常被疑美论者引用为「美国不可靠」的证据;而顺利交货则被反疑美论者引用。同一事实,两种诠释。
---
## 常见的分析陷阱
### 陷阱一:将爱国者等同于「绝对防御」
这是最常见的简化。爱国者提供的是概率防护与时间购买,绝非铁穹般的全面防护。将其视为绝对防御,会严重高估台湾的抗打击能力。
### 陷阱二:忽略拦截弹的库存与消耗率
只计算发射器数量,忽略拦截弹的有限库存与战时补充的困难。在饱和攻击下,拦截弹可能在数小时内耗尽,之后爱国者即失去作用。
### 陷阱三:脱离防空网格孤立分析爱国者
爱国者无法独立运作,需天弓提供纵深、捷羚提供低空掩护。若孤立分析爱国者,会忽略网格缺口(如低空巡弋飞弹)对整体防空的撕裂效应。
### 陷阱四:低估爱国者雷达的脆弱性
将焦点放在拦截弹上,忽略雷达才是系统的命门。雷达一旦被反辐射武器摧毁,整个连即告瘫痪。
### 陷阱五:在豪猪战略中错置爱国者的定位
将爱国者视为豪猪战略的典型代表是错误的。它是豪猪战略中的「例外」与「妥协」,过度投资爱国者将排挤真正符合不对称原则的资源。
### 陷阱六:假设北京会以单一模式攻击
假设北京只发动高空弹道飞弹饱和攻击。实际上,北京更可能采用混合攻击(弹道飞弹吸引爱国者开火 + 巡弋飞弹低空穿透 + 反辐射飞弹压制雷达),这种混合模式才是爱国者最棘手的情境。
---
## 仍不确定的部分
- **实际拦截率**:爱国者在实战中对北京现代弹道飞弹的真实拦截率仍不确定。波湾战争的拦截率争议至今未解,且北京飞弹的突防技术(诱饵、机动弹头)已大幅进化。
- **拦截弹库存确切数字**:台湾拥有的 PAC-3 与 MSE 拦截弹确切数量属高度机密,分析者只能推测,无法确知其能否支撑特定强度的饱和攻击。
- **北京高超音速武器的真实突防能力**:DF-17 等武器的实战效能仍不确定,爱国者 MSE 对其拦截概率也缺乏实弹验证。
- **美方的远端控制权限**:美方对台湾爱国者系统的代码与维护控制程度到底多深?在战时美方是否能「关闭」或「限制」台湾的拦截行动?此点无法确认,但对作战主权分析至关重要。
- **战时补充能力**:若台海爆发冲突,美方能否在海上封锁下向台湾补充爱国者拦截弹?这涉及后勤与政治决心的双重不确定。
---
## 与其他文件的关系
- 美对台军售动态:`narratives/us_taiwan/arms_sales_dynamics.md` —— 爱国者为军售常项,其节奏反映政策方向
- ROC 空军:`entities/organizations/roc_air_force.md` —— 爱国者之使用军种,影响指挥与资源逻辑
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md` —— 爱国者在不对称防御中的矛盾定位
- 北京飞弹威胁:`narratives/cross_strait/missile_threat_narrative.md` —— 爱国者论述的对应面与共生结构
- 疑美论:`narratives/cross_strait/skepticism_of_us.md` —— 爱国者交货状况常被引用为疑美论材料
- 台湾国防预算政治:`entities/domestic/defense_budget_politics.md` —— 爱国者预算的政党竞争脉络
---
## 一句话浓缩
> 爱国者飞弹(PAC-3)是台湾防空的物理锚点与心理盾牌——它在结构上无法独立抵御饱和攻击,且深陷经济消耗陷阱,但作为「时间购买者」与「核心保护者」,它是不对称防御中不可替代的刚性需求;分析时,看穿其在「拦截效能」与「消耗战脆弱性」间的双重性,比计算其技术参数更具战略价值。
---
last_updated: 2025-05-19
@@ -0,0 +1,359 @@
# 毒刺与标枪——便携式防空 / 反战车飞弹叙事
---
**类型**: 政治叙事
**当前角色**: 豪猪战略核心装备 / 不对称防卫论述的物质载体
**出生时间**: 毒刺(FIM-92)1981 年服役;标枪(FGM-148)1996 年服役
**何时调用**: 分析不对称作战论述、军购争议与延迟、民防与后备动员、乌克兰经验移植、单兵武器效力辩论、台美军事整合指标
---
## 一句话浓缩
毒刺与标枪是「大量、便宜、单兵可用」不对称防卫论述的物质化象征——它们在乌克兰战场被验证后,成为台湾豪猪战略的图腾,但也同时掩盖了从「战术成功」到「全民防卫体系」之间的巨大训练、维护与指管通联鸿沟。
---
## 叙事的核心命题与符号化
### 核心命题
便携式防空飞弹(毒刺)与反战车飞弹(标枪)代表了当代不对称防卫的核心论述:**「以小搏大」、「轻步兵可对抗正规装甲与空中武力」**。这种论述主张,台湾无需与中方进行昂贵的军备竞赛(战机、大舰),只需大量部署此类单兵武器,即可将台湾化为难以吞咽的刺猬,让任何跨海登陆与低空压制付出无法承受的代价。
### 符号化过程
毒刺与标枪不仅是武器,更是政治符号:
- **物质化的政治承诺**:美方出售毒刺与标枪,被视为对台湾「提供实质防卫工具」的承诺,而非仅卖昂贵平台作为外交象征。
- **自我防卫的决心展示**:台方采购此类武器,意在展示「全民皆兵、抵抗到底」的意志,从依赖大型平台转向分布式杀伤。
- **平民化战争的想象**:在媒体与大众认知中,这两种武器被简化为「一兵一弹击毁百万战车」的图腾,塑造了某种「弱者逆袭」的心理暗示。
**分析含义**:分析者必须区分「武器的战术价值」与「武器的符号价值」。在政治论述中,后者往往大于前者;当政治人物或媒体提及毒刺与标枪时,他们经常是在召唤「不对称防卫」的心理意象,而非讨论具体的射程与穿甲深度。
---
## 事实层:基本能力与台湾采购脉络
### 毒刺(Stinger)基本能力
毒刺是单兵肩射式防空飞弹,主要对付低空飞行的直升机、定翼机与无人机。
| 参数 | 数据 |
|---|---|
| 射程 | 约 4-8 公里(视版本与目标特性而定) |
| 射高 | 约 3.5-4 公里 |
| 导引方式 | 红外线追热(后期版本加入 UV 紫外线辨识,抗热焰弹干扰能力提升) |
| 弹头 | 高爆穿甲(Hit-to-Kill,直接撞击杀伤) |
| 重量 | 全系统约 15.7 公斤(含发射筒与飞弹) |
| 核心功能 | 防低空目标,迫使敌方直升机与攻击机拉高飞行高度,从而进入中高空防空系统的拦截范围 |
**分析含义**:毒刺的战术意义不在于「击落多少架」,而在于**「改变敌方行为」**——迫使敌方直升机不敢低空突穿,必须在高空作业,从而暴露于其他防空火力之下。这是一种「区域拒止」概念,而非单纯的击杀工具。
### 标枪(Javelin)基本能力
标枪是单兵携行式反战车飞弹,采用「射后不理」与「攻顶」模式。
| 参数 | 数据 |
|---|---|
| 射程 | 约 2.5-4.75 公里(视目标与模式而定) |
| 导引方式 | 红外线成像(IR FPA),射前锁定、射后不理 |
| 攻击模式 | 攻顶模式(Top Attack,飞弹爬升后从顶部最薄弱处贯穿) / 直射模式(Direct Attack,对付掩体或低空目标) |
| 穿甲深度 | 约 750mm 滚轧均质装甲(RHA) |
| 重量 | 全系统约 22.3 公斤(CLU 指挥发射单元 6.4 公斤 + 发射筒与飞弹 15.9 公斤) |
| 核心功能 | 反战车、反掩体,对重型装甲目标的非对称制衡 |
**分析含义**:标枪的「攻顶」模式是其在论述中具有象征意义的关键——它不正面挑战坦克最厚的正面装甲,而是攻击最薄弱的顶部。这种「避开强点、攻击弱点」的技术特征,完美隐喻了不对称作战的核心逻辑。
### 台湾采购脉络与数量
台湾采购毒刺与标枪的过程,反映了从「平衡发展」到「不对称作战」的路线拉扯:
- **早期迟疑(2000-2010 年代)**:台方曾长期未大量采购毒刺,原因包括预算排挤(优先采购大型平台如 F-16)、军种本位主义(空军偏好防空飞弹系统如爱国者与天弓,对单兵防空兴趣较低)。
- **转折期(2017-2020)**:美方开始强力推销不对称武器,台方逐步接受。2017 年首次公开采购标枪(约 400 枚发射器与 1700 枚飞弹的规模讨论)。
- **加速期(2020-2024)**:俄乌战争爆发后,采购加速。2022 年台方宣布采购 250 枚毒刺飞弹(约 30 亿新台币),后续追加。截至 2024 年,台湾已采购或正在接收的毒刺数量达数千枚级别,标枪亦持续增购。
- **交货延迟争议(2022-2024)**:因美方产能优先供应乌克兰,导致台湾部分毒刺与标枪交货延迟,此事件成为台湾内部「疑美论」的燃料之一——质疑美方是否「口惠而实不至」。
详见 `narratives/us_taiwan/arms_sales_dynamics.md`。
**分析含义**:采购数量的变化是观察「台美军事整合程度」的指标。但更重要的是交货延迟引发的政治效应——**军事物资的供应链安全,已直接与政治信任挂钩**。
---
## 叙事的源流与版本演变
### 源流:从智库倡议到政策实践
毒刺与标枪成为台湾防卫论述核心,经历了三个阶段:
1. **学术倡议期(2010 年代初)**:William Murray 等学者提出「豪猪战略」,主张台湾应大量采购便宜、致命的单兵武器。
2. **政策推动期(2017-2021)**:川普 1.0 至拜登初期,美方将倡议转化为军售实务,优先批准不对称武器。
3. **实证强化期(2022 后)**:俄乌战争提供了「实证」,毒刺与标枪在乌克兰初期的表现,使得此论述从「推论」变为「有实战验证的真理」。
详见 `narratives/us_taiwan/porcupine_strategy.md`。
### 版本演变:弱版、强版与极端版
| 版本 | 主张 | 支持者 | 隐含假设 |
|---|---|---|---|
| 弱版 | 便携式飞弹是传统军力的补充,增加敌方攻坚成本 | 部分陆军将领、务实派 | 传统军力仍是主力 |
| 强版 | 便携式飞弹应取代部分大型平台,成为防卫核心 | 美方智库、部分民进党决策者 | 大型平台在第一击下存活率低 |
| 极端版 | 全民皆兵,人手一弹,城镇战是唯一解 | 部分民防团体、网络意见领袖 | 台湾无法也无需进行机动防御 |
**分析含义**:分析时必须识别发言者使用的是哪种版本。强版与极端版的差异在于「是否还需要专业军队」——极端版实质上否定了常备军与大型平台的价值,这在实务上会导致军种抵制与资源分配的剧烈冲突。
---
## 乌克兰参照的神话化与解构
### 神话化:视觉化的战果
2022 年 2-3 月,乌克兰战场上大量流传以下画面:
- 俄军直升机被毒刺击落,坠落基辅郊区。
- 俄军 T-72 坦克被标枪攻顶,炮塔被掀飞(「飞炮塔」现象)。
- 乌军步兵肩扛飞弹,在民居间穿梭。
这些影像创造了强烈的叙事:**「轻步兵 + 便携飞弹 = 击退世界第二大军队」**。
### 解构:被忽略的脉络
乌克兰经验的「成功」,建立在多个台湾未必具备的条件上:
| 维度 | 乌克兰条件 | 台湾条件差异 |
|---|---|---|
| 地理 | 陆地接壤,北约补给线畅通 | 海岛,战时可能遭封锁,补给中断 |
| 敌方战术 | 俄军初期轻敌、营级战术群(BTG)补给线过长 | 解放军若攻台,必有详细预案与两栖后勤准备 |
| 前期训练 | 乌军自 2014 年起受北约 8 年轮训 | 台军与美军训练程度不同,后备战力存疑 |
| 空优争夺 | 俄军未能完全夺取制空权 | 解放军导弹与空军规模远超俄军,首波打击力度极大 |
| 情报支援 | 北约提供实时战场情报(ISR) | 台湾战时能否获得同等实时情报支援,仍不确定 |
**分析含义**:**把乌克兰初期战况直接等同台湾潜在战况,是常见的分析谬误**。乌克兰经验证明了便携式飞弹的战术价值,但未证明其在海岛封锁环境下的战略可持续性。分析者必须区分「战术验证」与「战略可比性」。
---
## 结构性命题:部署分散 vs 情报集中
便携式飞弹的引入,在台湾防卫体系中引发了一个深层的结构性矛盾:**「火力部署的去中心化」与「目标情报的中心化依赖」之间的张力**。
### 矛盾的根源
- **部署要求分散**:为避免被首波打击摧毁,发射组必须分散隐蔽于城镇、山地、海岸。这是豪猪战略的核心——存活率即威慑力。
- **作战依赖集中情报**:毒刺与标枪的射程仅数公里。若射手仅靠肉眼搜索,在现代化战场上存活率极低。他们需要雷达预警、无人机侦察、指管系统提供目标方位,才能在敌军出现时处于有利位置。这些 ISR(情报监视侦察)资源高度集中,且是首波打击的首要目标。
### 实战含义
如果 ISR 系统在首波打击中瘫痪,毒刺与标枪射手将退化为「肉眼搜索、被动接战」的游击力量。这不是无效,但其效力将从「系统化拒止」降级为「随机骚扰」。
**分析含义**:评估便携式飞弹的效力,不能只算「有多少枚飞弹」,必须同时算「有多少套 ISR 系统能支持这些飞弹」。**飞弹是豪猪的刺,但 ISR 是豪猪的眼睛**——没有眼睛的刺,只能盲目挥舞。
---
## 破除常见简化:「便宜与单兵可用」的错觉
### 错觉一:「便宜」
| 项目 | 成本(约略) |
|---|---|
| 一枚毒刺飞弹 | 约 12 万美元(385 万新台币) |
| 一枚标枪飞弹 | 约 17-24 万美元(545-770 万新台币) |
| 一套标枪CLU(发射单元) | 约 10-25 万美元 |
相较于 F-16(约 8000 万美元)或 M1A2T 战车(约 1000 万美元),便携式飞弹确实是「相对廉价」。但「相对廉价」不等于「可随意消耗」:
- 数千枚飞弹的总价仍达数十亿至百亿新台币,占国防预算显著比例。
- 战时补给困难,每一枚飞弹的消耗都必须精算。
- 将飞弹描述为「便宜」,容易导致采购决策忽视**总拥有成本**(训练、维护、库存管理)。
### 错觉二:「单兵可用」
「单兵可用」在论述中常被等同于「简单易用」,这是危险的简化:
- **标枪操作**:射手需熟练使用 CLU 的红外线热像仪搜索目标,在复杂热源环境中识别敌我,锁定目标焦点(锁定程序需精确聚焦),选择攻击模式,激活电池,发射。全套流程在压力下需数十秒至数分钟完成,训练不足极易失误。
- **毒刺操作**:需在肩扛姿态下完成敌我识别(IFF)询问、瞄准与激活,对射手的体能与心理素质要求极高。
- **射后不理的代价**:标枪虽可「射后不理」,但锁定前的暴露时间是射手最脆弱的时刻。
**分析含义**:「大量、便宜、单兵可用」是政治论述的简化口号。在军事实务中,**「单兵可用」意味着「需要大量训练有素的单兵」**——这恰恰是台湾当前最大的短板。分析者不能被口号蒙蔽,必须追问:谁来做射手?他们训练多久?他们能存活多久?
---
## 训练与维护的微观挑战
### 训练挑战:实弹与模拟的落差
台湾军队在便携式飞弹训练上面临几个实务问题:
- **实弹打靶机会稀缺**:一枚标枪飞弹造价近 800 万新台币,实弹射击成本极高。多数射手在服役期间可能从未发射过实弹,仅依赖模拟器训练。
- **模拟器的局限**:模拟器可训练操作程序,但无法模拟肩扛 15-22 公斤装备在泥泞、恐惧、敌火下的体能消耗与心理压力。
- **敌我识别训练不足**:在城镇战环境中,低空飞行器与装甲车辆可能同时属于敌我双方。毒刺射手在几秒钟内需完成 IFF 判断,误击友军的风险极高。
### 维护挑战:电池与精密电子
- **热电池的寿命**:毒刺与标枪使用热电池(激活后仅能维持数分钟电力),平时需定期检测,库存管理复杂。
- **冷却单元(BCU)**:毒刺的 BCU 提供导引头冷却,寿命极短(约 45 秒至数分钟),一旦激活必须迅速发射或放弃,否则飞弹失效。
- **高湿环境**:台湾高湿高盐环境对精密红外线寻标器的长期储存构成挑战,需严格的温控库房与定期校准。
**分析含义**:采购飞弹只是第一步。**飞弹的「可用率」取决于日常维护与训练体系**——这是看不见的后勤冰山。分析战力时,应关注「可发射率」而非「库存量」。
---
## 民防与后备体系的角色与落差
### 论述中的角色:全民皆兵的想象
在强版与极端版的豪猪论述中,便携式飞弹被设想为可下发至后备与民防力量的武器。部分民间防卫团体(如黑熊学院等)在宣传中,将「人手一弹」作为全民抵抗的象征,暗示每一个受过基础训练的平民都可能成为反装甲 / 防空射手。
### 实务上的落差
| 维度 | 论述想象 | 实务落差 |
|---|---|---|
| 人力素质 | 后备军人可快速上手 | 4 个月义务役期不足以培养合格射手,1 年役期仅能基础掌握 |
| 武器下发 | 分散至乡镇区 | 高价值精密武器集中保管,战时下发可能因交通中断而失败 |
| 指挥管制 | 独立游击作战 | 缺乏 ISR 支持的游击射手效能极低,且易误击友军 |
| 心理素质 | 坚决抵抗 | 未经严格实战训练的后备军人,面对装甲部队的心理崩溃风险极高 |
### 兵役延长的关联
2024 年起义务役恢复为 1 年,部分原因即为配合不对称作战的人力需求。1 年役期是否足以训练出能操作标枪 / 毒刺的合格射手?军方规划是将此类武器交由常备部队与主力后备(如甲种后备旅)操作,而非广泛下发至乡镇民防。
详见 `events/key_events/event_conscription_extension_2022.md`。
**分析含义**:**「全民皆兵」是政治动员的口号,不是军事部署的计划**。将便携式飞弹与民防画等号,是论述上的跳跃。分析时必须区分「哪些部队真正会操作这些武器」,而非被「全民」话语所迷惑。
---
## 对立与替代叙事
### 叙事一:传统军力不可偏废
**支持者**:部分海空军将领、蓝营部分人士、主张「正常国家」者
**核心论点**:仅靠便携式飞弹无法应对全面封锁与海空优势。若中方采封锁而非登陆,毒刺与标枪毫无用武之地。仍需战机、潜舰、大型舰艇维持制海制空。
**对毒刺/标枪叙事的批判**:过度聚焦于终局登陆战,忽略了封锁与灰色地带胁迫阶段。
### 叙事二:电子战与无人机将使便携飞弹失效
**支持者**:中方军事评论、部分技术悲观论者
**核心论点**:现代战争电子战能力极强,红外线导引头易受干扰;无人机蜂群可预先扫荡射手阵地;攻顶模式面对主动防御系统(APS)效力下降。
**对毒刺/标枪叙事的批判**:乌克兰经验已过时,下一场战争将是无人机与电子战的天下,单兵飞弹的「黄金窗口」已关闭。
### 叙事三:军购依赖的陷阱
**支持者**:疑美论者、部分第三势力
**核心论点**:大量采购美制单兵飞弹,使台湾防卫深度绑定美国军火供应链。一旦美方断供或延迟(如 2022-2024 的交货延迟),战力立即出现空隙。
**对毒刺/标枪叙事的批判**:这不是「自主防卫」,而是「对美依赖的加深」。
**分析含义**:这三种替代叙事分别从「作战维度」、「技术演进」与「政治自主」三个角度攻击便携飞弹叙事。分析时需注意,**这些批判并非全无道理,但它们各自也有隐含的政治意图**——传统军力叙事维护军种利益,电子战叙事服务于中方威慑,军购依赖叙事服务于疑美论。
---
## 选举与政治决策中的具体应用
### 作为「抗中保台」的物质证据
在选举语境中,毒刺与标枪的采购常被执政党作为「强化防卫、不向北京低头」的证据。它们比 F-16 更容易向大众解释:「这种武器让登陆的共军付出代价」。
### 作为「备战避战」的论述工具
政治人物引用乌克兰经验,主张「唯有让敌方知道代价,才能避战」。毒刺与标枪在此论述中扮演「代价制造者」的角色。
### 作为攻击对手「不重视国防」的武器
若在野党质疑军购预算或防卫政策,执政阵营常以「是否反对采购标枪 / 毒刺」作为攻击点,将政策辩论转化为「是否愿意防卫台湾」的忠诚度测试。
### 交货延迟作为政治攻击点
反之,在野党与疑美论者则利用交货延迟事件,攻击执政党「花钱买不到东西」、「美方不可靠」。
**分析含义**:便携式飞弹在选举中的功能,远超过其军事功能。它们是**「防卫意志」的计量单位**——支持采购 = 支持防卫;质疑采购 = 质疑防卫。这种简化逻辑压抑了实质的政策辩论。
---
## 关联分析
### 关联一:与豪猪战略的连结
**功能**:提供物质基础与典型案例
毒刺与标枪是豪猪战略最典型的物质载体。分析豪猪战略的推进程度,可直接观察此类武器的采购与部署进度。详见 `narratives/us_taiwan/porcupine_strategy.md`。
### 关联二:与军购动态的连结
**功能**:提供采购脉络与政治博弈细节
毒刺与标枪的采购历程(延迟、追加、预算排挤)是观察台美军售动态的窗口。交货延迟事件是近年台美军事信任的重要测试案例。详见 `narratives/us_taiwan/arms_sales_dynamics.md`。
### 关联三:与兵役延长的连结
**功能**:提供人力基础与训练现实
1 年义务役的恢复,是不对称作战人力配套的一环。便携式飞弹需要射手,射手的培养需要时间。但 1 年是否足够?这需与兵役延长事件交叉验证。详见 `events/key_events/event_conscription_extension_2022.md`。
### 关联四:与疑美论的连结
**功能**:用于对照与交叉验证
毒刺交货延迟事件,是疑美论者经常引用的具体案例。分析疑美论的升温,需检视军购延迟的时间线与疑美论述的高峰期是否重叠。详见 `narratives/cross_strait/skepticism_of_us.md`。
### 关联五:与城镇战论述的连结
**功能**:提供脉络层
便携式飞弹的战术设定,隐含了「战场在本土」的假设——城镇战是最终手段。这与「拒敌于彼岸」的传统战略形成张力。详见相关城镇战论述档案。
### 关联六:与中方军改 / 两栖作战能力的连结
**功能**:提供威胁评估基础
评估毒刺与标枪的效力,必须同时评估解放军的两栖登陆能力、直升机突击能力与主动防御系统发展。威胁与反威胁是共同演化的。详见相关共军军改档案。
---
## 常见的分析陷阱
### 陷阱一:把乌克兰经验直接套用台湾
错。地理条件、补给线、敌方战术、前期训练完全不同。乌克兰证明了战术可行性,未证明战略可比性。
### 陷阱二:高估「单兵可用」,忽略训练门槛
「单兵可用」是相对于操作战机而言。实际上,合格射手的培养需数月至数年,且实弹经验稀缺。将「单兵可用」等同于「简单易用」是致命的简化。
### 陷阱三:把采购数量等同于可用战力
库存量不等于可发射量。电池寿命、维护状况、射手训练水平、ISR 支持能力,共同决定真正的战力。只算飞弹数量,是纸上谈兵。
### 陷阱四:忽略电子战与主动防御的演进
便携式飞弹的导引头面临日益严峻的电子战干扰与主动防御系统挑战。以 2022 年乌克兰的性能数据推估 2028 年台海战场的效力,可能高估。
### 陷阱五:将「全民皆兵」论述等同于军事部署计划
政治动员口号不等于作战计划。便携式飞弹不可能也无必要下发至未经严格训练的平民。将民防想象与专业军事操作混为一谈,会导致对后备战力的误判。
### 陷阱六:把便携飞弹视为解决封锁的方案
毒刺与标枪仅针对低空与地面目标,对海空封锁完全无效。若仅聚焦于此,将忽略封锁场景下的防卫短板。
---
## 仍不确定的部分
- **台海电子战环境对红外线导引的实际干扰程度**:缺乏实战验证,模拟与实战可能有极大落差。
- **战时 ISR 系统的存活率**:若指管系统遭首波瘫痪,便携飞弹的效力将大幅下降,但确切下降幅度未知。
- **后备动员的实际可操作率**:教召与 1 年义务役能否培养出足够的合格射手,仍需数年验证。
- **解放军对便携飞弹的反制战术演进**:无人机扫荡、电子干扰、装甲编组调整等反制措施的发展速度,尚难精确评估。
- **美方战时补给台湾的实际能力**:在海岛封锁下,能否持续提供飞弹与电池等消耗品,是最大的战略未知数。
---
## 与其他文件的关系
- 豪猪战略论述:`narratives/us_taiwan/porcupine_strategy.md` —— 提供战略框架,本档案为其物质层例证
- 军购动态:`narratives/us_taiwan/arms_sales_dynamics.md` —— 提供采购历程与延迟事件细节
- 兵役延长事件:`events/key_events/event_conscription_extension_2022.md` —— 提供人力配套的时间线
- 疑美论:`narratives/cross_strait/skepticism_of_us.md` —— 提供交货延迟事件的政治效应分析
- 美方关键学者:`entities/foreign_actors/key_us_taiwan_scholars.md` —— 提供推动不对称作战的人员背景
- 城镇战论述:`narratives/us_taiwan/urban_warfare_discourse.md`(占位) —— 提供战术场景脉络
---
## 一句话浓缩
> 毒刺与标枪是「大量、便宜、单兵可用」不对称防卫论述的物质化象征——它们的战术价值在乌克兰被验证,但其战略效力在台湾将受限于**训练门槛、维护后勤、指管依赖与海岛补给**的结构性落差。
> 分析时必须穿透「以小搏大」的神话,追问:**谁来做射手?谁来给目标?谁来补飞弹?**——这三个问题,才是便携式飞弹从论述走向实战的真正试金石。
---
last_updated: 2026-05-08
@@ -0,0 +1,368 @@
# M1A2T 战车与陆军装甲叙事
---
**类型**: 政治叙事
**当前角色**: 台湾陆军核心战车 / 豪猪战略辩论标靶 / 城镇与滩岸作战角色争议焦点
**出生时间**: 2019(川普政府批准军售)/ 2024(首批交机预定期)
**何时调用此档案**: 分析台湾陆军装甲兵力结构转型;评估「大型平台 vs 不对称武器」的战略辩论;解读美台军售中的政治妥协与红线管理;推演台湾城镇战与滩岸阻击的战术想定;观察台湾国防预算排挤效应;分析 2028 选举中关于国防自主与不对称作战的政见交锋
---
## 一句话浓缩
M1A2T 是台湾陆军试图从「消耗战预备队」走向「重装甲威慑」的**身份锚点**,也是豪猪战略辩论中「大型昂贵平台」的**最佳标靶**——它既是填补战力代差、提供城镇战硬壳的务实选择,也是挤占不对称资源、受制于岛内后勤与地形的结构性负担。
---
## 事实层:规格、进程与结构
### M1A2T 的核心规格与战略意涵
M1A2T 是美国 M1A2 艾布兰主力战车的台湾特规版(T 代表 Taiwan)。理解其规格的政治意涵,关键在于识别**哪些设计改变了陆军的作战教条与政治叙事**:
**关键规格与政治/战略意涵**:
| 规格 | 事实内容 | 政治与战略意涵 |
|---|---|---|
| 重量 | 约 63 公吨(含装甲与战斗装载) | 远超台湾现有桥樑与道路设计载重(多数限重 40-50 公吨),迫使陆军必须编列「基础设施强化」预算,也暴露了「重战车是否适合本岛地形」的根本质疑 |
| 火力 | 120 公厘滑膛炮(M256) | 可有效击穿解放军现役所有登陆战车与两栖装甲车,提供「绝对杀伤优势」的叙事素材,是陆军证明自身「能歼敌于岸」的技术底气 |
| 装甲 | 贫铀装甲(推测降规为复合装甲) | 美方是否提供贫铀装甲仍是机密,此一「降规与否」的猜测,直接牵动「美方是否留一手」的疑美论叙事 |
| 动力 | 燃气涡轮发动机 | 需大量航空燃油(JP-8),耗油量极大。战时油料补给线将成为最脆弱的阿基里斯腱,挑战台湾的后勤持续力 |
| 射控 | 车长独立热显像仪(CITV)与数位化资链 | 具备「猎杀」(Hunter-Killer)能力,使台湾战车首次具备与美军现役同级的战场觉知,是「质的优势弥补量的劣势」之技术基础 |
**分析含义**:M1A2T 的核心逻辑是「以重击轻、以质胜量」。它假设解放军的首波登陆部队以轻/中型装甲为主,M1A2T 能以火力与防护的绝对优势进行不对称打击。然而,63 公吨的重量与燃气涡轮的后勤需求,使得此一战力必须依附于特定的地形与基础设施,这为「战车无用论」与「地形限制论」提供了事实弹药。
### 采购案:2019 年军售突破
**事实内容**:
- 时间:2019 年 7 月,美国国务院批准;2020 年 12 月签署发价书(LOA)
- 内容:108 辆 M1A2T 战车、14 辆 M88A2 装甲救济车、250 枚 FGM-148 标枪飞弹等配套
- 总价:约 20 亿美元(新台币约 600 亿元)
- 交付时程:原订 2024 年首批 38 辆交机,2025 与 2026 年各 35 辆;实际因供应链与测试延宕,2024 年底首批抵台,预计 2027 年前后完成全数交付
- 部署单位:陆军装甲第 584 旅(新竹湖口)与装甲第 564 旅(高雄冈山),负责北南部反击任务
**脉络层**:
108 辆的数字并非随机构成,而是替换陆军现役最老旧的 M60A3 战车(约 200 辆中的一部分)。此案的政治背景必须置于 2019 年的美中对抗升级期:
1. **与 F-16V 的套装效应**:2019 年 8 月,美方紧接着宣布售台 66 架 F-16V。战车与战机同时获批,是川普政府将台湾作为印太战略支点的政治宣示,而非单纯的军事需求排序。
2. **陆军的军种政治**:在空军获得 F-16V、海军推动潜舰国造之际,陆军若不提出高单价的重大建案,将面临预算与政治边缘化。M1A2T 是陆军在「不对称战略」浪潮中,维持军种规模与预算话语权的防卫性采购。
3. **美方的红线管理**:M1A2 属于进攻性色彩较强的重型装备,美方在此案前曾多年拒绝售台 M1A1。2019 年的转向,反映美方对北京红线评估的变动——战车被视为「防卫性反击」而非「跨海进攻」。
**分析如何用此事实**:
1. 20 亿美元对陆军是空前负担。分析台湾国防预算排挤时,必须将 M1A2T 的后续维持费(高油耗、特殊零件)计入,这直接压缩了陆军采购反装甲飞弹与机动防空系统的财务空间。
2. 108 辆仅能编成 2 个战车营,无法全面替换老旧战车。这意味着陆军将出现「精锐核心 + 大量老旧」的二元结构,分析战力时不能假设全陆军皆具备 M1A2T 战力。
3. 详见 `narratives/us_taiwan/arms_sales_dynamics.md`,理解此案如何嵌入美台军售的常态化与升级化趋势。
### 与既有战车的搭配:CM-11 与 M60A3 的残局
**事实内容**:
- 台湾陆军现有约 460 辆 CM-11(勇虎)与约 200 辆 M60A3。
- CM-11(1990 年服役):美制 M60 底盘 + 自制炮塔与射控,属二代战车水准,防护力弱,已出现底盘老化与动力包件停产问题。
- M60A3(1990 年代购入):美制老兵,火炮与射控已严重落后,防护力无法抵御现代反装甲武器。
**脉络层**:
M1A2T 并非用来全换旧车,而是作为「硬核」,与既有的 CM-11/M60A3 形成高低配。在战术想定中,M1A2T 负责担纲反击矛头,老旧战车则担任据点防守或火力支援。然而,这种搭配存在**速度与指挥的断层**:M1A2T 的机动与射控节奏远快于旧车,混编可能导致旧车脱队或成为防线弱点。
**分析如何用此事实**:
1. 「高低配」是预算不足下的妥协,不是最佳战术设计。分析时应注意,老旧战车在战时的存活率极低,可能拖累 M1A2T 的反击节奏。
2. 陆军长期缺乏新装备,M1A2T 的引入将产生「装备代差内的代差」——同一旅内,M1A2T 乘员与 CM-11 乘员的战术素养与操作逻辑完全不同,这是组织适应的隐形成本。
3. 详见 `entities/organizations/roc_army.md`,理解陆军如何在此种装备断层中维持训练与战力。
---
## 叙事层:M1A2T 的多重政治诠释
同一辆 M1A2T,在豪猪战略的光谱上,被不同行动者赋予了截然不同的叙事功能。
### 叙事一:「滩岸歼敌与反击」的传统防卫者
**核心命题**:重装甲是执行反击、歼灭登陆敌军的唯一手段。没有战车,滩岸防御只是被动挨打。
**论述逻辑**:
- 解放军登陆必然伴随两栖装甲车与气垫艇,唯有 120 公厘战车炮能保证击毁
- 轻装步兵与反装甲飞弹无法实施「反击」,只能固守;战车具备机动与冲击力,能将敌人赶下海
- M1A2T 的防护力使其能在敌火下推进,是步兵的「移动掩体」
- 因此,M1A2T 不是「传统武器」,而是「不对称反击的矛头」
**支持者**:台湾陆军装甲兵科、KMT 与 DPP 中的传统国防鹰派、部分强调「重陆军」的退役将领
**反对者视角**:此叙事假设战车能在敌火力(舰炮、无人机、机降反甲组)下顺利推进至滩岸,且忽略了解放军可能绕过坚硬据点、直接渗透城镇与设施的战术。将反击成功系于 108 辆重战车,是脆弱的单点依赖。
### 叙事二:「城镇战移动碉堡」的务实辩护
**核心命题**:M1A2T 的真正价值不在滩岸,而在城镇战中的硬壳防御与火力支点。
**论述逻辑**:
- 台湾西部高度都市化,城镇战不可避免
- 在城镇战中,缺乏重装甲的步兵极易被敌方轻装甲与步兵战车碾压
- M1A2T 在街道中可作为移动碉堡,封锁路口、击穿建筑物掩护、提供绝对火力优势
- 乌克兰战争证明,城镇战中的战车虽非无敌,但缺乏战车的一方伤亡更为惨重
**支持者**:部分吸取乌克兰战争教训的战术研究者、强调「本岛防卫」的本土防卫论者
**反对者视角**:此叙事忽略了城镇战对步兵与战车协同的极高要求。台湾陆军联兵营的步兵比例不足,若步兵无法掩护战车,M1A2T 在城镇中将沦为反装甲飞弹(如俄军柳树飞弹)的猎物。63 公吨的重车在狭窄街道中难以转向,一旦被击毁更会堵塞交通,反成障碍。
### 叙事三:「豪猪战略资源错置」的不对称倡导者
**核心命题**:20 亿美元的 M1A2T 是对豪猪战略的背离,挤占了大量反装甲/防空飞弹的预算,且在第一击下存活率堪忧。
**论述逻辑**:
- 现代反装甲武器(标枪、NLAW、无人机)已使重战车不再是陆战之王
- 63 公吨的战车在台湾受限于桥樑、道路与地形,机动性大打折扣
- 后勤依赖(油料、救济车)使其在战时极易因补给线被切断而瘫痪
- 同样 20 亿美元,可购买数千枚标枪与刺针飞弹,形成更密集的杀伤网
- 详见 `narratives/us_taiwan/porcupine_strategy.md`
**支持者**:美方不对称战略倡导者(如 CSIS 兵棋推演团队部分成员)、台湾部分本土防卫论者、DPP 内部倾向「刺猬/豪猪」的青壮派
**反对者视角**:此叙事过度神化便携飞弹。乌克兰战争同样证明,仅靠飞弹无法夺回失地,最终仍需装甲兵力实施反攻。且飞弹的射程与持续火力远不及战车炮,若敌方步兵战车在城镇中推进,缺乏战车的一方只能不断后退。
### 叙事四:「打破现状」的挑衅
**核心命题**:M1A2 具备强大的攻击能力,属进攻性武器,改变了台海陆战平衡。
**论述逻辑**:
- M1A2 是美军现役主力,其火力与防护远超「防卫需求」
- 此举鼓励台独势力以武力拒统,增加两岸军事冲突风险
- 违反中美之间对台军售的限缩默契
**支持者**:北京官方、部分国际现实主义学者
**反对者视角**:此叙事将防御性反击等同于进攻。M1A2T 缺乏跨海投射能力,其存在仅能用于本岛反击,无法对大陆构成主动威胁。且中方自身两栖装甲兵力(如 05 式两栖战车)已大量服役,所谓「改变平衡」实为中方单方面军力扩张的结果。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:「滩岸决战」与「城镇防卫」的教条冲突
M1A2T 的战术定位在陆军内部存在根本性的教条冲突。
**内在矛盾**:
- 若依「滩岸决战」教条,M1A2T 应部署于新竹湖口与高雄冈山,战时迅速向海岸线机动,实施反击
- 但现代登陆作战多采取「多点突入与垂直包围」,解放军可能绕过装甲旅集结地,直接机降台北港或重要设施
- 若依「城镇防卫」教条,M1A2T 应分散配属给守备旅,固守城镇路口
- 但分散配属将丧失装甲兵「集中使用」的冲击力,且 108 辆分散后毫无数量优势
**分析如何用此矛盾**:
分析 M1A2T 的战力时,不要问「它能不能打赢」,而要问「它被放在哪里」。部署位置决定了它的战术角色,也决定了它的存活率。若陆军坚持「集中反击」,M1A2T 可能在机动途中遭无人机与长程火力摧毁;若分散固守,则可能被步兵战车与反甲组蚕食。
### 矛盾二:机动性与地形的根本冲突
**内在矛盾**:
- M1A2T 的战术价值建立在「机动反击」上,但 63 公吨的重量与台湾的西部地形存在先天矛盾
- 台湾西部河川密布(如浊水溪、高屏溪),战时桥樑是首要被毁目标
- 若桥樑中断,M1A2T 无法渡河,将被困于河川一侧,反击成为空谈
- 为解决此问题,陆军需编列预算强化桥樑与道路,但这等于向敌方预告了战车的机动路线
**结构性命题**:
**M1A2T 不是「机动打击」力量,而是「有条件机动」的半固定火力点。** 它的机动性受制于基础设施的完整度。在开战初期,M1A2T 更可能扮演「装甲列车」式的角色——沿着预设的完好道路与阵地移动,而非在野外任意驰骋。这使得「反击」的战术弹性大幅受限。
### 矛盾三:军种存活与战略转型的张力
**内在矛盾**:
- 豪猪战略要求陆军转型为「轻装、机动、分散」的飞弹化部队
- 但 M1A2T 的采购,代表陆军仍将大量预算与人力锁在「重装、集中、后勤依赖」的装甲结构中
- 陆军之所以争取 M1A2T,部分原因是维护装甲兵科的建制正当性——若全转向飞弹,装甲兵科将面临裁撤或降级
**结构性命题**:
**M1A2T 是台湾陆军在「豪猪战略」浪潮中的「组织防卫机制」。** 它的采购,军事逻辑次于军种政治逻辑。陆军必须证明「重装甲仍有用」,才能在国防预算中保住份额。这不是台湾独有的现象,全球陆军在面对不对称战略转型时,皆出现类似的组织抗拒。
---
## 对比分析:M1A2T vs 不对称武器(标枪/刺针)
详见 `entities/equipment/stinger_javelin_taiwan.md` 与 `narratives/us_taiwan/porcupine_strategy.md`。
### 对比一:M1A2T vs 标枪飞弹
| 维度 | M1A2T 战车 | 标枪飞弹 | 分析含义 |
|---|---|---|---|
| 成本 | 单价约 1800 万美元 + 后勤 | 单价约 15 万美元(飞弹) | 标枪的成本效益在「反装甲」单项上远高于战车 |
| 机动与地形 | 受限桥樑、道路、耗油 | 步兵便携,不受地形限制 | 标枪符合豪猪战略的「分散机动」原则,战车则否 |
| 持续火力 | 40 发 120mm 炮弹,可反装甲/反步兵/破墙 | 单发,发射后需重装 | 战车具备多目标与持续压制能力,标枪仅能反单一高价值目标 |
| 战术功能 | 反击、冲击、移动掩体 | 伏击、点杀伤 | 标枪无法夺回阵地,战车可以 |
| 生存性 | 易被无人机与长程火力锁定 | 分散隐蔽,存活率高 | 生存性是豪猪战略的核心指标,标枪在此完胜 |
**破除常见简化**:
「M1A2T 很贵所以没用,应全买标枪」——这是错的。乌克兰战争证明,标枪能有效伏击战车,但无法守住城镇防线。缺乏战车的步兵,在敌方步兵战车与火炮推进时只能后退。战车与标枪是互补关系,非替代关系。真正的争议不在于「要不要战车」,而在于「在有限预算下,多少战车与多少标枪的组合才是最优」。
### 对比二:城镇战中的角色差异
| 维度 | M1A2T | 反装甲飞弹小组 | 无人机 |
|---|---|---|---|
| 城镇战功能 | 封锁路口、破墙、提供步兵掩体 | 伏击街道上的敌方装甲 | 侦察、投弹、引导火炮 |
| 弱点 | 顶部装甲薄弱,易被无人机与顶攻飞弹击毁 | 视距受限,无法穿透建筑物 | 易受电子干扰,无法提供持续火力 |
| 协同需求 | 需步兵保护侧翼与顶部 | 需步兵掩护射击阵地 | 需操作员与通讯链路 |
**分析如何用此对比**:
1. 将 M1A2T 与标枪视为互斥,是分析上的懒惰。在城镇战想定中,M1A2T 负责正面冲击与火力压制,标枪负责侧翼伏击,无人机负责顶部侦察与攻击,三者构成完整的防御体系。
2. 但预算有限。当 20 亿美元投入 M1A2T,标枪与无人机的采购量必然受压缩。分析者应关注「机会成本」,而非仅评估「单兵战力」。
---
## 豪猪战略辩论中的 M1A2T
### M1A2T 作为「大型平台」的典型争议
在豪猪战略的辩论光谱上,M1A2T 是比 F-16V 更极端的争议案例。F-16V 至少有「制空权是防卫前提」的共识,M1A2T 则连「重装甲在本岛是否有用」都缺乏共识。
**豪猪纯粹主义者的批判**:
- 20 亿美元可买超过 1300 枚标枪飞弹,或数万架自杀式无人机
- 战车是「铁棺材」,在无人机与标枪面前生存率趋近于零
- 台湾地形不适合重战车,买 M1A2 等于买陆上堡垒
- 结论:应放弃重装甲,全陆军飞弹化与无人机化
**传统防卫者的反驳**:
- 乌克兰战争证明,战车仍是城镇战与反击的必要核心
- 标枪无法守卫据点,飞弹打完就没了,战车炮弹可持续压制
- 放弃重装甲等于放弃反击能力,只能被动挨打
- 结论:没有战车的豪猪,是被拔掉刺的豪猪
### M1A2T 在辩论中的「妥协位置」
与 F-16V 类似,M1A2T 的采购反映了美台双方的妥协:
- 美方批准 M1A2T,但数量仅 108 辆,远低于陆军全换装的需求(约 300-400 辆),这是「给你重装甲,但只给精锐核心」的限缩
- 台方接受 108 辆作为「象征性突破」,同时被迫采购大量不对称武器(如 2022 年的大批飞弹军售)
**分析含义**:
理解台湾陆军当前的兵力结构,必须看穿「豪猪战略」的话语外衣。M1A2T 的存在,使得台湾在名义上接受豪猪战略,在实际上仍维持传统重装甲的核心。这种**「话语上的不对称 vs 实践上的全谱系」**是台湾国防政策的核心张力,也是 2028 选举中各阵营无法回避的结构性矛盾。
---
## 未来叙事:2028 选举与陆军转型
### M1A2T 在选举中的潜在角色
M1A2T 预计于 2027 年前后完成交付,正好是 2028 选举的前夕。其叙事可能在选举中被多方运用:
**叙事一:「重装甲威慑」的安全保证**
- 主张:M1A2T 抵台,代表台湾陆军具备反击能力,是吓阻共军登陆的实质保证
- 逻辑:展示美台合作成果,强调国防现代化
- 潜在风险:若交机延宕或出现技术问题,将反噬成为「政府无能」的攻击点
**叙事二:「资源错置」的批判**
- 主张:20 亿买铁棺材,不如买飞弹与无人机
- 逻辑:攻击执政党国防政策向传统军种倾斜,忽视不对称战力
- 潜在风险:KMT 若采此论述,可能被反批为「削弱国防」;DPP 若采此论述,则自我矛盾(因采购案在 DPP 任内启动)
**叙事三:「城镇战准备」的务实路线**
- 主张:M1A2T 是城镇防卫的必要硬壳,应强化与步兵、无人机的协同训练
- 逻辑:跳过「要不要战车」的争论,直指「如何用好战车」
- 潜在风险:承认城镇战可能性,可能引发民众恐慌与「放弃滩岸」的质疑
### 分析含义
1. **M1A2T 是「对美信任度」的试金石**:交机进度、技术降规与否、后勤支援到位情况,都将成为 2028 选举中检验美台关系的指标。
2. **军种政治的选举化**:陆军是否会为了维护 M1A2T 的预算与地位,而在选举期间释放「战力不足」的讯息?这是分析国防预算分配时需警惕的变量。
---
## 关联分析
### 关联一:与台湾陆军的组织命运连结
详见 `entities/organizations/roc_army.md`。
- 功能:提供结构层与用于交叉验证
- M1A2T 决定了陆军未来 20 年的装甲兵力核心。陆军的预算争取、人才招募、战术发展,皆围绕此一平台。
- 交叉验证:若出现「联兵旅改编」或「步兵旅增编反甲飞弹」的新闻,应回溯检视 M1A2T 的部署是否改变了陆军的兵力结构平衡。
### 关联二:与美对台军售动态的结构性连结
详见 `narratives/us_taiwan/arms_sales_dynamics.md`。
- 功能:提供事实层与脉络层
- M1A2T 案是 2019 年军售扩张期的标志性事件,理解此案的时间点(川普时期、美中贸易战)与美方「节奏与品项」的调整,必须置于军售动态的整体框架中。
- 此案也是「品项升级」(从轻战车转向重战车)的典型案例,反映了美方对台军售限制的松动。
### 关联三:与豪猪战略辩论的张力连结
详见 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:用于对照与提供内在矛盾
- M1A2T 是豪猪战略的「反面教材」或「必要前提」,取决于分析者的立场。
- 分析时,应将 M1A2T 视为豪猪战略辩论中的「常数」——无论论述如何,此平台已存在且已投入巨资,后续决策只能在既成事实上调整。
### 关联四:与不对称武器的对照连结
详见 `entities/equipment/stinger_javelin_taiwan.md`。
- 功能:用于对照与交叉验证
- M1A2T 与标枪/刺针的预算排挤关系,是评估台湾国防资源配置的关键。
- 交叉验证:当标枪飞弹采购数量增加时,是否意味着 M1A2T 的后续维持费被压缩?反之亦然。
### 关联五:与北京反应类型学的互动
- 功能:提供脉络层
- 2019 年 M1A2T 案批准后,北京的反应模式:官方强烈抗议(第二层),但未触发大规模军演(第五层)。
- 这显示北京对「重战车军售」的红线反应已有惯例化趋势,除非未来跨越至更具进攻性的武器(如主战车搭载的长程攻陆飞弹),否则反应可能停留在既定层级。
### 关联六:与国防预算排挤效应的因果连结
- 功能:提供结构性命题
- 20 亿美元的承诺,意味着未来十年的国防预算中,有相当比例被「锁定」用于支付此案的后续维持。
- 这直接排挤了其他军种(如海舰的维护、空军的弹药补充)的预算空间。
- 分析台湾国防政策时,不能只看「买了什么」,要看「因为买这个,所以没买什么」。
---
## 常见的分析陷阱
### 陷阱一:将 M1A2T 等同于陆战优势的恢复
M1A2T 提升了单点火力与防护,但不等于恢复了台湾的陆战优势。陆战是体系产物(步兵、炮兵、防空、工兵、后勤)。将单一平台的性能提升等同于战略优势恢复,是技术决定论的谬误。108 辆 M1A2T 无法覆盖台湾数百公里的海岸线与城镇。
### 陷阱二:在「战车 vs 飞弹」的辩论中选边
将 M1A2T 视为「完全必要」或「完全浪费」皆是简化。它是政治妥协与军种政治的产物,兼具城镇战威慑价值与资源排挤风险。分析者应呈现其双重效应,而非加入其中一方阵营。真正的分析焦点应是「组合比例」与「战术教条」的适配性。
### 陷阱三:忽略基础设施的「隐形成本」
M1A2T 的 20 亿美元只是采购价。为了让 63 公吨的战车能跑,台湾需编列大量预算强化桥樑、道路、油库与维修厂。这些隐形成本常被忽略,导致低估 M1A2T 对国防预算的实际排挤效应。
### 陷阱四:假设 M1A2T 在战时能顺利机动至反击阵地
和平时期的机动演练,在战时将面临截然不同的条件:桥樑被毁、道路堵塞、无人机监控、敌火阻绝。假设 M1A2T 能如演习般顺利从湖口机动至海岸线,是严重的乐观偏见。分析时应将「机动受阻」视为常态而非例外。
### 陷阱五:低估燃气涡轮发动机的后勤灾难
M1A2T 的燃气涡轮发动机耗油量极大,且对空气滤清系统要求极高(台湾沙尘与高湿度环境不利)。战时若油料补给线被切断,或滤清器损耗过快,M1A2T 将迅速丧失机动性,成为固定火力点。以柴油发动机战车的后勤经验推算 M1A2T,会严重高估其持续战力。
### 陷阱六:将乌克兰战争经验直接套用于台湾
乌克兰战争确实证明战车在城镇战中仍有价值,但乌克兰是广袤平原,战车有机动空间;台湾是狭长岛屿,城镇紧邻海岸,且河川分割地形。直接套用乌战经验,忽略地形差异,会导致战术想定失准。
---
## 仍不确定的部分
- **贫铀装甲是否降规**:美方是否提供贫铀装甲予 M1A2T,仍属机密。若降规为复合装甲,其防护力将显著下降,直接影响存活率评估。此点目前仅有猜测,无实证。
- **城镇战协同战术的成熟度**:台湾陆军联兵营的步兵比例偏低,步战协同训练是否足以支撑城镇战需求,仍不确定。M1A2T 若缺乏步兵掩护,在城镇中极易被猎杀。
- **无人机对重装甲的威胁演进速度**:穿越机(FPV)与反装甲无人机的发展极为迅速,未来 3-5 年内,重装甲在无人机面前的生存率可能进一步下降。M1A2T 的主动防御系统(若配备)能否应对,仍是未知数。
- **桥樑与道路的战时抢修能力**:台湾工兵部队的战时抢修速度,直接决定 M1A2T 的机动范围。此一能力目前缺乏公开验证,且受限于材料储备与人员训练。
- **油料战时储备与分配机制**:M1A2T 需要大量 JP-8 航空燃油,战时油库将是首要被打击目标。台湾的油料分散储存与战时分配机制,是否足以支撑装甲旅的反击行动,仍高度不确定。
---
## 与其他文件的关系
- 美对台军售动态:`narratives/us_taiwan/arms_sales_dynamics.md`(提供 M1A2T 案在军售历史中的定位与政治背景)
- 台湾陆军:`entities/organizations/roc_army.md`(提供 M1A2T 的使用军种结构与组织文化脉络)
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md`(提供 M1A2T 所在的战略辩论框架与不对称论述)
- 不对称武器:`entities/equipment/stinger_javelin_taiwan.md`(提供 M1A2T 在预算与战术上的对照系)
- 北京反应类型学:`primitives/frameworks/beijing_response_typology.md`(提供预测北京对 M1A2T 案反应的分析框架)
- 疑美论:`narratives/cross_strait/skepticism_of_us.md`(提供 M1A2T 降规与延迟被诠释为疑美论证据的脉络)
- 战略模糊:`primitives/concepts/strategic_ambiguity.md`(提供 M1A2T「重装但不具进攻性」的政策逻辑基础)
---
## 一句话浓缩
> M1A2T 是台湾陆军在豪猪战略浪潮中紧抓的**身份锚点**,也是「大型平台 vs 不对称」辩论的**结构性强辩场**。
> 它的政治意义远超其战术价值——既是填补战力代差、提供城镇战硬壳的务实选择,也是挤占资源、受制地形与后勤的结构性负担。
> 分析 M1A2T,不是算它能不能打赢登陆部队,而是看它如何**锚定陆军的组织命运、预算分配与战略定位**。
---
last_updated: 2025-05-19
@@ -0,0 +1,77 @@
# entities/foreign_actors/ 外国行动者
外国政府、政治人物、机构、智库的档案。重点是其与台湾的关联、对台立场、近年动态。
## 文件清单
### 美国 - 行政部门
- `us_white_house.md` — 白宫对台政策的决策路径
- `trump_administration_2.md` — 川普 2.0 内阁与对台政策不确定性
- `us_state_department.md` — 国务院:东亚局与台美关系
- `us_dod.md` — 美国国防部:印太战略与台湾角色
- `ait.md` — 美国在台协会:实质大使馆的运作
- `nsc_us.md` — 美国国家安全会议中的台海议题
### 美国 - 国会
- `us_congress_taiwan_caucus.md` — 国会台湾连线与跨党派支持
- `us_senate_key_figures.md` — 关键参议员(亲台/反共/谨慎派)
- `us_house_key_figures.md` — 关键众议员
- `us_legislation_on_taiwan.md` — 涉台立法的清单与影响
### 美国 - 政治人物
- `donald_trump.md` — 川普对台立场的多次转折
- `jd_vance.md` — 万斯副总统的孤立主义倾向与对台
- `marco_rubio.md` — 鲁比奥国务卿与对台立场
- `key_2028_us_presidential_aspirants.md` — 2028 美国可能总统人选与各自台湾立场
### 美国 - 智库
- `csis.md` — 战略与国际研究中心:台湾计画与年度报告
- `brookings.md` — 布鲁金斯学会:台海议题学者群
- `cfr.md` — 外交关系协会
- `hudson_institute.md` — 哈德逊研究所:偏鹰派
- `heritage_foundation.md` — 传统基金会:保守派、近年对台态度变化
- `rand_corporation.md` — 兰德公司:兵推与情境分析
- `aei.md` — 美国企业研究所
- `quincy_institute.md` — 昆西研究所:克制派、对台政策辩论中的特殊声音
- `german_marshall_fund.md` — GMF:跨大西洋视角下的台湾
- `key_us_taiwan_scholars.md` — 美国关键台海学者清单(葛来仪、卜睿哲等)
### 日本
- `japan_pm_office.md` — 日本首相官邸对台政策
- `japan_mofa.md` — 日本外务省与对台关系
- `japan_mod.md` — 日本防卫省与台海有事
- `japan_jaa_taipei.md` — 日本台湾交流协会
- `japan_lpd_taiwan_committee.md` — 自民党台湾政策委员会
- `key_japanese_politicians.md` — 关键日本政治人物(前首相、对台友好派)
- `sasakawa_peace_foundation.md` — 笹川和平财团
- `japan_inpex_and_taiwan_studies.md` — 日本主要台湾研究机构
- `japan_taiwan_caucus_legislators.md` — 日华议员恳谈会成员
### 中国大陆
- `ccp_central_taiwan_work_office.md` — 中央对台工作领导小组
- `tao_taiwan_affairs_office.md` — 国台办:发言模式与决策角色
- `ccp_united_front.md` — 中共统战部对台部分
- `pla_eastern_theater.md` — 解放军东部战区
- `xi_jinping_taiwan_policy.md` — 习近平的对台政策演变
- `wang_huning_role.md` — 王沪宁与对台理论
- `key_chinese_taiwan_scholars.md` — 大陆涉台学者群(清华、人大、社科院等)
- `chinese_state_media_on_taiwan.md` — 大陆官媒涉台报导特征(人民日报/环球时报/中评社)
- `cctv_taiwan_strait_program.md` — 央视《海峡两岸》节目:风向指标
- `huanqiu_global_times.md` — 环球时报:对台强硬声音的代表
- `china_review_news.md` — 中评社:港媒涉台中介角色
### 欧洲与其他
- `eu_external_action_service.md` — 欧盟对外事务部
- `key_eu_member_states_taiwan.md` — 关键欧盟成员国对台政策(德/法/捷/立陶宛/波兰)
- `uk_taiwan_policy.md` — 英国对台政策
- `australia_taiwan_policy.md` — 澳洲与台湾
- `canada_taiwan_policy.md` — 加拿大与台湾
- `india_taiwan_relations.md` — 印度与台湾的渐进发展
- `southeast_asia_taiwan.md` — 东南亚国家对台立场(菲/越/印尼/星)
- `philippines_special.md` — 菲律宾在第一岛链的关键角色
### 国际组织与多边
- `g7_on_taiwan.md` — G7 公报中的台湾相关表述演变
- `un_resolution_2758.md` — 联合国 2758 号决议的解释战
- `who_taiwan_exclusion.md` — WHO 与台湾参与争议
- `icao_taiwan.md` — 国际民航组织与台湾
@@ -0,0 +1,381 @@
# 美国在台协会(American Institute in Taiwan, AIT)
---
**类型**: 机构档案
**何时调用**: 分析美台官方互动管道、AIT 馆长涉台发言、美方对台态度的"日常温度"、川普 2.0 时期 AIT 的运作变化
---
## 一句话浓缩
AIT 是法律上的"民间非政府组织",实质上的"美国在台大使馆"——这种"法律拟制"是美台关系所有运作的基础形式。理解 AIT 的"双重身份"、馆长头衔的特殊性、人员组成的官方背景,是理解美台日常关系的钥匙。
---
## AIT 的法律性质(关键基础)
### 设立基础
AIT 由 **1979 年台湾关系法第 6 条**授权设立:
- 注册为美国哥伦比亚特区的**非营利组织**
- **不是政府机构**(法律上)
- 但**实质执行外交职能**
### 双重身份
| 维度 | 法律身份 | 实质身份 |
|---|---|---|
| 性质 | 非营利组织 | 美国大使馆 |
| 资金 | 国务院"资助" | 政府预算 |
| 人员 | "雇用"美国官员 | 国务院、国防部派任 |
| 职能 | "民间交流" | 全部外交职能 |
**分析含义**:AIT 的"双重身份"是 1979 年的法律设计——让美方能维持"非官方"形式但执行实质外交。这种"法律拟制"是美台关系的核心运作机制。
详见 `primitives/concepts/taiwan_relations_act.md`。
---
## AIT 的组织结构
### 总部与台北办事处
- **华盛顿总部**(Washington Headquarters):在维吉尼亚州阿灵顿
- **台北办事处**(Taipei Office):在台北内湖区(2019 启用新馆舍)
- **高雄分处**(Kaohsiung Branch Office):南台湾涉外事务
### 主要职位
- **理事主席**(Chairman):在华盛顿,对全 AIT 负责
- **执行理事**(Managing Director):在华盛顿
- **台北办事处处长**(Director, Taipei Office):常被称"AIT 馆长"——实质大使
- **台北办事处副处长**(Deputy Director):实质副大使
- **政治组、经济组、文化组、领事组**等:与一般大使馆相同
### 人员来源
AIT 人员**实际上多由国务院派任**:
- 处长(馆长)由资深职业外交官出任
- 政治组、经济组等由国务院官员"借调"
- 国防部官员也常有派驻
- 法律上是"AIT 雇员",实质是美国政府官员
**分析含义**:AIT 人员的"专业外交官"背景让其运作具高度专业性——但他们仍是"AIT 雇员",不能以"美国政府官员"身份出现。
---
## "AIT 馆长"的特殊地位
### 头衔的政治学
AIT 馆长**不是大使**——但实质执行大使职能:
- 公开场合通常称"AIT Director"或"美国在台协会处长"
- 不能正式称"Ambassador"
- 但**外界(包括媒体)常称"美国驻台大使"** ——这是非正式用法
### 历任馆长
历任 AIT 馆长多为资深职业外交官,部分有更高政治分量:
- 早期(1979-1990s):低调、专业
- 1990s 后:增加政治分量
- 2000s 后:常由曾任驻外大使的资深外交官出任
- 2018 后(川普 1.0):象征性升级(如 2018 新馆舍启用、2020 现役军人首次公开制服出席等)
**分析含义**:AIT 馆长的层级与背景**反映美方对台关系的重视程度**——是观察指标。
### 馆长公开活动的政治意义
AIT 馆长在台湾的公开活动**有明确的政治意义**:
- 公开场合出席(特别是政治性活动)= 美方政治支持
- 与台湾政治人物公开互动 = 关系层级信号
- 公开发言的措辞 = 美方立场的"日常版本"
**分析含义**:分析馆长公开活动时,要看其**与历史惯例的对比**——是否升级、降级、维持。
---
## AIT 与台北驻美机构(TECRO)的对应关系
### TECRO 的对应角色
台北方面对应 AIT 的机构是 **驻美国台北经济文化代表处(Taipei Economic and Cultural Representative Office, TECRO)**:
- 设立基础:与 AIT 对等、双向"民间组织"
- 总部在华盛顿,多个分处
- 实质执行台湾驻美外交职能
### "对等不对称"的运作
AIT 与 TECRO 在法律上对等——但**实际地位不完全对称**:
- AIT 馆长在台湾享受"近似大使"待遇(高层接待、特殊礼遇)
- TECRO 代表在美国的待遇**受美方限制**(不能进入美国国务院主要建筑等)
- 这种"待遇不对称"反映美台关系的"非对等"性质
**分析含义**:分析 TECRO 代表的待遇变化——是观察美台关系松绑的指标。
详见 `entities/foreign_actors/tecro.md`(占位)。
---
## AIT 的实际职能
### 一、外交沟通管道
最核心功能:
- **日常沟通**:与台北所有重要部会的对接
- **危机管理**:重大事件时的紧急沟通
- **政策传达**:将美方立场传达给台北
- **信息收集**:将台湾情势分析报告华盛顿
**分析含义**:AIT 是美台关系的**"日常运作中枢"**——其表现的稳定性影响整体关系。
### 二、领事职能
- 美国公民在台事务
- 签证(赴美签证)
- 与台湾政府的领事协调
### 三、安全合作
- 与台湾国防部、国安体系的对接
- 军售案的协调
- 演训合作的协调
### 四、经济与商务
- 美台贸易投资协调
- 半导体、科技合作
- 经济政策对接
### 五、文化与公共外交
- 美国文化推广
- 教育交流(傅尔布莱特等)
- 公共外交(媒体合作等)
### 六、"非官方"性的维持
虽然实质是大使馆,但 AIT 必须**形式上维持"非官方"**:
- 公开活动避免使用"大使馆"语言
- 文件不用"国对国"形式
- 重大政治表态需要华盛顿核准
**分析含义**:这种"形式维持"看似繁琐,但是美台关系的政治基础——任何"形式上的官方化"都会引发北京强烈反应。
---
## AIT 在历史关键时刻
### 1995-96 飞弹危机
- AIT 在危机中扮演重要协调角色
- 与台湾政府、华盛顿之间的紧急沟通
- 此后 AIT 的"危机管理"职能强化
### 2000 政党轮替
- 陈水扁上台后,AIT 在两岸议题上"提醒台北谨慎"
- 美方对陈水扁的不满通过 AIT 传达
- 这种"美方约束台北"的角色当代仍有
### 2018 新馆舍启用
- 内湖新馆舍正式启用
- 现役美军人员首次在公开仪式中出席(虽未制服)
- 象征美台关系升级
### 2020 中天事件期间
- AIT 在媒体争议中"保持距离"
- 显示美方不主动介入台湾内部媒体争议
### 2024 赖清德就职
- AIT 出席就职典礼(按惯例)
- 后续与赖政府建立工作关系
- 中线消失后的危机管理新挑战
---
## 关联分析
### 关联一:与 TRA 的不可分关系
AIT 是 TRA 的产物——其存在与运作都依赖 TRA:
- TRA 不修改 → AIT 不会被取消
- TRA 执行强度变化 → AIT 工作强度变化
- TRA 框架内 → AIT 可调整运作但不超越
**分析含义**:分析 AIT 时,TRA 是基础前提。
详见 `primitives/concepts/taiwan_relations_act.md`。
### 关联二:与"一中政策"的协调
AIT 的"非官方"形式 = 一中政策的具体实践:
- 一中政策不承认台湾国家地位
- AIT 不能是"大使馆"——必须是"民间组织"
- 这种法律拟制让一中政策与 TRA 共存
**分析含义**:任何"AIT 升级为官方"的提议都直接挑战一中政策——所以这种调整极少发生。
### 关联三:与川普 2.0 的潜在张力
川普 2.0 时期 AIT 的潜在变化:
- 总体看:AIT 运作高度制度化——不易被改变
- 可能影响:
- 馆长人选(鹰派 vs 克制派)
- 公开活动的频率与层级
- 与台北沟通的强度
- 危机管理的具体方式
**分析含义**:AIT 的稳定性源自专业体系——但**馆长人选与"运作风格"**仍可调整。
详见 `narratives/us_taiwan/trump_taiwan_uncertainty.md`。
### 关联四:与北京反应的关系
北京对 AIT 的态度:
- 法律上:北京不承认 AIT 是官方机构(与一中原则一致)
- 实际上:北京承认 AIT 的实质角色
- 北京施压:通过抗议 AIT 馆长公开活动等方式持续表达不满
- 但**不会推动取消 AIT**——因这会触发美方更强反应
**分析含义**:北京对 AIT 的"被动接受 + 持续抗议"是稳定模式。
### 关联五:与"美方非官方协调"的关系
AIT 是美方"非官方协调"的核心,但**不是唯一管道**:
- **第二轨道沟通**:智库、学者、退休官员
- **国会管道**:国会议员访台
- **军事管道**:印太司令部对台沟通
- **AIT**:日常官方管道
**分析含义**:分析美台沟通时,AIT 是核心但要看多管道——重要议题常多管道并行。
详见 `narratives/us_taiwan/us_taiwan_back_channels.md`(占位)。
### 关联六:与美方人事的连动
AIT 高层人事**反映美方对台政策**:
- 派资深、知名的外交官 → 重视台湾
- 派较低层级 → 降低重视
- 馆长是否曾任驻外大使 → 政治分量
**分析含义**:观察 AIT 高层人事变化是政策方向的指标——比公开声明更稳定。
---
## 实务分析:用 AIT 视角解读
### 应用一:观察 AIT 馆长公开活动
| 活动类型 | 信号意义 |
|---|---|
| 出席总统就职 | 标准 |
| 出席政治集会 | 罕见,强信号 |
| 与具体政治人物公开会面 | 有信号意义 |
| 在敏感时机出席仪式 | 信号强 |
| 公开发表涉台政策声明 | 美方立场宣示 |
观察这些活动**与历史惯例的对比**——是分析关键。
### 应用二:解读 AIT 公开声明
AIT 公开声明的措辞特征:
- 措辞与国务院一致 → 标准立场
- 措辞强于国务院 → 馆长个人风格或对台升级信号
- 措辞弱于国务院 → 谨慎信号
### 应用三:观察 AIT 与台湾官员互动的"频率与层级"
- 与总统府的频次:高 → 关系密切
- 与外交部的层级:高层 → 重要议题处理
- 与立法院的接触:增加 → 美方关切立法
### 应用四:识别"AIT 形式调整"
任何"AIT 形式调整"都是政治信号:
- 改名(如"美国驻台办事处")→ 重大调整
- 馆长头衔变化 → 中等调整
- 标志、徽章调整 → 微调
- 内部工作流程调整 → 行政变化
观察这些调整的**方向**(朝官方化或朝民间化)。
---
## 常见的分析陷阱
### 陷阱一:把 AIT 当大使馆
法律上不是。这种区分有重要的政治含义。
### 陷阱二:把 AIT 当民间组织
实质上是大使馆。把它当真正的民间组织会低估其角色。
### 陷阱三:忽略"双重身份"的政治学
AIT 的整套运作建立在"形式 vs 实质"区分上——分析时不能忽略。
### 陷阱四:把馆长发言当个人意见
AIT 馆长公开发言通常代表华盛顿立场——不是个人意见。
### 陷阱五:低估 AIT 的危机管理角色
危机时 AIT 是关键——但这种角色不公开。分析时要考虑"看不见的工作"。
### 陷阱六:把 AIT 升级当"不可能"
历史上确有调整(馆舍升级、人员层级变化等)——不是完全不变。但调整的边界仍受一中政策约束。
---
## 仍不确定的部分
- **川普 2.0 时期 AIT 馆长人选**:影响运作风格
- **重大危机时 AIT 的应对能力**:未经充分测试
- **AIT 与川普风格的协调**:可能与传统外交风格有张力
---
## 与其他文件的关系
- 台湾关系法:`primitives/concepts/taiwan_relations_act.md`
- 美国一中政策:`primitives/concepts/us_one_china_policy.md`
- 川普 2.0 不确定性:`narratives/us_taiwan/trump_taiwan_uncertainty.md`
- 美对台政策分析框架:`primitives/frameworks/us_taiwan_policy_analysis.md`
- 鲁比奥档案:`entities/foreign_actors/marco_rubio.md`
- 萧美琴档案:`entities/people/hsiao_bi_khim.md`
- 美方非官方管道:`narratives/us_taiwan/us_taiwan_back_channels.md`(占位)
---
## 一句话浓缩
> AIT 是**法律上的民间组织 + 实质上的美国大使馆**——这种"法律拟制"是美台关系的根本运作机制。
> 分析 AIT 时不能只看其声明——要看人员层级、公开活动惯例、与历史的对比。
> AIT 的"形式调整"是美方对台政策的**敏感信号**——比许多政策声明更重要。
---
last_updated: 2026-05-08
@@ -0,0 +1,284 @@
# 北京商務部及其對台制裁工具
---
**類型**: organization
**當前角色**: 對台經濟脅迫的法定執行者與「武器化相互依賴」的機構化樞紐
**何時調用此檔案**: 當分析者需要判定對台經濟限制措施的法律/制度來源時;當需要區分「常規貿易救濟」與「政治性經濟制裁」的機構動機時;當評估 ECFA 框架的法律解構過程時;當分析不可靠實體清單、出口管制等工具的升級路徑與反向效應時;當需要拆解北京跨部門(商務部、國台辦、海關總署)協調機制時
---
## 一句話定位
北京商務部不是單純的貿易監管機構,而是將政治紅線轉譯為「程序合規」經濟制裁的機器——它讓經濟脅迫披上法律外衣,使政治施壓具備了可升級、可逆轉且難以在國際爭端機制中直接訴訟的結構特徵。
---
## 機構性質與制度張力
### 事實層:法律性質與實質角色
北京商務部(MOFCOM)在法律上是中華人民共和國國務院組成部門,主管國內外貿易和國際經濟合作。其法定職能包括擬訂貿易政策、進行貿易救濟調查(反傾銷、反補貼、保障措施)、制定進出口管制清單、以及外商投資的管理與審查。
然而,在對台政策執行中,商務部的實質角色已從「貿易監管者」演變為「經濟制裁的工具掌控者」。這種演變並非透過修改商務部組織法實現,而是透過擴張解釋現有貿易防禦工具(如將貿易壁壘調查用於政治施壓)與制定新的國家安全例外清單(如不可靠實體清單)來完成。
### 脈絡層:法律性質 vs. 實質角色的內在矛盾
商務部面臨的核心結構性張力是:**它必須在「維護多邊貿易體系合規性」與「執行黨中央政治施壓指令」之間保持平衡。**
作為全球第一大貿易國的主管部門,商務部有強烈的動機維持 WTO 規則的表面合規,以避免引發國際連鎖報復;但作為黨國體制的執行機構,它又必須回應對台工作的政治需求。這種張力導致商務部的對台行動呈現出獨特的「程序主義」特徵:它極少直接宣布「因為台灣做了 X 所以我們制裁 Y」,而是啟動漫長的調查程序,最終以「符合法規」的形式宣布懲罰。
**破除常見簡化**:把商務部視為國台辦的「橡皮圖章」或被動執行者是錯的。商務部在工具選擇、程序節奏和法律論證上擁有實質的官僚自主性。國台辦決定「要不要打」,商務部決定「怎麼打才合法」。這種自主性有時會導致行動的滯後或力度的折扣,因為商務部必須顧及國際貿易體系的反彈。
### 分析含義
分析商務部的行動時,不應只看宣布結果,必須追蹤其「程序過程」。商務部啟動調查的時機、調查期的長短、結論的措辭,都是解讀北京政治意圖的關鍵指標。當商務部縮短調查期、簡化程序時,代表政治指令的急迫性壓倒了官僚程序的合規性;當商務部延長調查期或低調結案時,代表官僚體系正在為政治降級提供台階。
---
## 組織結構與涉台職能核心
商務部並非全體動員對台,其涉台職能高度集中於幾個特定司局。理解這種內部結構,才能精準定位決策源頭。
### 核心涉台司局
| 司局名稱 | 法定職能 | 涉台實質角色 | 觀察指標 |
|---|---|---|---|
| 對外貿易司 | 擬訂進出口管理政策 | 對台貿易政策調整(如 ECFA 中止)的具體執行 | 進出口許可證管理、兩岸貿易統計發布 |
| 產業損害調查局 / 貿易救濟局 | 貿易救濟調查(反傾銷、反補貼、保障措施) | 對台「貿易壁壘調查」與反傾銷的主力單位 | 調查立案公告、傾銷幅度裁定、臨時措施 |
| 安全與管制局 | 出口管制、不可靠實體清單 | 對台高科技零組件管制、台商實體清單列入 | 出口管制清單調整、不可靠實體清單公告 |
| 外商投資管理司 | 外商投資准入與管理 | 對台商投資審查、國家安全審查 | 台商重大投資案審批停滯、合規稽查 |
| 台港澳司 | 對台港澳經貿政策協調 | 名義上的涉台主管,實質更多是協調與聯絡窗口 | 兩岸經貿交流會議的官方代表 |
**中等模型想不到的洞察**:台港澳司雖然名義上是對台經貿的主管單位,但在制裁工具的運用上,實質權力卻在貿易救濟局與安全與管制局手中。台港澳司的角色更接近「白臉」與「聯絡人」,負責在制裁啟動前後傳遞訊息或安撫台商;而真正扣下扳機的「黑臉」是貿易救濟局。當分析者看到台港澳司頻繁出面發言時,通常代表北京正在試圖控制制裁的升級幅度,而非準備發動新制裁。
### 分析含義
追蹤商務部人事變動時,應重點關注貿易救濟局與安全與管制局的首長異動,而非部長層級的變化。這兩個局的首長若由具有強烈國安背景或法律戰經驗的官員接任,通常預示著對台經濟工具的法律化與升級。
---
## 主要制裁工具深度解析
商務部掌控的對台制裁工具並非單一武器,而是一個從「低可見度行政干預」到「高強度法律戰」的軍火庫。每種工具都有其特定的法律邏輯與政治適用場景。
### 工具一:貿易壁壘調查——法律戰的先鋒
**事實層**:2023 年 4 月,商務部宣布對台灣 2455 項產品進行貿易壁壘調查,指控台灣違反 WTO 規則與 ECFA 相關規定,單方面限制大陸產品進口。調查期原定至 2023 年 10 月,後延長至 2024 年 1 月 12 日(台灣大選前夕)。2024 年 5 月,商務部發布結論,認定台灣構成貿易壁壘,隨後宣布中止 ECFA 部分產品關稅減讓。
**脈絡層**:貿易壁壘調查是商務部近年對台最具戰略意義的工具創新。過去北京對台施壓多依賴海關總署的 SPS(檢疫)措施,但 SPS 措施具有「個案性」與「技術性」限制,難以升級為系統性制裁。貿易壁壘調查則不同,它從根本上挑戰台灣對大陸貿易管制的合法性,為中止 ECFA 提供了「國內法層面」的法律基礎。
**結構性命題**:貿易壁壘調查的本質是**「法律戰的前置程序」**。北京深知在 WTO 框架下直接起訴台灣存在政治與程序障礙(台灣的入會身分與兩岸特殊關係),因此選擇透過國內法程序進行「單邊認定」。這種單邊認定在國際法上或有爭議,但在國內法層面卻完成了「合法化」閉環,使後續的 ECFA 中止不再是「政治報復」,而是「對違規行為的合法反制」。
**分析時如何用**:當商務部啟動貿易壁壘調查時,分析者不應將其視為單純的貿易摩擦,而應視為**「為後續升級行動儲備法律彈藥」**。調查結論的發布時間與範圍,是預測 ECFA 命運的最精準指標。參見 `narratives/cross_strait/ecfa_legacy_debate.md`。
### 工具二:不可靠實體清單(UEL)——懸在台商頭上的達摩克利斯之劍
**事實層**:2019 年商務部宣布建立「不可靠實體清單」制度,針對「違背市場原則、中斷與中國企業正常交易」或「對中國國家安全造成危害」的外國實體。2023 年起,該清單開始實際運作,將參與對台軍售的美國企業(如洛馬、雷神)列入,並實施罰款與進出口限制。
**脈絡層**:不可靠實體清單在設計上具有高度的「選擇性執法」空間。其判定標準(「危害國家安全」、「違背正常交易原則」)極為模糊,且未設明確的除名機制。這使得它成為一種典型的「威懾性工具」——清單的存在本身比實際列入更具壓迫感。
**破除常見簡化**:將不可靠實體清單等同於美國的實體清單是錯的。美國商務部實體清單主要基於「技術管控」(防止技術外流),而北京的不可靠實體清單本質上是「政治忠誠審查」。對台商而言,被列入的風險不僅在於技術斷供,更在於整個大陸市場的瞬間封鎖與資產凍結。
**分析時如何用**:目前不可靠實體清單主要針對美企,但對台商的潛在威懾極大。分析者應關注商務部是否開始將清單適用於「被認為支持特定政黨的台資企業」。若出現首例台商被列入,將標誌著經濟制裁從「產業打擊」升級為「精準政治懲罰」。參見 `primitives/frameworks/economic_coercion_typology.md` 中的「市場准入與投資限制」類型。
### 工具三:出口管制——精準掐斷供應鏈的隱形手
**事實層**:2020 年《出口管制法》實施後,商務部獲得了以國家安全為由限制特定貨物、技術和服務出口的廣泛權力。近年商務部多次調整《中國禁止出口限制出口技術目錄》,並對關鍵礦物(如鎵、鍺、石墨、銻)實施出口許可管制。
**脈絡層**:出口管制在對台制裁中扮演「隱性殺手」角色。北京極少直接宣布對台出口管制(因為這等同於承認兩岸是敵對實體),而是透過「全球性管制」來間接影響台灣。例如,限制鎵和鍺的出口,名義上是針對美國的半導體封鎖,但台灣作為全球半導體代工重鎮,受衝擊程度極高。
**分析時如何用**:當商務部宣布新的全球性出口管制時,分析者必須評估其對台灣供應鏈的「附帶損害」。如果某項管制恰好卡住台灣特定產業的咽喉(如高科技原材),且時機與兩岸政治事件重合,則應將其解讀為「隱蔽性協同施壓」,而非單純的中美博弈。
### 工具四:反傾銷與反補貼——常規武器的政治化使用
**事實層**:商務部對台灣產品發起反傾銷調查的頻率近年顯著上升,涵蓋聚碳酸酯、特定化學品、不鏽鋼等。2023 年 8 月,商務部對台灣進口聚碳酸雙酚 A 裁定高達 16.9%-22.4% 的反傾銷保證金。
**脈絡層**:反傾銷是 WTO 認可的合法貿易救濟工具,這使其成為商務部「政治意圖合法化」的最佳載體。與 SPS 措施不同,反傾銷調查由商務部主導,程序更嚴謹,結論更具法律強制力,且直接影響產品成本結構(加徵關稅),而非單純的市場准入(禁止進口)。
**分析時如何用**:判定反傾銷是否為政治脅迫的關鍵在於「對稱性」與「時機」。若商務部對其他國家同類產品未發起調查,或調查啟動時間緊鄰政治事件,則政治動機明顯。反傾銷稅的裁定幅度也是信號——幅度越高,懲罰意圖越強。參見 `narratives/china_side/economic_coercion_examples.md`。
---
## 對台具體案例與打擊光譜
商務部的對台行動並非隨機打擊,而是呈現出清晰的「打擊光譜」:避開台灣的「矽盾」,精準瞄準傳產與農業。
### 案例一:ECFA 早收清單的系統性解構
**事實層**:2024 年 1 月,商務部認定台灣構成貿易壁壘後,財政部等部門隨即宣布中止 ECFA 項下丙烯、對二甲苯等 12 個稅目產品的關稅減讓。2024 年 5 月,進一步中止機械、紡織、石化等 134 個稅目的關稅減讓。兩波合計佔 ECFA 早收清單的近 80%。
**脈絡層**:這是商務部主導的「結構性脫鉤」行動。不同於過去農產品禁令的「點狀打擊」,ECFA 中止是「面狀剝奪」。商務部選擇石化、機械、紡織作為打擊對象,具有雙重戰略考量:一是這些產業對大陸市場依賴度高,打擊痛感強;二是這些產業不涉及大陸自身的高科技供應鏈安全,中止不會反噬大陸經濟。
**分析含義**:ECFA 案例揭示了商務部制裁的「非對稱自傷迴避原則」。北京始終避免對台灣高科技產品(如晶片)實施制裁,因為這會直接衝擊大陸自身的電子製造業。這使得商務部的制裁工具存在結構性盲區——它只能打「大陸可替代的產業」,無法打「大陸必須依賴的產業」。
### 案例二:農產品與傳產的交錯打擊
**事實層**:雖然農產品禁令(如鳳梨、石斑魚、釋迦)多由海關總署以檢疫名義發布,但商務部在其中扮演了政策協調與信號釋放的角色。在傳產方面,商務部直接主導了對台灣輪胎、鋼鐵、化學品的反傾銷調查。
**脈絡層**:農產品與傳產的交錯打擊,構成了「南北夾擊」的政治效應。農產品打擊中南部農民,傳產打擊中北部與沿海的工業從業人員。商務部的反傾銷稅與海關總署的禁令相互配合,形成了從「暫停進口」到「加徵重稅」的立體打擊網。
**分析含義**:分析者常將海關總署的 SPS 措施與商務部的貿易救濟措施分開看待,這是錯誤的。在對台經濟脅迫中,兩者是同一戰略下的不同戰術分工。參見 `events/recurring_patterns/pattern_economic_coercion.md`。
---
## ECFA 調整中的角色:從「讓利者」到「收債人」
### 事實層
ECFA 過去是北京「以經促統」的核心載體,商務部在其中扮演「讓利者」角色,透過早收清單給予台灣關稅優惠。然而,2022 年後,商務部對 ECFA 的角色徹底轉變。它不再談「讓利」,而是談「對等」與「合規」。貿易壁壘調查是這一轉變的里程碑,商務部透過調查結論,將 ECFA 框架從「政治紅利」重新定義為「有條件的契約」。
### 脈絡層
這種角色轉變的深層邏輯是北京對台戰略的根本調整:**「經濟融合未能帶來政治靠近,則經濟槓桿必須轉為懲罰工具。」** 商務部作為 ECFA 的主管執行機構,其行動宣告了「惠台政策」時代的終結與「法律戰」時代的開啟。商務部不再以「兩岸一家親」的邏輯運作,而是以「你違規,我懲罰」的邏輯運作。
### 分析含義
當分析 ECFA 的未來時,不應再問「北京何時恢復早收清單」,而應問「商務部還剩多少 ECFA 項目可以中止」。商務部的行動具有「棘輪效應」,中止容易恢復難,因為恢復需要台灣先解除貿易壁壘,這在政治上幾乎不可能。參見 `narratives/cross_strait/ecfa_legacy_debate.md`。
---
## 跨部門協調:國台辦、商務部與海關總署的三角博弈
對台經濟脅迫從來不是單一部門的行為,而是三個機構的複雜協調(有時是博弈)產物。
### 協調機制拆解
| 機構 | 核心職能 | 在脅迫中的角色 | 行動特徵 |
|---|---|---|---|
| 國台辦 | 對台政治路線與輿論戰 | 政治指令發出者與輿論定調者 | 先於或同步於制裁發布政治警告,提供「為什麼打」的論述 |
| 商務部 | 貿易政策與法律戰 | 法律程序執行者與工具提供者 | 啟動調查、裁定違規、宣布中止協議,提供「怎麼打」的合法基礎 |
| 海關總署 | 邊境查驗與檢疫 | 物理執行者 | 暫停進口、通關延遲、嚴查證件,提供「在哪裡打」的物理封鎖 |
**中等模型想不到的洞察**:這三個機構之間存在微妙的「官僚賽局」。國台辦希望打擊信號明確、力度大;商務部希望程序合規、不違反 WTO;海關總署希望執行成本最低、不影響正常通關秩序。當三者步調一致時(如 2024 年 ECFA 中止),代表黨中央已下達明確指令;當三者步調不一時(如國台辦放話但商務部遲遲未立案),代表內部對升級力度仍有分歧。
**分析時如何用**:觀察「發言順序」是判斷北京決心的重要指標。若國台辦先發言定調,商務部隨後跟進,代表這是政治主導的升級;若商務部先行立案調查,國台辦低調回應,代表北京試圖將衝突控制在「貿易摩擦」層級,避免政治攤牌。參見 `entities/foreign_actors/taiwan_affairs_office.md`。
---
## 近年涉台行動的頻次與升級路徑
### 事實層
2020 年以前,商務部對台直接行動較少,多由海關總署出面。2021 年後,商務部直接主導的行動頻次呈指數級上升:
- 2021:對台聚碳酸雙酚 A 反傾銷立案。
- 2023:對台 2455 項產品貿易壁壘調查(史無前例的規模)。
- 2024:兩波中止 ECFA 關稅減讓,合計 146 項產品。
### 脈絡層:升級路徑的結構性特徵
商務部的行動呈現出清晰的「法律化、系統化、不可逆化」升級路徑:
1. **從行政干預到法律認定**:早期是海關的行政暫停,現在是商務部的法律裁定。
2. **從個案到系統**:早期針對單一產品(鳳梨),現在針對整個貿易體制(貿易壁壘調查)。
3. **從可逆到不可逆**:早期禁令可隨時解除,現在 ECFA 中止涉及契約毀棄,重啟需重新談判。
**分析含義**:這種升級路徑與 `primitives/frameworks/economic_coercion_typology.md` 中的分類完全吻合——北京已從「精準/半公開/高可逆」的 SPS 工具,升級到「半全面/完全公開/中低可逆」的法律戰工具。這意味著未來即使兩岸政治氛圍緩和,經濟關係也很難回到過去的狀態,因為商務部已經透過法律程序將脫鉤制度化。
---
## 制裁工具的「反向效應」
### 事實層
商務部的制裁在對台造成短期經濟痛感的同時,也產生了顯著的「反向效應」:
1. **供應鏈加速重組**:台灣對大陸出口依賴度從 2020 年的 43.8% 降至 2023 年的 35.4%(含香港)。
2. **台灣內部團結效應**:農產品禁令反而促使台灣政府與民間建立外銷分散機制,受打擊產業的對中敵意上升。
3. **國際資本避險**:跨國企業將部分產能移出中國大陸,以規避商務部不可靠實體清單的潛在風險。
### 脈絡層:武器化相互依賴的內在矛盾
商務部制裁的核心邏輯是「武器化相互依賴」——利用台灣對大陸市場的依賴來強制改變其行為。但這種策略存在結構性悖論:**脅迫越有效,越促使對方降低依賴;依賴越低,未來的脅迫籌碼就越少。**
這就是為何商務部在 ECFA 中止上採取「切香腸」策略——它必須精準控制每次剝奪的幅度,既要製造足夠痛感,又不能一次性將台灣推離大陸市場,否則將徹底喪失經濟槓桿。
**分析含義**:評估商務部制裁的效力時,不能只看台灣的短期經濟損失,必須同時評估「台灣依賴度的下降速度」。如果依賴度下降速度快於制裁造成的損失累積速度,則長期來看,制裁是失敗的——它消耗了北京自身的戰略籌碼。
---
## 國際比較:商務部作為經濟脅迫工具的當代角色
### 事實層
將北京商務部與美國商務部(BIS/OFAC)進行比較,可以揭示其獨特性:
- **美國商務部**:主要基於「技術國家安全」與「人權」邏輯,制裁多為出口管制與金融封鎖,具有強烈的「域外管轄」特徵(二級制裁)。
- **北京商務部**:主要基於「主權領土完整」與「反制外部干預」邏輯,制裁多為市場准入限制與貿易救濟,具有強烈的「境內市場槓桿」特徵(利用中國大陸作為全球最大單一市場的吸引力)。
### 脈絡層
北京商務部的制裁模式可稱為「市場准入型脅迫」。它不像美國那樣試圖將目標從全球金融與技術體系中隔離(因為北京缺乏這種制度性權力),而是透過威脅切斷目標與中國大陸市場的連結來施壓。這種模式的優勢在於合法爭議空間較小(主權國家有權決定誰能進入其市場),劣勢在於只對「高度依賴大陸市場」的實體有效,對「不依賴大陸市場」的實體(如部分美國軍工企業或台灣高科技上游)缺乏殺傷力。
**分析含義**:當分析商務部對跨國企業的施壓時,關鍵變數是該企業「中國營收佔比」。佔比越高,商務部不可靠實體清單的威懾力越大;佔比越低,威懾力越弱。這解釋了為何歐洲企業對不可靠實體清單的恐懼遠高於美國企業。
---
## 關聯分析
### 關聯一:與「國台辦」檔案的對照
`entities/foreign_actors/taiwan_affairs_office.md`。功能:提供脈絡層,解釋政治指令的源頭。商務部的行動節奏受國台辦的政治定調約束,分析商務部行動前必須先確認國台辦的輿論風向。
### 關聯二:與「經濟脅迫案例集」的驗證
`narratives/china_side/economic_coercion_examples.md`。功能:提供事實層,用於交叉驗證商務部行動的具體數據與時間線,確認「貿易壁壘調查」等工具的實際殺傷力。
### 關聯三:與「經濟脅迫反覆模式」的對接
`events/recurring_patterns/pattern_economic_coercion.md`。功能:提供脈絡層,將商務部的單一行動放入歷史模式中檢驗,判斷當前處於「升級-僵持-降級」週期的哪個階段。
### 關聯四:與「ECFA 遺產辯論」的深度連結
`narratives/cross_strait/ecfa_legacy_debate.md`。功能:提供結構性脈絡,解釋商務部為何選擇 ECFA 作為法律戰的突破口,以及 ECFA 中止對兩岸經貿架構的長期解構效應。
### 關聯五:與「經濟脅迫類型學」的映射
`primitives/frameworks/economic_coercion_typology.md`。功能:提供分析工具,將商務部的具體工具(貿易壁壘調查、不可靠實體清單)代入類型學的五軸矩陣,判定其強度、可見度與可逆性。
### 關聯六:與台灣內部產業結構的對照
(隱含關聯)功能:用於對照,解釋商務部打擊目標的選擇邏輯。商務部避開半導體而選擇石化紡織,正是因為台灣產業結構中半導體的不可替代性與傳產的可替代性。
---
## 常見的分析陷阱
### 陷阱一:將商務部視為單一理性計算者
商務部內部存在貿易救濟局(傾向法律戰)、台港澳司(傾向維持交流)與安全與管制局(傾向國安管控)的分歧。有時制裁的節奏混亂,並非精心設計的「灰地帶戰略」,而是內部路線妥協的結果。
### 陷阱二:過度解讀法律程序的實質獨立性
商務部的貿易壁壘調查與反傾銷調查雖然程序完備,但在對台議題上,立案與結論幾乎不可能違背黨中央的政治意志。將其調查結論視為「客觀貿易評估」是嚴重誤判,它本質上是「政治決定的法律背書」。
### 陷阱三:低估「不可靠實體清單」的寒蟬效應
只統計被實際列入清單的企業數量,會低估其威力。清單的真正作用是迫使所有台商與跨國企業進行「自我審查」,主動切割與台灣特定政治勢力的關聯,以規避被列入的風險。
### 陷阱四:假設「經濟自傷」會阻止北京升級
認為大陸自身經濟放緩會制約商務部的制裁力度,是忽略了北京的政治計算邏輯。在特定政治節點(如台灣大選前),「政治收益」的權重遠高於「經濟自傷」,商務部會毫不猶豫地啟動高自傷工具。
### 陷阱五:混淆海關總署與商務部的職能
將所有經濟制裁都歸於商務部,會忽略海關總署在 SPS 措施中的主導權。商務部管「關稅與制度」,海關管「物理通關」,兩者工具不同,降級難度也不同。
### 陷阱六:直線外推制裁的升級趨勢
假設商務部會持續升級直至全面終止 ECFA 或封鎖所有貿易。這忽略了「籌碼消耗」的邏輯——北京需要保留部分經濟槓桿作為未來談判的誘餌,全面脫鉤等於自廢武功。
---
## 仍不確定的部分
1. **不可靠實體清單適用於台商的閾值**:目前尚無台資企業被正式列入,商務部內部對於將清單用於「本國領土」上的企業是否存在法律或政治顧慮,仍不透明。
2. **ECFA 完全中止的決策點**:商務部已中止大部分早收清單,但何時會宣布終止 ECFA 整體框架(包含投資保障等條款),目前缺乏可靠的決策圈訊息。
3. **跨部門協調的具體機制**:國台辦、商務部與海關總署在發動制裁前的具體協調流程(是常設小組還是臨時會議?誰有否決權?)仍屬黑箱。
4. **出口管制對台灣高科技業的實際滲透率**:商務部的全球性出口管制(如關鍵礦物)對台灣半導體供應鏈的實際卡脖子程度,受限於台灣企業的庫存策略與替代來源,難以精確量化。
5. **地方政府的執行偏離**:商務部的指令在地方層級(如各省商務廳對台商的稽查)是否存在選擇性執行或加碼執行的情況,缺乏系統性數據。
---
## 與其他文件的關係
- 國台辦檔案:`entities/foreign_actors/taiwan_affairs_office.md`(功能:提供政治指令源頭的脈絡,解釋商務部行動的上位邏輯)
- 經濟脅迫案例集:`narratives/china_side/economic_coercion_examples.md`(功能:提供商務部歷次行動的事實層細節與數據,用於驗證升級路徑假設)
- 經濟脅迫反覆模式:`events/recurring_patterns/pattern_economic_coercion.md`(功能:提供商務部行動在歷史週期中的定位,用於識別行動模式是否偏離常軌)
- ECFA 遺產辯論:`narratives/cross_strait/ecfa_legacy_debate.md`(功能:提供 ECFA 法律戰的深度脈絡,解釋商務部貿易壁壘調查的戰略意圖)
- 經濟脅迫類型學:`primitives/frameworks/economic_coercion_typology.md`(功能:提供分類工具,將商務部的具體工具代入五軸矩陣進行結構化分析)
---
## 一句話濃縮
> 北京商務部是將政治紅線轉譯為法律程序的機器,它透過貿易壁壘調查與出口管制等工具,讓經濟脅迫具備了程序正當性與升級彈性,但這種「武器化相互依賴」正面臨籌碼消耗與加速脫鉤的結構性反噬。
---
last_updated: 2025-05-19
@@ -0,0 +1,294 @@
# 布鲁金斯学会涉台研究(Brookings Institution Taiwan Studies)
---
**类型**: 机构档案
**当前角色**: 华盛顿建制派智库、温和派/战略模糊维持派的知识大本营
**成立时间**: 1916 年(学会整体)
**何时调用此档案**: 引用布鲁金斯涉台报告时;分析美方建制派/温和派对台立场时;评估战略模糊论述的学理基础时;解读前 AIT 及国安会官员的政策主张时;进行多智库立场对照时
---
## 一句话浓缩
布鲁金斯学会是华盛顿涉台研究「建制温和派」的知识锚点——它不是中立的资讯汇总站,而是**战略模糊维持、两岸危机管控、反对单向升级**这套政策框架的主要生产者与捍卫者;理解布鲁金斯,就是理解美国行政体系与学界的「默认共识」是如何被论证与维持的。
---
## 机构基本性质与结构定位
### 事实层
- **全名**: Brookings Institution
- **成立**: 1916 年
- **总部**: 华盛顿特区
- **政治/思想光谱**: 主流中偏左 / 建制派
- **资金来源**: 基金会(如福特、洛克菲勒)、企业、个人捐款、外国政府资助(含台湾,需公开揭露)
- **核心定位**: 强调「高质量研究」影响公共政策,传统上与民主党行政体系关系更密切,但维持两党互动
### 脉络层:布鲁金斯的「建制性」本质
布鲁金斯在华盛顿智库生态中的核心特征不是「中立」,而是**建制性**。它的研究预设了当前美国政治体制与全球霸权地位的正当性,政策建议旨在「优化」而非「颠覆」现有体系。
在涉台议题上,这种建制性表现为:
1. **现状偏好**:将台海现状视为最符合美方利益的稳定态,任何改变现状的单边行动(无论来自北京、台北或华盛顿)均被视为风险。
2. **行政体系视角**:其学者多出身行政体系(AIT、国安会、国务院),研究视角天然贴近行政部门的「危机管控」逻辑,而非国会的「立法宣示」逻辑。
3. **理性假设**:预设北京、台北、华盛顿均为理性计算的行动者,可通过信号传递与威慑维持平衡。
### 分析含义
分析时,不应将布鲁金斯视为「客观事实的提供者」,而应视为**「美方行政体系默认路线的辩护者与细化者」**。当布鲁金斯发布报告批评某项政策时,通常意味着该政策偏离了行政体系的舒适区。
---
## 涉台研究的历史与当代演变
### 事实层:从中国研究到独立的台湾研究
布鲁金斯早期的涉台研究依附于「中国研究」框架下。台湾议题的独立化与深化,主要发生在 2000 年代以后,关键转折点与人事任命高度相关:
- **1990 年代至 2000 年代初**:台湾作为「两岸关系」的附属议题存在,研究重心在北京的意图与美中关系。
- **2000 年代中期至 2010 年代**:随着台湾民主化深化及两岸张力变化,台湾内政与选举研究增加。Richard Bush(卜睿哲)的加入是关键转折。
- **2016 年以后**:美中战略竞争加剧,布鲁金斯的涉台研究开始从「两岸关系」向「美台关系与区域安全」倾斜,但始终保留强烈的「两岸联动」视角。
- **2020 年代**:应对战略清晰化呼声与北京军事压力,布鲁金斯成为捍卫「战略模糊」与「双重威慑」升级版的核心阵地。
### 脉络层:研究框架的结构性转移
布鲁金斯涉台研究的演变,映射了华盛顿政策圈的整体认知转移:从「台湾是美中关系的麻烦」到「台湾是美中竞争的支点」,再到「台湾是印太安全架构的节点」。
然而,布鲁金斯的独特之处在于:**即便在「台湾是支点/节点」的认知下,它仍坚持「台湾不应成为挑衅的来源」**。这种立场在当代常被批评为「指责受害者」,但在布鲁金斯的逻辑中,这是「危机管控」的必然推论。
### 分析含义
追踪布鲁金斯研究重心的转移,可作为观察**华盛顿建制派如何重新包装「战略模糊」**的指标。当布鲁金斯开始大量生产「台湾韧性」或「不对称防御」的报告时,不代表它放弃了危机管控,而是它在寻找新的论述来让危机管控在竞争时代显得可行。
---
## 关键涉台学者与立场解析
布鲁金斯的涉台立场不是铁板一块,但存在高度共识。理解其立场需拆解核心学者的知识结构与政策偏好。
### Richard Bush(卜睿哲):当代涉台研究的学术权威
- **背景**: 前 AIT 理事主席(1997-2002),布鲁金斯东亚政策研究中心资深研究员
- **专长**: 两岸关系、台湾内政、美台历史互动
- **立场特征**:
- **战略模糊的学理捍卫者**:Bush 多次公开撰文解释为何战略模糊优于战略清晰,其论点核心是「清晰化承诺将削弱美方的战略灵活性,并可能鼓励台北采取冒险行动」。
- **台湾内政的深度解读者**:他是华盛顿少数能精细区分台湾各党派两岸路线差异的学者,常提醒美方不应将台湾视为单一行动者。
- **反对单向归责**:虽强调北京是台海紧张的主要来源,但也常指出台北或华盛顿的政策误判可能引发危机。
- **影响力来源**: AIT 资历赋予其无可挑战的「实务权威」地位;多本台海专著(如《At Cross Purposes》)是学界必读。
- **代表作品**: 《At Cross Purposes: U.S.-Taiwan Relations Since 1942》;大量政策简报与国会证词。
**分析含义**:Bush 的发言在分析中常被用作**「华盛顿建制派底线」的指标**。当 Bush 批评某种对台政策(如呼吁驻军台湾)时,代表该政策在行政体系内几乎不可能被采纳。引用 Bush 时,必须意识到他的权威来自对「体系稳定」的维护,而非对「台湾主体性」的推崇。
### Ryan Hass(何瑞恩):美中台三角的操作者
- **背景**: 前国家安全会议(NSC)中国/台湾/蒙古主任,布鲁金斯东亚政策研究中心主任
- **专长**: 美中关系、美台政策操作、信号传递
- **立场特征**:
- **操作层面的战略模糊派**:Hass 较少像 Bush 那样进行历史学理辩护,而是从操作层面论证「模糊如何产生威慑」。
- **强调「信心与保证」的平衡**:常主张美方需在对台保证与对中威慑间取得平衡,反对过度倾斜。
- **对北京信号的精细解读**:倾向于将北京的强硬言论解读为「信号」而非「立即行动的意图」,主张美方应冷静回应。
- **影响力来源**: 欧巴马时期国安会资历;与当前民主党政策圈紧密连结;高频媒体曝光。
- **代表作品**: 《Stronger: Adapting America's China Strategy in an Age of Competitive Interdependence》;大量 Brookings 政策评论。
**分析含义**:Hass 代表了**「后欧巴马时代的危机管控派」**。他的分析框架高度依赖「信号传递模型」。分析 Hass 的文章时,应注意他常将台海问题简化为「美中信号博弈」,而在一定程度上将台湾的能动性边缘化。
### 其他重要学者
- **Patricia Kim**:专长美中关系与全球治理,立场偏温和,常强调美中需建立护栏,涉台论述侧重避免误判。
- **Lynn Kuok**:专长国际法与南海/东海议题,涉台研究延伸自海洋争端与法律战视角,立场偏规则与制度维护。
- **Shelley Rigger**(虽主要在 Davidson College,但与 Brookings 合作密切):台湾选举与内政专家,提供台湾社会微观视角,是布鲁金斯理解台湾民意的重要接口。
---
## 立场特征:温和派、战略模糊维持派的结构性拆解
### 事实层
布鲁金斯在涉台政策辩论中的核心标签是「温和派」与「战略模糊维持派」。这表现为:
1. 反对战略清晰化(如明确承诺协防台湾)。
2. 反对美方在台海采取「挑衅性」行动(如高层访台常态化、驻军)。
3. 强调两岸对话的价值。
4. 支持对台军售与不对称防御,但反对将台湾推向法理独立。
### 脉络层:「温和」不是中立,是特定的政策偏好
将布鲁金斯标记为「温和」容易产生误导,暗示其立场是各极端的中道。实际上,**布鲁金斯的立场是高度特定的政策偏好——即维持美方最大的战略弹性**。
这种偏好存在两个结构性内在矛盾:
1. **民主价值与民主管控的矛盾**:布鲁金斯支持台湾的民主,但当台湾民主可能改变现状(如公投、修宪)时,布鲁金斯常表现出「民主管控」的倾向——即主张台湾民主应在美方可控的范围内运作。这种「民主 paternalism(家长主义)」常引发台北的不满。
2. **威慑与保证的内在冲突**:布鲁金斯主张对中威慑与对台保证并重,但在实践中,当北京升高压力时,美方增加对台支持常被北京视为「改变现状」,而布鲁金斯又主张避免刺激北京——这导致其政策建议在危机时刻常显得被动。
### 分析含义
分析布鲁金斯报告时,**不要问「这是否客观」,而要问「这如何服务于维持美方战略弹性的目的」**。当布鲁金斯学者批评北京时,通常是为了合理化美方的威慑行动;当其提醒台北时,通常是为了防止台北的行动压缩美方的回旋空间。
---
## 近年涉台报告与政策辩论中的实际角色
### 角色一:战略清晰化浪潮的「刹车片」
2020 年以来,随着 CSIS、Hudson、CFR 等机构学者推动战略清晰化,布鲁金斯成为最主要的制衡力量。Bush 与 Hass 频繁撰文,论证清晰化将导致威慑失效或台北道德风险。
**分析含义**:布鲁金斯在此辩论中的角色不是「反对支持台湾」,而是**「定义支持台湾的正确方式」**——即军售与经济整合,而非政治/军事承诺的升级。
### 角色二:台湾选举的「冷静剂」
每逢台湾大选,布鲁金斯均会发布评估报告。其报告基调通常是:无论谁当选,美台关系都将维持,台湾选民主要关注内政,北京的反应是可预测的。
**分析含义**:这种「冷静」论述的功能是**降低华盛顿对台湾选举的焦虑**,防止美方在选举期间做出过度反应。分析时需注意,这种论述可能低估了台湾选举结果对两岸结构的长期冲击。
### 角色三:拜登政府政策的事后解释者
拜登总统多次公开表示「若北京动武将防卫台湾」(看似偏离战略模糊),布鲁金斯学者常在事后发文解释「这不代表政策改变,而是口误或个人观点」,试图将发言拉回模糊框架。
**分析含义**:布鲁金斯在此扮演了**「建制共识的修复者」**角色。分析时,应区分拜登发言的实质意义与布鲁金斯试图赋予它的意义——两者的落差本身就是分析素材。
### 角色四:不对称防御的学术背书者
布鲁金斯支持台湾发展不对称防御,但其论述侧重于「这如何增强威慑并避免战争」,而非 CSIS 或 Hudson 侧重于「这如何在未来战争中获胜」。
**分析含义**:同样的政策建议(不对称防御),在不同智库的底层逻辑不同。布鲁金斯视其为**维持现状的工具**,鹰派视其为**备战工具**。
---
## 与政府的「旋转门」关系
### 事实层
布鲁金斯与民主党政府的旋转门极为活跃:
- **进入政府**:Ryan Hass 曾在欧巴马政府任职;多位布鲁金斯学者在拜登政府担任顾问或进入国安体系。
- **离开政府**:Richard Bush(AIT 主席转任);多位前国务院、国安会官员卸任后加入布鲁金斯。
### 脉络层:旋转门的知识同构
旋转门不仅带来人脉,更带来**知识框架的同构**。布鲁金斯学者与民主党行政官员共享同一套「危机管控」词汇与预设。这意味着:
1. 布鲁金斯报告常是政府政策的「试气球」或「学术包装」。
2. 学者批评政府时,通常针对「手段」而非「目标」。
### 分析含义
当分析布鲁金斯报告时,**将其视为「民主党行政体系的学术延伸」比视为「独立学术批评」更准确**。若要寻找对行政体系的根本性挑战,应看向 Quincy(克制派)或 Hudson(鹰派),而非 Brookings。
---
## 与其他智库的立场对照
| 智库 | 立场定位 | 对战略模糊的态度 | 对台支持的核心逻辑 | 与 Brookings 的关键差异 |
|---|---|---|---|---|
| **Brookings** | 建制温和派 | 维持,并重新诠释 | 维持现状、危机管控 | 基准组 |
| **CSIS** | 中立/偏中右,技术导向 | 内部多元,偏维持但接受升级 | 实力威慑、军事平衡 | 更侧重军事数据与兵推,较少价值判断 |
| **Hudson** | 鹰派/战略清晰派 | 反对,主张清晰化 | 意识形态对抗、击败中国 | 视台湾为击败中国的核心,Brookings 视台湾为需管控的风险 |
| **Quincy** | 克制派/退缩派 | 反对,但主张减少承诺 | 避免战争、降低美方风险 | 主张美方应减少涉入,Brookings 主张美方应积极管控 |
详见 `entities/foreign_actors/csis.md`、`entities/foreign_actors/hudson_institute.md`、`entities/foreign_actors/quincy_institute.md`。
**分析含义**:布鲁金斯与 Hudson 的对立是「管控风险 vs. 赢得竞争」的对立;与 Quincy 的对立是「维持霸权管控 vs. 放弃霸权收缩」的对立。布鲁金斯处于这两股力量的拉扯中间,试图证明「既不升级也不退缩的中间路线」仍然可行。
---
## 关联分析
### 关联一:与「战略模糊」概念的共生关系
布鲁金斯是战略模糊论述的主要生产者。分析战略模糊概念的演变时,必须追踪 Bush 与 Hass 的近期文章——他们正试图在「维持模糊」的前提下,容纳「对台支持升级」的现实,创造如「战略弹性」等替代词汇。
**功能**: 提供脉络层——理解战略模糊如何被学术化与合理化。
### 关联二:与台湾官方的沟通管道
布鲁金斯是台美第二轨道沟通的关键节点。台湾驻美代表处常选择布鲁金斯作为发表演讲的场所;台湾国安会官员常在访美时与布鲁金斯学者闭门交流。
**功能**: 提供事实层与脉络层——布鲁金斯不仅是观察者,也是台美互动的场域。
### 关联三:与 CSIS 的分工与竞争
在华盛顿涉台研究圈,CSIS 提供军事技术分析,Brookings 提供政治战略分析。两者常合作发布报告,但在政策建议上,CSIS 更易接受军事升级,Brookings 更强调政治管控。
**功能**: 用于对照——识别同一政策建议(如不对称防御)背后的不同动机。
详见 `entities/foreign_actors/csis.md`。
### 关联四:与 Hudson 的意识形态对抗
Hudson 学者常公开批评 Brookings 的战略模糊立场为「过时」与「失败」。Brookings 则批评 Hudson 的清晰化主张为「鲁莽」与「挑衅」。这场辩论是美方涉台政策圈最核心的结构性张力。
**功能**: 用于对照——定位特定政策建议在光谱上的位置。
详见 `entities/foreign_actors/hudson_institute.md`。
### 关联五:与 Quincy 的「反共识」对照
Quincy 主张美方应减少对台承诺,这与 Brookings 的「维持承诺但管控风险」截然不同。然而,两者在「反对 Hudson 式的军事升级」上存在短暂共识。分析时需区分:**反对升级的原因是「为了管控现状」还是「为了收缩霸权」**。
**功能**: 用于交叉验证——识别反对升级阵营内部的裂痕。
详见 `entities/foreign_actors/quincy_institute.md`。
### 关联六:与关键学者的个人网络
Bush 与 Hass 的个人网络覆盖 AIT、国务院、白宫国安会。分析布鲁金斯报告时,需结合 `entities/foreign_actors/key_us_taiwan_scholars.md` 中的学者关系图,识别报告是否反映了特定官僚群体的立场。
**功能**: 提供事实层——将机构立场还原为人际网络与官僚利益。
详见 `entities/foreign_actors/key_us_taiwan_scholars.md`。
---
## 常见的分析陷阱
### 陷阱一:将「温和」等同于「亲中」或「对台不友善」
错。布鲁金斯坚定支持对台军售与经济整合。其「温和」体现在**手段**(反对军事对抗升级)而非**目的**(维持美方主导的台海现状)。将其贴上「亲中」标签会严重误判其政策影响力。
### 陷阱二:忽略布鲁金斯立场的「民主家长主义」
布鲁金斯支持台湾民主,但当台湾民主可能改变现状时,常流露出管控倾向。分析其报告时,若忽略这种「条件式支持」,将无法理解为何台北有时对布鲁金斯的论述感到不满。
### 陷阱三:将布鲁金斯报告视为「客观事实」
布鲁金斯报告是**特定政策框架下的论证产物**。其数据可能客观,但选题、框架与推论深受建制派偏好影响。引用时必须标注「此为建制派维持现状路线的观点」。
### 陷阱四:低估其捍卫战略模糊的韧性
许多分析认为战略模糊正在瓦解,布鲁金斯只是在做无谓的抵抗。然而,布鲁金斯正通过重新定义模糊(如「战略弹性」)来适应新现实。低估其论述的适应力,将误判美方政策的延续性。
### 陷阱五:假设布鲁金斯代表整个美国学界
布鲁金斯是建制派的核心,但不是全部。CSIS 的技术派、Hudson 的鹰派、Quincy 的克制派都有独立影响力。仅引用布鲁金斯报告来论证「美方学界认为 X」是严重的取样偏差。
### 陷阱六:忽略资金来源对研究方向的微妙影响
布鲁金斯接受台湾政府资助(通过驻美代表处等渠道)是公开事实。这不必然导致结论偏颇,但可能影响**选题优先级**——例如更关注台湾视角的安全需求,而较少关注北京视角的核心利益。
---
## 仍不确定的部分
- **拜登政府多次「口误」后,布鲁金斯内部是否正在重新评估战略模糊的可行性**:公开文件仍维持模糊,但私下辩论可能已松动。
- **若 2028 年前北京对台施加极限压力,布鲁金斯是否会调整其反对战略清晰的底线**:当前框架尚未经受真正危机的测试。
- **新一代学者的立场演变**:Bush 等老一代学者逐渐退休,年轻一代是否仍坚守传统建制派框架,或向 CSIS 的技术/鹰派方向靠拢,仍待观察。
- **川普 2.0 时期若再次执政,布鲁金斯作为民主党大本营,其政策影响力将如何被边缘化**:其报告可能从「政策蓝图」降级为「反对派论述」。
---
## 与其他文件的关系
- CSIS:`entities/foreign_actors/csis.md` —— 智库立场对照,军事技术分析 vs. 政治战略分析
- Hudson Institute:`entities/foreign_actors/hudson_institute.md` —— 智库立场对照,战略清晰 vs. 战略模糊
- Quincy Institute:`entities/foreign_actors/quincy_institute.md` —— 智库立场对照,克制收缩 vs. 建制管控
- 关键美国台海学者:`entities/foreign_actors/key_us_taiwan_scholars.md` —— 学者个人网络与立场定位
- 战略模糊概念:`primitives/concepts/strategic_ambiguity.md` —— 布鲁金斯捍卫的核心概念
- 美对台政策分析框架:`primitives/frameworks/us_taiwan_policy_analysis.md` —— 理解建制派操作逻辑
---
## 一句话浓缩
> 布鲁金斯学会不是台海议题的客观观察站,而是**美方建制派「维持现状、危机管控、反对单向升级」路线的知识引擎**;理解其报告,就是理解华盛顿行政体系如何在战略模糊的框架下,试图同时威慑北京、安抚台北、并保留自身最大的行动自由。
---
last_updated: 2025-05-20
@@ -0,0 +1,135 @@
# 中共中央对台工作领导小组
---
type: organization
current_role: 中共对台决策最高协调与领导机构
established: 1954 (前身中央对台三人小组) / 1979 (现制)
when_to_invoke: 分析北京对台政策源头、重大对台信号释放、跨部门对台行动协调、高层人事变动对台意涵时
priority: P4
issue_group: beijing_policy_and_pressure
---
中共中央对台工作领导小组是北京对台决策体系的神经中枢,以极度不透明的党内协调机制统合党政军各方,其运作逻辑直接映射总书记的权力集中度与战略偏好。
## 法律与党内地位:隐形的决策引擎
### 事实层
中共中央对台工作领导小组(以下简称对台小组)是中共党内负责对台工作的最高决策与协调机构。该机构未在中国宪法或任何国家法律中注册,属于典型的“党内有、国家无”的权力实体。其决策以中共中央文件或领导人批示形式下达,不具国家行政法规形式,却对行政体系具有绝对约束力。
### 脉络层
对台小组的存在体现了北京“以党领政”的极端典型。对台工作被北京定义为涉及“主权与领土完整”的核心利益,因此不能交由国务院系统(行政体系)主导,必须由党中央直接把控。这种制度安排导致国台办(国务院台湾事务办公室)与中共中央台办实为“一套人马两块牌子”,但在决策链条上,中共中央台办仅是对台小组的执行秘书处,不具备政策倡议的最终权力。
### 分析含义
分析者在定位对台小组时,必须剥离其法律表象。不能从国务院的组织法或国台办的三定方案去寻找对台政策的决策逻辑。对台小组的党内地位意味着:对台政策的本质是“党建国家”逻辑下的政治决断,而非基于行政理性的政策产出。当观察到国台办发布重大政策或声明时,分析应直接上溯至对台小组的意志,而非停留在国台办层级。
## 组织结构与人事惯例:权力的金字塔
### 事实层
对台小组的组长惯例由中共中央总书记担任。现任组长为习近平。副组长通常由全国政协主席担任,现任副组长为王沪宁(2023年就任),前任为汪洋(2018-2023)、俞正声(2013-2018)、贾庆林(2002-2012)。
小组成员涵盖对台工作相关的核心部门首长,固定成员通常包括:
- 中央外事工作委员会办公室主任(外事系统)
- 国务院台办主任(对台执行系统)
- 中共中央统战部部长(统战系统)
- 国家安全部部长(情报系统)
- 外交部部长(外交系统)
- 中央军委联合参谋部副参谋长或东部战区司令员(军事系统)
### 脉络层
组长由总书记担任,确立了“最高领导人亲自抓台海”的集权模式。副组长由政协主席担任,具有双重结构性意义:第一,全国政协在制度上主管统一战线工作,对台工作被视为统战的最高阶形态;第二,政协主席作为政治局常委,具备协调跨系统(外交、军事、情报)的党内资历与权威。
成员的部门分布揭示了北京对台工作的本质——它不是单纯的“两岸交流”或“经贸融合”,而是被置于大战略博弈的框架下。军方与国安系统首长的常设参与,意味着对台小组的每一次决策评估都内含武力选项的成本核算与情报支撑。
### 分析含义
**破除常见简化**:把“对台工作领导小组”等同于“和平台一工作组”是错的。军方代表的常设存在决定了它本质上是战略博弈与危机管控机构,而非单纯的统战机构。
分析者应将人事变动作为政策风向的核心指标。例如,王沪宁接任副组长,其背景是意识形态与理论建构专家(而非如汪洋般具经贸背景),这暗示北京对台工作的重心可能从“经贸融合”向“政治统合与反独论述建构”倾斜。观察军方代表的层级(是东部战区司令还是军委联参副参谋长)亦可推断军事手段在当前决策权重中的位置。
## 与执行系统的关系:大脑与白手套
### 事实层
对台小组与国台办的关系是决策与执行的关系。国台办(中共中央台办)是对台小组的办事机构,负责落实小组决议、起草文件、进行日常两岸沟通与舆论战。国台办主任通常兼任对台小组秘书长或办公室主任。
在军事执行端,对台小组通过中央军委下达指令,东部战区是对台军事行动的主要执行者(参见 `entities/foreign_actors/pla_eastern_theater.md`)。
### 脉络层
这里存在一种结构性张力:国台办作为前台机构,需要向台湾及国际社会传递“和平发展”的信号;而对台小组作为后台大脑,必须准备“武力反独”的方案。当对台小组的决策偏向强硬时,国台办往往陷入“话语赤字”,其公开声明与实际军事行动之间产生巨大撕裂。
此外,国台办在习时代的政策倡议权被大幅削弱。在江胡时代,国台办主任(如陈云林)尚有一定空间主导两岸经贸谈判(如ECFA);习时代,国台办(张志军、刘结一、宋涛)更多沦为对台小组意志的“传声筒”与“白手套”,其功能从“政策设计”降格为“政策包装与执行”。
### 分析含义
分析时,绝对不能将国台办的公开表态等同于北京的真实决策意图。国台办的发言属于“论述层”(参见 `narratives/china_side/unification_process_discourse.md`),而对台小组的运作属于“决策层”。当国台办释放温和信号的同时,若东部战区军事动作频发,应判断这并非“系统内耗”,而是对台小组“软硬两手”策略的精确分工。国台办的温和是战术掩护,军方的强硬是战略底线。
## 决策机制的不透明性与习时代的权力集中
### 事实层
对台小组的运作极度不透明。其会议频率、具体议程、表决机制均不对外公开。没有公开的会议纪要,决议通常以“中央对台工作会议”精神或内部文件形式下达至省级对台系统。
习时代的权力集中彻底重塑了对台小组的内部动力学。在江胡时代的“九龙治水”格局下,对台小组成员(各常委与部委首长)之间存在一定的利益博弈与政策拉锯,国台办、外交部、军方有时会释放不同信号。习时代确立“定于一尊”后,对台小组从“协调与妥协机制”转变为“最高意志的传达机制”。
### 脉络层
**结构性命题**:信息茧房效应的向上传导。由于组长(总书记)的偏好决定了成员的政治生命,小组成员倾向于提供符合组长预期或至少不触怒组长的情报与方案。这种结构导致对台小组在危机时刻可能产生“误判的自我强化”——当最高层倾向于认为“武力威慑有效”时,情报系统会过滤掉威慑反效的证据,军方会夸大威慑能力,国台办则无法提出异议。
权力集中也导致政策的“试错能力”丧失。过去,对台政策可在地方(如福建平潭)先行先试,失败后调整;现在,重大对台决策被视为最高领导人的政治承诺,缺乏回旋余地,导致政策一旦确立便具有刚性,难以根据台湾内部政治生态的突变进行灵活微调。
### 分析含义
分析者必须警惕“理性假设”的局限。不能假设对台小组的决策总是基于完全信息与理性计算。在高度集权下,决策的盲点往往在于最高领导人的认知盲点。分析时,应关注哪些信息被排除在官方叙事之外,而非仅仅分析官方公布了什么。
## 可观察的信号体系:从黑箱中提取数据
### 事实层
由于对台小组本身不发声,分析者必须依赖外部信号倒推其决策状态。关键观察节点包括:
1. **年度中央对台工作会议**(通常1-2月举行):国台办主任做工作报告,政协主席(副组长)发表讲话。这是对台小组年度定调的窗口。
2. **重大政治节点讲话**:总书记在党代会报告、建国/建党周年讲话中的涉台段落。
3. **人事调整**:国台办主任、海协会会长、东部战区司令的更替。
4. **军演模式**:东部战区联合军演的规模、科目与宣布时机(参见 `playbooks/analyzing_beijing_signal.md`)。
5. **内部文件下发**:如《台湾问题与新时代中国统一事业》白皮书的发布,或特定反独惩戒名单的更新。
### 脉络层
这些信号并非随机释放,而是对台小组精心设计的“威慑-统战”复合信号。信号释放的时机往往与台湾内部选举周期、美国对台军售或高层访台高度联动。
然而,信号解读存在“噪音”。例如,国台办发言人的日常谴责属于低强度的仪式性表态,不反映决策层的战略转向;而政协主席在对台工作会议上的措辞微调(如从“和平发展”转为“融合发展”,或重提“决不承诺放弃使用武力”的权重),则属于高强度信号,直接反映对台小组的共识。
### 分析含义
分析者在使用信号时,必须建立“信号层级矩阵”:
- **一级信号(战略转向)**:总书记涉台讲话的定性改变、白皮书发布、大规模实弹演习。
- **二级信号(战术调整)**:对台工作会议副组长讲话措辞变化、国台办主任换人。
- **三级信号(日常噪音)**:国台办例行发布会回应、特定台企罚款。
只有一级和二级信号才具有分析对台小组决策的价值。过度解读三级信号是常见的分析错误。
## 关联分析
1. **`entities/foreign_actors/taiwan_affairs_office.md`(提供执行层验证)**:国台办是对台小组意志的降维传达者。通过比对国台办公开行动与对台小组已知偏好,可验证对台小组的指令边界。功能:交叉验证。
2. **`entities/foreign_actors/pla_eastern_theater.md`(提供军事层验证)**:东部战区的行动是对台小组威慑决策的物理体现。军演的烈度与对台小组的政治需求直接挂钩。功能:提供事实层与对照。
3. **`narratives/china_side/unification_process_discourse.md`(提供论述层脉络)**:对台小组的决策需要论述包装。分析论述的演变可倒推对台小组在法理或政治上的新动作。功能:提供脉络层。
4. **`playbooks/analyzing_beijing_signal.md`(提供方法论)**:对台小组是不透明的,必须依赖信号分析框架。该文件提供解码对台小组行为的工具。功能:提供分析框架。
5. **中共中央外事工作委员会(隐含关联)**:对台小组与外事委员会在成员上高度重叠(外长、外办主任),但台湾议题被单独剥离,显示北京将台湾问题视为高于一般外交的独立战略层级。功能:提供结构性对照。
## 常见的分析陷阱
1. **将国台办等同于对台小组**:这是最常见的降维错误。国台办没有最终决策权,其温和表态往往只是对台小组强硬决策的缓冲包装。
2. **假设对台小组内部存在派系制衡**:在习时代,将对台政策解读为“强硬派(军方)与温和派(国台办)的妥协”是过时的。当前结构是最高领导人定调,各部门竞争性执行以表忠诚,不存在实质制衡。
3. **过度解读仪式性表态**:将国台办发言人的日常谴责视为政策升级的信号,忽略了这些表态只是对台小组设定的自动反应机制。
4. **将军事行动与政治决策割裂**:认为东部战区军演是军方“擅动”或“例行公事”。在当前体制下,没有对台小组(即总书记)的授权,针对台海的大规模军演不可能发生。
5. **线性外推政策轨迹**:假设北京的对台政策会沿着过去的轨迹(如先经后政、和平发展)线性发展。对台小组的决策受最高领导人历史定位需求驱动,存在随时打破惯例的突变可能。
## 仍不确定的部分
1. **会议频率与议程设定**:对台小组是每月开会还是仅在危机时召开?议程是由总书记直接下达,还是由国台办/国安部提报?目前缺乏可靠情报。
2. **军方在小组内的实际话语权**:军委联参部代表在小组内是仅提供选项,还是能实质影响决策方向?在台海危机升级时,军方的评估是否会被政治需求覆盖?
3. **情报评估的失真度**:国安部与对台系统向对台小组提供的台湾内部政治情报,在多大程度上被过滤或迎合上级偏好?这是判断北京是否会产生战略误判的关键盲区。
4. **副组长的实际裁量权**:王沪宁作为副组长,是仅负责执行习近平的意志,还是在战术层面拥有裁量权?其意识形态背景在多大程度上塑造了当前的对台统战策略?
## 与其他文件的关系
- `entities/foreign_actors/taiwan_affairs_office.md` —— 执行机构档案,用于对照决策与执行的落差。
- `entities/foreign_actors/pla_eastern_theater.md` —— 军事执行档案,用于验证对台小组威慑信号的物理边界。
- `narratives/china_side/unification_process_discourse.md` —— 论述档案,用于解析对台小组决策的合法化包装过程。
- `playbooks/analyzing_beijing_signal.md` —— 信号分析框架,用于从对台小组的黑箱运作中提取有效数据。
---
中共中央对台工作领导小组是北京对台战略的黑箱引擎,其极度集权与不透明的特性,要求分析者必须透过执行机构的表象与仪式性噪音,从高层人事逻辑与跨系统行动的缝隙中倒推决策真相。
last_updated: 2024-05-20
@@ -0,0 +1,287 @@
# CSIS 战略与国际研究中心(Center for Strategic and International Studies)
---
**类型**: 智库档案
**何时调用**: 引用 CSIS 涉台报告时;分析美国主流智库对台立场;评估"中国实力计画"等具体项目;理解智库与政府的人事流动
---
## 一句话浓缩
CSIS 是华盛顿最具影响力的中立 / 偏中右智库之一——在台海议题上**不是单一立场**,是**多个研究项目并存**(中国实力计画、台湾计画、亚洲海事透明倡议等),各项目立场略有差异。理解 CSIS 涉台报告需要识别**是哪个项目、哪位学者**——不能笼统说"CSIS 立场"。
---
## CSIS 基本信息
- **全名**: Center for Strategic and International Studies
- **成立**: 1962 年
- **总部**: 华盛顿特区
- **现任主席与 CEO**: John J. Hamre(前国防部副部长)
- **政治立场**: 大致中立 / 偏中右——强调"两党共识"
- **资金来源**: 多元——基金会、政府合约、企业、个人捐款
- **影响力来源**: 政府关系、媒体能见度、人才储备
**分析含义**:CSIS 的"中立"是**机构定位**——但具体项目与学者立场有差异。
---
## CSIS 涉台主要项目
### 项目一:中国实力计画(China Power Project)
由 Bonnie Lin(林洋)领导(接替 Bonnie Glaser 后)。
**核心特征**:
- 综合分析中国军事、经济、科技实力
- 涉台研究的主要平台
- 输出大量数据、地图、报告
- **当代台海议题的主要数据来源**
**典型产出**:
- 「中国军力指数」
- 「台海冲突情境模拟报告」
- 涉台政策辩论文章
**分析含义**:引用「CSIS 报告」时,常实际是「中国实力计画」的产出。
### 项目二:台湾计画(Taiwan Project)
由 Brian Hart 等人主导。
**核心特征**:
- 专门聚焦台湾内政、选举、两岸关系
- 与台湾学者合作密切
- 输出台湾选举追踪、政党分析
**典型产出**:
- 台湾选举观察报告
- 两岸关系定期评估
- 台湾经济议题分析
### 项目三:亚洲海事透明倡议(Asia Maritime Transparency Initiative, AMTI)
由 Greg Poling 领导。
**核心特征**:
- 聚焦南海、东海等海洋议题
- 涉台与海洋议题交叉
- 卫星图像分析与公开数据
**典型产出**:
- 南海岛礁建设追踪
- 解放军海军活动分析
- 涉台海上活动的公开监测
### 项目四:CSIS 亚洲计画下其他子项目
包括日本、韩国、东南亚等子计画——也常涉及台湾议题。
---
## CSIS 涉台关键学者
### 现任与近期核心人物
**Bonnie Glaser**:
- 长期 CSIS 涉台学者,**2021 转任 GMF**
- 战略模糊维持派代表
- 仍是涉台分析最有影响力的美国学者之一
- 详见 `entities/foreign_actors/key_us_taiwan_scholars.md`
**Bonnie Lin(林洋)**:
- 接替 Glaser 主导中国实力计画
- 来自 RAND,专长军事议题
- 立场偏鹰但不极端
**Brian Hart**:
- 台湾计画的研究员
- 专长台湾内政与选举
- 与台湾学者合作密切
**Matthew Funaiole**:
- 中国实力计画资深研究员
- 海军议题专家
**Greg Poling**:
- AMTI 主任
- 南海议题专家——涉台延伸研究
### CSIS 涉台立场的"内部多元"
CSIS 内部学者立场**并不一致**:
- 有偏鹰派(强调对台支持升级)
- 有偏稳定派(强调战略模糊维持)
- 有技术派(专注数据、不持强烈立场)
**分析含义**:「CSIS 立场 X」常是单一学者立场——不能等同于机构立场。
---
## CSIS 在政策辩论中的角色
### 角色一:「严肃」涉台政策的发布平台
CSIS 报告常被国会、行政部门、媒体引用——是涉台政策辩论的"严肃载体"。
### 角色二:兵推与情境分析的来源
CSIS 多次进行台海冲突兵推:
- 公开发布部分结果
- 影响国防部内部规划
- 塑造公众对"美中冲突"的认知框架
最著名:2023 年的「下次战争——台海冲突兵推报告」。
### 角色三:政府人才储备库
CSIS 与美国政府的人事流动密切:
- 退休官员加入 CSIS
- CSIS 学者进入政府
- 这种"旋转门"让 CSIS 高度政策导向
### 角色四:外国对美沟通的接触点
外国政府(包括台湾)常通过 CSIS 间接与美国政策圈互动:
- 台湾政府资助部分 CSIS 项目(公开揭露)
- 台湾学者访问 CSIS
- 这是台美第二轨道沟通的重要管道
---
## 关联分析
### 关联一:与「战略模糊辩论」的关系
CSIS 是战略模糊辩论的**主要场域**:
- Glaser 是战略模糊维持派代表
- Hudson 等智库的战略清晰派常与 CSIS 对话
- CSIS 内部也有不同立场
**分析含义**:分析战略模糊辩论必须看 CSIS 的多元立场。
详见 `primitives/concepts/strategic_ambiguity.md`。
### 关联二:与 2023 兵推报告的影响
2023 年 CSIS 「下次战争」报告影响巨大:
- 公开提出「美中战争代价」具体评估
- 影响国会预算、国防部规划
- 在台湾舆论中被广泛引用——支撑「军事升级紧迫感」论述
- 但也被质疑「过度悲观」或「假设过强」
**分析含义**:此报告是**台海议题转向公众辩论的关键事件**——分析当代台海讨论必须知此报告。
### 关联三:与川普 2.0 时期的角色变化
CSIS 在川普 2.0 时期的潜在角色变化:
- 与传统共和党、民主党都有联系——但与川普 MAGA 派关系较远
- 川普 2.0 政府人事中,CSIS 人才比例可能下降
- 但 CSIS 报告仍是**公共辩论的重要素材**
**分析含义**:CSIS 影响力**部分依赖政府关系**——若与川普政府疏远,影响力可能下降。
### 关联四:与台湾官方的关系
CSIS 与台湾官方互动密切:
- 与外交部、国安会有定期沟通
- 与驻美 TECRO 关系密切
- 部分项目接受台湾政府资助
- 但保持「研究独立」声明
**分析含义**:CSIS 涉台报告的"独立性"有局限——分析时要考虑资金来源。
### 关联五:与媒体的关系
CSIS 学者**频繁出现在主流美媒**:
- NYT、WaPo、CNN 等的涉台报导常引用 CSIS 学者
- 这种媒体能见度让 CSIS 立场获得更大公众影响
- 但媒体引用常**简化**学者立场——产生误读
**分析含义**:分析「美媒报导的 CSIS 立场」时要谨慎——常是简化版。
---
## 实务分析:解读 CSIS 涉台报告
### 应用一:识别报告来自哪个项目
读到「CSIS 报告」时,先识别:
- 中国实力计画 → 综合分析
- 台湾计画 → 台湾内政
- AMTI → 海洋议题
- 其他子计画 → 不同视角
不同项目的方法学、立场、资金来源有差异。
### 应用二:识别报告作者立场
每位 CSIS 学者立场有差异——分析报告时要考虑作者的立场倾向。
### 应用三:评估资金来源
CSIS 的资金来源对涉台报告有潜在影响——若研究由台湾政府资助,结论倾向可能有偏。
### 应用四:与其他智库对照
只看 CSIS 不够——应同时参考:
- Brookings(中道)
- Hudson(鹰派)
- Quincy(克制派)
- RAND(技术导向)
多智库对照能识别「智库共识」与「智库分歧」。
详见 `entities/foreign_actors/brookings.md`(占位)、`entities/foreign_actors/hudson_institute.md`(占位)、`entities/foreign_actors/quincy_institute.md`(占位)。
---
## 常见的分析陷阱
### 陷阱一:把 CSIS 当单一立场
错。内部学者立场有差异。
### 陷阱二:把单一报告当「美国政策方向」
CSIS 报告影响政策但不等于政策——是政策辩论的素材。
### 陷阱三:忽略资金来源
部分项目接受台湾政府或其他国家资助——影响研究方向。
### 陷阱四:低估其媒体影响力
CSIS 学者通过媒体的影响力很大——超越其报告本身。
### 陷阱五:把「中国实力计画」等同 CSIS
它是 CSIS 的一个项目——CSIS 涉台研究还有其他项目。
---
## 与其他文件的关系
- 关键美国学者:`entities/foreign_actors/key_us_taiwan_scholars.md`
- Brookings:`entities/foreign_actors/brookings.md`(占位)
- Hudson:`entities/foreign_actors/hudson_institute.md`(占位)
- Quincy:`entities/foreign_actors/quincy_institute.md`(占位)
- RAND:`entities/foreign_actors/rand_corporation.md`(占位)
- 战略模糊:`primitives/concepts/strategic_ambiguity.md`
- 美对台政策分析框架:`primitives/frameworks/us_taiwan_policy_analysis.md`
---
## 一句话浓缩
> CSIS 不是单一立场——是**多个项目 + 多位学者 + 多元资金**的复合体。
> 引用 CSIS 时**先识别"是哪个项目、哪位学者"**——这比说"CSIS 立场 X"准确得多。
---
last_updated: 2026-05-08
@@ -0,0 +1,313 @@
# 欧盟对外行动署(European External Action Service, EEAS)
---
**类型**: 机构档案
**当前角色**: 欧盟外交与安全政策的执行机构,受成员国共识约束的「刹车系统」
**何时调用**: 分析欧盟对台政策的实质落地程度、解读欧盟外交高级代表涉台发言、评估欧洲议会决议的实际政策效力、判断欧盟在台海危机中的可能反应模式、分析欧盟整体与成员国在对台议题上的温差
---
## 一句话浓缩
EEAS 是欧盟外交的执行中枢,但在涉台议题上,它受制于「成员国共识决」的制度性约束,扮演着将欧洲议会「高姿态支持」过滤为「低法律承诺」的刹车角色——理解 EEAS 的运作,就是理解为何欧盟对台叙事呈现「声量高、落实低」的结构性特征。
---
## EEAS 的法律性质与制度定位
### 设立基础与条约框架
EEAS 依据 **2009 年《里斯本条约》** 设立,2010 年正式运作:
- 协助欧盟外交与安全政策高级代表(High Representative for Foreign Affairs and Security Policy, HR/VP)执行职务
- 法律性质为欧盟的「自主机构」(Autonomous institution),不隶属于欧盟执委会或理事会,但与两者紧密连动
- 核心任务:确保欧盟对外行动的「一致性、协调性与效率」
### 双重身份与内在张力
| 维度 | 法律设计 | 实际运作 |
|---|---|---|
| 权力来源 | 条约授权 + 成员国共识 | 受核心成员国(德法)实质制约 |
| 对台角色 | 执行欧盟「一个中国政策」 | 在政策边缘进行有限度的弹性诠释 |
| 与议会关系 | 无需执行议会决议 | 必须回应议会的政治压力,但以「不违反共识」为前提 |
| 人事组成 | 欧盟官员 + 成员国外交官借调 | 成员国借调人员的立场常反映母国利益 |
**分析含义**:EEAS 的「自主性」是有限的——它在法律上是欧盟的单一外交声音,但在实质运作上,它是 27 个成员国利益的「最小公倍数」。这种结构在对台议题上尤为明显:任何突破一中政策传统边界的举措,都可能遭遇成员国的否决,使 EEAS 在涉台行动上先天趋于保守。
---
## EEAS 的组织结构与涉台处理机制
### 核心决策层
- **欧盟外交高级代表(HR/VP)**:同时兼任欧盟执委会副主席与理事会主席,是 EEAS 的最高负责人。当前为 Josep Borrell(2019-2024),其继任者的对台立场将直接影响未来五年的政策基调。
- **执行秘书长(Secretary-General)**:负责日常行政与协调,确保政策指令的执行。
- **亚太总司(Asia and Pacific Department)**:直接负责对中与对台事务的日常处理,是涉台政策的业务核心。
### 驻外网络与台北的定位
EEAS 的全球驻外机构称为「欧盟代表团」(EU Delegations),法律地位等同于大使馆。然而,在台北的机构并非代表团,而是 **欧洲经贸办事处(European Economic and Trade Office, EETO)**:
- **设立时间**:2003 年
- **法律性质**:非官方的经贸办事处,不具备外交机构地位
- **人员组成**:主要由欧盟执委会贸易总司派任,名义上不隶属 EEAS 的外交体系
- **实质功能**:处理欧台经贸、科技、文化等非政治领域交流
**分析含义**:EETO 的「非官方」性质是欧盟一中政策的具体实践。与 AIT 在美台关系中的核心枢纽地位不同,EETO 在欧台关系中更多是「功能性的技术窗口」,而非政治沟通管道。当需要传递政治信号时,EEAS 通常通过成员国驻台机构或非正式管道进行,以维持形式上的非官方性。
### 涉台事务的内部协调机制
EEAS 处理涉台事务时,必须在多重约束中运作:
1. **成员国协调**:通过「政治与安全委员会」(PSC)及各层级工作组,寻求 27 国共识
2. **与执委会的跨机构协调**:贸易、产业等议题涉及执委会专属权限,EEAS 需与执委会贸易总司(DG Trade)等协调
3. **与欧洲议会的缓冲**:议会通过涉台决议后,EEAS 需在正式回应中表达「注意到了」但重申「一中政策不变」
**分析含义**:这种多重协调机制使 EEAS 在涉台议题上的反应速度缓慢且措辞高度制式化。分析者若看到 EEAS 对某项涉台事件的回应「慢半拍」或「措辞平淡」,不应简单解读为「不支持」,而应理解为「共识决下的必然结果」。
---
## 欧盟外交高级代表的涉台立场演变
### 从 Mogherini 到 Borrell:基调的微调
**Federica Mogherini(2014-2019)**:
- 严格遵循一中政策,将台湾完全框定在经济与功能性议题
- 极少在公开场合提及台湾,避免任何可能被解读为政治升级的举措
- 任内中欧关系仍处于「战略伙伴」框架,台湾议题被边缘化
**Josep Borrell(2019-2024)**:
- 面对中欧关系恶化与地缘政治剧变,对台措辞出现明显调整
- 多次公开强调「台海和平稳定对欧洲至关重要」
- 2023 年提出「台湾对欧洲的重要性不仅在于芯片,也在于地缘政治」,将台湾从经济议题提升至战略议题
- 但在关键政治立场上,仍严格守住一中政策底线,不支持台湾加入需主权国家资格的国际组织
**分析含义**:HR/VP 的立场演变反映了 EEAS 整体的微调——从「刻意回避」转向「有限度正视」,但底线未变。Borrell 的「芯片与地缘政治」论述,为 EEAS 在不改变法律框架的前提下提升对台关注提供了正当性基础。然而,HR/VP 的个人风格受制于理事会授权,其公开发言的边界由成员国共识决定。
---
## 对台政策的演变:2019 香港后的结构性转折
### 传统框架的松动
2019 年前,EEAS 的对台政策严格遵循「政经分离」:
- 政治上:严格遵守一中政策,无官方接触
- 经济上:将台湾视为重要的贸易与投资伙伴
2019 年香港反修例事件及后续国安法实施,成为 EEAS 对台政策调整的结构性催化剂:
- 欧盟内部「价值观外交」的声量上升,对中疑虑加深
- 台湾作为「华人社会的民主对照」被显性化
- 供应链安全焦虑(车用芯片短缺)使台湾的战略价值从经济层面向安全层面溢出
### 2021 《印太战略》:首次纳入台湾
EEAS 于 2021 年发布《欧盟印太战略》,这是其对台政策的重要节点:
- 首次在战略文件中明确提及台湾
- 将台湾定位为印太地区的「重要经贸与高科技伙伴」
- 强调台海和平稳定对区域及欧洲利益的重要性
**分析含义**:《印太战略》纳入台湾,标志着 EEAS 在战略层面不再回避台湾议题。但需注意其措辞的精确性——台湾被定位为「伙伴」而非「盟友」,且仅在经贸与高科技领域被强调,政治与安全领域的着墨极少。这种「经济升级、政治冻结」的模式,是 EEAS 在成员国共识约束下的最大公约数。
详见 `narratives/international/eu_taiwan_relations.md`。
---
## 立陶宛事件中的角色:制度性保护的极限测试
### 事件的爆发与 EEAS 的反应
2021 年立陶宛允许台湾以「台湾」名义设立代表处,北京对立陶宛实施全面经济胁迫并降级外交关系。此事件对 EEAS 构成了严峻的制度性挑战。
详见 `events/key_events/event_lithuania_taiwan_office_2021.md`。
EEAS 的反应呈现明显的阶段性:
1. **初期迟疑**:事件爆发初期,EEAS 的公开回应相对温和,试图避免与北京直接对抗
2. **逐步升级**:随着北京胁迫加剧,EEAS 开始公开批评北京的「胁迫性外交」
3. **制度性反制**:推动「反胁迫工具」(Anti-Coercion Instrument, ACI)的立法进程,作为应对北京经济胁迫的制度性武器
### EEAS 面临的结构性困境
立陶宛事件暴露了 EEAS 在应对成员国涉台突破时的根本矛盾:
| 维度 | 制度设计 | 实际运作 |
|---|---|---|
| 共同外交与安全政策 | 成员国应团结一致 | 核心成员国(如德法)不愿为立陶宛承担对中关系全面恶化的代价 |
| 欧盟单一市场 | 北京对立陶宛的封锁实质影响欧盟单一市场 | EEAS 缺乏快速干预单一市场被武器化的工具 |
| 对外行动一致性 | EEAS 应协调一致对外 | 北京绕过布鲁塞尔,直接对成员国施压,利用欧盟内部的分歧 |
**分析含义**:立陶宛事件揭示了 EEAS 的一个深层结构问题——**欧盟的对外行动制度设计是为了「避免冲突」,而非「赢得对抗」**。当北京采取胁迫手段时,EEAS 的共识决机制反而成为掣肘,使其无法迅速且有力地保护成员国。此事件对其他成员国产生了「威慑效应」:突破一中政策边界的代价主要由当事国承担,而欧盟层面的保护并不可靠。
详见 `entities/foreign_actors/key_eu_member_states_taiwan.md`。
---
## 与北京关系的「限制性」
### 北京对 EEAS 的策略:绕过布鲁塞尔
北京深知 EEAS 受制于成员国共识,因此在对欧外交中采取「双边为主、多边为辅」的策略:
- 优先与核心成员国(德、法)建立紧密的双边关系,利用其经济依赖制约欧盟整体立场
- 在涉台议题上,通过双边渠道向成员国施压,避免在多边框架下与 EEAS 正面交锋
- 对 EEAS 本身采取「分化与边缘化」策略,降低其作为欧盟单一外交声音的权威性
### EEAS 对北京的「有限度反制」
EEAS 在应对北京时,可用的政策工具有限:
- **话语反制**:公开批评北京的人权纪录与胁迫行为
- **制度建构**:推动 ACI 等反胁迫工具
- **战略调整**:将中欧关系从「战略伙伴」重新定位为「系统性对手」
- **实质限制**:在半导体出口管制、投资审查等领域与美方协调
**分析含义**:EEAS 对北京的反制受制于内部经济利益的分裂——部分成员国(如德国)不愿牺牲对中经贸利益。这使得 EEAS 在涉台议题上的行动空间被进一步压缩:它必须在「支持台湾的价值观诉求」与「维护中欧关系的经济利益」之间寻找极窄的平衡点。
---
## 与欧洲议会的协调:引擎与刹车的制度性张力
### 权责不对等的结构
欧洲议会与 EEAS 在涉台议题上的互动,是理解欧盟对台政策「高声量、低落实」的关键:
- **欧洲议会**:无外交决策权,但拥有预算审批权与政治话语权,是涉台支持的「引擎」
- **EEAS**:掌握外交执行权,但受成员国共识约束,是涉台突破的「刹车」
### 互动模式
1. 议会通过高姿态涉台决议(如推动 BIA、支持台湾国际参与)
2. EEAS 在正式回应中「注意到」议会的立场,但重申「欧盟的一个中国政策未变」
3. 议会持续施压,EEAS 在不触碰红线的前提下进行微调(如提升 EETO 的层级与功能)
**分析含义**:这种互动模式产生了一种「制度性道德风险」——议会可以尽情表达对台支持以获取选民认同,而无需承担外交后果;EEAS 则必须面对北京的反制与成员国的质疑。这种权责不对等,使得欧盟对台政策呈现出「象征性升级多于实质性突破」的特征。
---
## 与美方涉台政策的协调
### 战略对接与战术温差
EEAS 与美方在涉台议题上保持密切沟通,但存在明显的「战术温差」:
| 维度 | 美方立场 | EEAS 立场 |
|---|---|---|
| 一中政策 | 重新诠释(如增加台湾代表处更名等) | 严格维持传统框架 |
| 安全承诺 | 《台湾关系法》下的实质安全保证 | 无安全承诺,仅强调「和平解决」 |
| 经济合作 | 《台美21世纪贸易倡议》 | 推动BIA但迟迟未启动谈判 |
| 对中定位 | 战略竞争 | 系统性对手 + 合作伙伴 + 经济竞争者(三重定位) |
**分析含义**:EEAS 在对台议题上跟随美方的战略方向(如去风险、供应链韧性),但刻意保持战术距离。这种「战略跟随、战术脱钩」的模式,源于欧盟的「战略自主」诉求——EEAS 不愿被外界解读为美方的附庸,尤其在法国的强力推动下,欧盟试图在对台议题上展现独立的步调。
---
## 未来对台关系的潜在路径
### 路径一:功能性升级(最可能)
在不改变一中政策框架的前提下,持续提升与台湾在非政治领域的合作:
- 深化半导体供应链合作
- 强化网络安全与虚假信息防范的交流
- 提升EETO的功能与层级(如增加政治官员的派驻)
### 路径二:制度性突破(可能性低但非零)
在特定条件下,推动BIA谈判或提升政治接触层级:
- 需要中欧关系进一步恶化作为催化剂
- 需要核心成员国(尤其是德国)的立场松动
- 需要美方提供更强的战略支撑
### 路径三:受制回摆(不可忽视)
若中欧关系出现缓和,或成员国经济压力增大,EEAS 可能收紧对台政策:
- 降低涉台公开表态的层级
- 搁置实质性的合作倡议
- 回归严格的「政经分离」模式
**分析含义**:EEAS 的对台政策路径取决于外部环境(中欧关系、美中博弈)与内部动力(成员国共识、议会压力)的交互作用。分析者应避免线性外推——欧盟对台支持并非不可逆的单向进程,而是随大环境波动的动态平衡。
---
## 关联分析
### 关联一:与欧盟整体对台叙事的互构
`narratives/international/eu_taiwan_relations.md` 提供了 EEAS 运作的宏观叙事框架。EEAS 是此叙事的「落地执行者」,其政策微调是叙事演变的实质指标。功能:提供脉络层,理解 EEAS 行动背后的叙事驱动。
### 关联二:与立陶宛代表处事件的对照
`events/key_events/event_lithuania_taiwan_office_2021.md` 提供了 EEAS 制度性保护能力的极限测试案例。功能:提供事实层,展示 EEAS 在成员国突破红线时的反应模式与局限。
### 关联三:与关键成员国的立场连动
`entities/foreign_actors/key_eu_member_states_taiwan.md` 提供了 EEAS 决策的底层约束条件。EEAS 的对台政策空间由核心成员国的立场决定。功能:提供交叉验证,检验 EEAS 的公开表态是否与成员国共识一致。
### 关联四:与 AIT 的对照分析
`entities/foreign_actors/ait.md` 提供了对照对象。AIT 是法律拟制下的实质大使馆,而 EETO 是法律拟制下的经贸办事处——两者的「形式与实质落差」程度不同,反映了美欧对台承诺的根本差异。功能:用于对照,避免将 EETO 等同于 AIT。
### 关联五:与北京对欧胁迫叙事的互构
北京的对欧经济胁迫是 EEAS 政策空间的最大外部制约。功能:提供脉络层,理解 EEAS 谨慎立场的结构性成因。
### 关联六:与台湾半导体战略的嵌套
台湾半导体对欧战略价值是 EEAS 提升对台关注的核心驱动力。功能:提供事实层,解释 EEAS 在不改变政治框架下提升对台关系的经济理性。
---
## 常见的分析陷阱
### 陷阱一:将 EEAS 的声明等同于欧盟整体政策
EEAS 的公开声明反映的是 27 国的「最小公倍数」,而非欧盟的「最大可能性」。高姿态声明往往掩盖了内部的深刻分歧。
### 陷阱二:将 EETO 等同于 AIT
EETO 不具备 AIT 的政治沟通功能与安全协调角色。将两者等同,会严重高估欧盟在台海的安全承诺。
### 陷阱三:忽视 HR/VP 的个人风格受制于理事会授权
Borrell 的强硬措辞容易让人产生「欧盟对台政策转向」的错觉,但其发言的边界由成员国共识决定,个人风格无法突破制度性约束。
### 陷阱四:假设 EEAS 能有效保护突破红线的成员国
立陶宛案例证明,EEAS 的制度性保护滞后且有限。假设 EEAS 能为对台突破提供「安全网」,是危险的误判。
### 陷阱五:将「去风险」等同于「对台政治支持」
EEAS 的「去风险」战略本质上是经济安全策略,台湾在此战略中是「工具」而非「目的」。将经济合作政治化,会误判欧盟的底线。
### 陷阱六:假设欧盟对台支持是不可逆的单向进程
欧盟对台政策受中欧关系大环境影响,具有可逆性。若中欧关系缓和,EEAS 的对台政策可能回摆。
---
## 仍不确定的部分
- **下一任 HR/VP 的对台立场**:Borrell 的继任者是否延续「有限度正视」的路线,将直接影响未来五年的政策基调
- **ACI 的实际威慑力**:反胁迫工具能否有效遏制北京对成员国的经济胁迫,仍未经充分测试
- **BIA 谈判的启动时机**:执委会与 EEAS 在 BIA 议题上的立场温差何时能弥合,仍不确定
- **德国立场松动的条件**:德国对中经济依赖的「去风险」进程是否会导致其对台政策松动,尚待观察
- **台海危机时 EEAS 的反应模式**:EEAS 在台海军事危机中的协调能力与反应速度,未经实战检验
---
## 与其他文件的关系
| 主题 | 关联文件 | 功能说明 |
|---|---|---|
| 欧盟对台关系叙事 | `narratives/international/eu_taiwan_relations.md` | 提供宏观叙事框架与制度性张力分析 |
| 立陶宛代表处事件 | `events/key_events/event_lithuania_taiwan_office_2021.md` | 提供突破案例与 EEAS 反应模式的事实层 |
| 关键成员国对台立场 | `entities/foreign_actors/key_eu_member_states_taiwan.md` | 提供成员国立场光谱与底层约束条件 |
| 美国在台协会 | `entities/foreign_actors/ait.md` | 提供对照分析(法律拟制与实质功能的差异程度) |
| 欧盟一中政策概念 | `primitives/concepts/eu_one_china_policy.md`(占位) | 提供法律与概念基础 |
---
## 一句话浓缩
> EEAS 是欧盟对台政策的执行中枢与结构性刹车——它将欧洲议会的「高姿态支持」过滤为「低法律承诺」,在成员国共识决的约束下,以「经济升级、政治冻结」的模式回应台海变局;分析者必须区分 EEAS 的「话语微调」与「政策实质」,避免将 HR/VP 的强硬措辞等同于欧盟的整体突破,更不可将 EETO 的功能性存在等同于 AIT 的战略级承诺。
---
last_updated: 2025-05-19
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more