Compare commits

..
4 Commits
Author SHA1 Message Date
likun 2a91e2e7ad 知识库补充到p6 2026-05-18 15:18:22 +08:00
likun de9f6b6711 feat: 新增写作画布系统 2026-05-15 16:22:04 +08:00
likun d533ddde88 feat: 补充知识库内容p4 2026-05-15 16:17:55 +08:00
likun ae4e4f67c2 新增大模型知识库技能 2026-05-15 14:51:16 +08:00
553 changed files with 170378 additions and 11450 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
remote connectivity test - 2026-05-13
+6 -12
View File
@@ -1,17 +1,13 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
# Workspace/editor
.vscode/
frontend-web/.vscode/
# Local runtime logs and pid files
.runtime/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
@@ -58,8 +54,6 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
View File
Whitespace-only changes.
-140
View File
@@ -1,140 +0,0 @@
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
## 一、需求背景
当前 `frontend-web/src/App.tsx` 同时承载了:
1. 登录/首页相关路由(如 `/login`、`/landing`)
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
---
## 二、目标(你提出的核心诉求)
你希望:
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
---
## 三、重构范围
### 1)保留在 `App.tsx` 的内容
- 全局 Provider:`HashRouter`、`AppProviders`
- 登录/首页路由:
- `/`
- `/login`
- `/login/:username`
- `/landing`
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
### 2)迁移出 `App.tsx` 的内容
将以下与工作台有关的路由从 `App.tsx` 抽离:
- `/workspace`(重定向)
- `/workspace/chats`
- `/workspace/chats/:thread_id`
- `/workspace/agents`
- `/workspace/agents/new`
- `/workspace/agents/:agent_id/chats`(重定向)
- `/workspace/agents/:agent_id/chats/:thread_id`
- `/workspace/scheduled`
- `/workspace/scheduled/tasks`
- `/workspace/scheduled/runs/:run_id`
以及配套的包装组件职责:
- `WorkspaceLayout`
- `ChatRuntime`
---
## 四、建议的新结构(方案)
## 方案 A(推荐):新增“业务路由容器组件”
在 `pages` 下新增一个路由容器组件,例如:
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
职责:
- 专门维护 `/workspace/*` 相关路由
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
- 输出一个可被 `App.tsx` 挂载的路由节点
同时在 `App.tsx` 里只保留:
- 登录/首页/404
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
---
## 五、“再建立一个页面放置导航”的落地解释
你说的“建立一个页面放置这些导航”,可以有两种理解:
1. **路由容器页(推荐)**:
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
- 即上面的 `WorkspaceRoutes.tsx`。
2. **可见的导航页(可选)**:
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
---
## 六、实施步骤(开发任务拆解)
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
5. 校验以下访问路径行为一致:
- `/workspace` 重定向
- chats/agents/scheduled 各页面可正常进入
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
- 404 不受影响
6. 运行前端并做手工回归。
---
## 七、验收标准
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
2. `/workspace/*` 全部由新建组件统一管理。
3. 功能行为与改造前一致(或符合新定义)。
4. 代码可读性提升:登录入口与业务入口职责分离。
---
## 八、注意事项
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
---
## 九、建议的命名(可直接采用)
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
---
## 十、后续可选优化
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
-975
View File
@@ -1,975 +0,0 @@
# DeerFlow 前端开发文档
## 目录
1. [整体目录结构](#1-整体目录结构)
2. [路由结构](#2-路由结构)
3. [核心数据流与状态管理](#3-核心数据流与状态管理)
4. [主要页面组件](#4-主要页面组件)
5. [核心业务组件](#5-核心业务组件)
6. [核心 Hooks 与工具函数](#6-核心-hooks-与工具函数)
7. [API 调用层](#7-api-调用层)
8. [国际化机制](#8-国际化机制)
9. [主题与样式体系](#9-主题与样式体系)
10. [关键业务流程](#10-关键业务流程)
11. [项目配置与依赖](#11-项目配置与依赖)
12. [常见开发模式](#12-常见开发模式)
---
## 1. 整体目录结构
```
frontend-web/src/
├── App.tsx # 应用入口,顶层路由 + Provider
├── env.ts # 环境变量类型定义
├── pages/ # 路由级页面组件
│ ├── PageRoutes.tsx # /page/* 根路由容器(含外部系统预留位)
│ ├── WorkspaceRoutes.tsx # /page/workspace/* 工作区路由
│ ├── LoginPage.tsx # 登录页
│ ├── LandingPage.tsx # 营销落地页(强制 dark 主题)
│ ├── ChatsPage.tsx # 对话列表
│ ├── ChatPage.tsx # 单条对话(核心页面)
│ ├── AgentsPage.tsx # 智能体库
│ ├── AgentChatPage.tsx # 与智能体对话
│ ├── NewAgentPage.tsx # 创建智能体(两步流程)
│ ├── ScheduledTasksPage.tsx # 定时任务管理
│ ├── ScheduledTaskRunDetailPage.tsx # 定时任务执行详情
│ ├── ScheduledChatPage.tsx # 定时任务对话跳转页
│ └── NotFoundPage.tsx # 404 页面
├── components/ # 业务组件库
│ ├── ui/ # shadcn/ui 基础组件(Button、Card、Dialog 等 40+)
│ ├── ai-elements/ # AI 交互可视化组件
│ │ ├── prompt-input/ # 输入框 Context + 子组件系统
│ │ ├── artifact.tsx # 制品展示
│ │ ├── canvas.tsx
│ │ ├── conversation.tsx # 虚拟滚动对话容器
│ │ ├── message.tsx
│ │ ├── model-selector.tsx
│ │ ├── code-block.tsx
│ │ └── shimmer.tsx
│ ├── workspace/ # 工作区业务组件(详见第 5 节)
│ └── landing/ # 落地页组件
├── core/ # 核心业务逻辑层(非 UI)
│ ├── api/ # API 客户端(LangGraph SDK + Fetch)
│ ├── auth/ # 认证管理
│ ├── config/ # 环境配置 & Base URL
│ ├── i18n/ # 国际化(上下文、翻译文件、语言检测)
│ ├── threads/ # 对话线程管理(最核心)
│ ├── agents/ # 智能体 CRUD
│ ├── models/ # 模型列表
│ ├── settings/ # 本地设置(LocalStorage 发布订阅)
│ ├── uploads/ # 文件上传
│ ├── artifacts/ # 制品管理
│ ├── memory/ # 用户记忆
│ ├── skills/ # 技能管理
│ ├── scheduled-tasks/ # 定时任务
│ ├── messages/ # 消息处理工具函数
│ ├── tasks/ # 子任务 Context
│ ├── notification/ # 系统通知
│ └── utils/ # 通用工具(文件下载、日期格式化等)
└── hooks/ # 全局自定义 hooks
```
---
## 2. 路由结构
> 项目使用 **HashRouter**,所有路由访问形如 `http://host/#/xxx`
### 2.1 App.tsx 顶层路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | Navigate | 重定向到 `/login` |
| `/login` | LoginPage | 登录(自动登录,无表单) |
| `/login/:username` | LoginPage | 带用户名登录 |
| `/landing` | LandingPage | 营销落地页(固定 dark 主题) |
| `/page/*` | PageRoutes | 应用内容根容器 |
| `*` | NotFoundPage | 404 |
### 2.2 PageRoutes.tsx(`/page/*`)
```
/page/
├── index → Navigate to "workspace"
├── workspace/* → WorkspaceRoutes
└── [TODO 预留] → 外部系统路由挂载位置
```
### 2.3 WorkspaceRoutes.tsx(`/page/workspace/*`)
| 路径 | 页面组件 | 布局包裹 |
|------|---------|--------|
| `index` | Navigate → `chats/new` | - |
| `chats` | ChatsPage | WorkspaceLayout |
| `chats/:thread_id` | ChatPage | WorkspaceLayout + ChatRuntime |
| `agents` | AgentsPage | WorkspaceLayout |
| `agents/new` | NewAgentPage | WorkspaceLayout |
| `agents/:agent_id/chats` | Navigate → `/page/workspace/agents` | - |
| `agents/:agent_id/chats/:thread_id` | AgentChatPage | WorkspaceLayout + ChatRuntime |
| `scheduled` | Navigate → `.../scheduled/tasks` | - |
| `scheduled/tasks` | ScheduledTasksPage | WorkspaceLayout |
| `scheduled/runs/:run_id` | ScheduledTaskRunDetailPage | WorkspaceLayout |
### 2.4 布局 Provider 层级结构
```
WorkspaceLayout
├── SidebarProvider(侧边栏展开/收起状态)
│ ├── WorkspaceSidebar
│ │ ├── WorkspaceHeader(Logo、新建按钮)
│ │ ├── WorkspaceNavChatList(Chats / Agents / Scheduled 导航)
│ │ ├── RecentChatList(最近会话列表)
│ │ └── WorkspaceNavMenu(Settings / About / GitHub)
│ ├── SidebarInset(主内容区)
│ │ └── [Page Content]
│ ├── CommandPalette(快捷命令面板)
│ └── Toaster(全局通知)
ChatRuntime(仅 ChatPage / AgentChatPage 使用)
├── SubtasksProvider(子任务执行状态)
├── ArtifactsProvider(制品选中/打开状态)
└── PromptInputProvider(输入框内容/附件状态)
```
---
## 3. 核心数据流与状态管理
### 3.1 全局 Provider 架构
```
App
└── HashRouter
└── AppProviders(useLocation 驱动主题判断)
├── NextThemesProvider
│ └── attribute="class",/landing 强制 forcedTheme="dark"
├── I18nProvider
│ └── initialLocale = detectLocale()(从 cookie 或浏览器语言)
└── QueryClientProvider(TanStack React Query 全局缓存)
```
### 3.2 全局 Context 列表
| Context | 文件 | 职责 | 消费 Hook |
|---------|------|------|-----------|
| `I18nContext` | `core/i18n/context.tsx` | 当前语言 + 翻译对象 | `useI18n()` |
| `SubtaskContext` | `core/tasks/context.tsx` | 实时子任务执行状态 | `useSubtaskContext()` |
| `ArtifactsContext` | `components/workspace/artifacts/context.tsx` | 制品选中/面板开关 | `useArtifacts()` |
| `PromptInputContext` | `components/ai-elements/prompt-input/` | 输入框内容、附件列表 | `usePromptInputController()` |
| `ThreadContext` | `components/workspace/messages/context.tsx` | 当前 thread 对象下传 | 直接读 context.thread |
### 3.3 LocalStorage 数据
**操作层:** `core/settings/store.ts`(发布-订阅,`useSyncExternalStore` 驱动响应式)
| Key | 内容 |
|-----|------|
| `deerflow.auth` | `{ access_token, token_type, user_id, email }` |
| `deerflow.local-settings` | `{ notification, context: { model_name, mode, reasoning_effort } }` |
| `deerflow.thread-model.{threadId}` | 每条线程的模型覆盖 |
### 3.4 SessionStorage 数据
| Key | 内容 |
|-----|------|
| `deerflow.run-id.{threadId}` | WebSocket run_id,用于断线重连 |
### 3.5 React Query 缓存 Key
| Query Key | 数据 |
|-----------|------|
| `["threads"]` | 线程列表 |
| `["thread", threadId]` | 单条线程 |
| `["agents"]` | 智能体列表 |
| `["models"]` | 模型列表 |
| `["skills"]` | 技能列表 |
| `["memory"]` | 用户记忆 |
| `["scheduled-tasks"]` | 定时任务列表 |
| `["scheduled-runs", taskId]` | 任务执行记录 |
---
## 4. 主要页面组件
### 4.1 ChatPage.tsx — 单条对话页面
**职责:** 管理单条聊天线程的完整交互,包括消息流、输入、工具调用展示、制品等。
**关键状态:**
```typescript
const [threadId, setThreadId] = useState(routeThreadId || uuid());
const [isNewThread, setIsNewThread] = useState(!routeThreadId || routeThreadId === "new");
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const [showFollowups, setShowFollowups] = useState(false);
const [settings, setSettings] = useThreadSettings(threadId);
```
**核心 Hook:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// 不刷新页面,直接更新 URL hash
history.replaceState(null, "", `...#/page/workspace/chats/${createdThreadId}`);
},
onFinish: (state) => {
// 后台通知
showNotification(state.title, { body: lastMessageText });
}
});
```
**主要子组件:**
| 组件 | 职责 |
|------|------|
| `<MessageList>` | 消息渲染(含工具调用、子任务、制品) |
| `<Welcome>` | 新对话欢迎屏 |
| `<InputBox>` | 输入框(模式选择、附件、发送) |
| `<ThreadTitle>` | 线程标题(自动生成后同步更新) |
| `<ExportTrigger>` | 导出对话按钮 |
| `<ArtifactTrigger>` | 制品面板开关 |
| `<TokenUsageIndicator>` | Token 用量展示(可选) |
| `<TodoList>` | 任务清单 |
| `<Suggestions>` | 追问建议 |
**数据流:**
```
用户输入 (InputBox)
↓ handleSubmit(message: PromptInputMessage)
↓ sendMessage(threadId, message)
↓ [内部] 上传文件 → 提交到 LangGraph API
↓ WebSocket 流返回消息
↓ thread.messages 更新 → MessageList 重渲染
```
---
### 4.2 AgentChatPage.tsx — 与智能体对话
与 ChatPage 结构基本一致,差异点:
- 从路由获取 `agent_id`,传入 `useThreadStream({ assistantId: agent_id })`
- Header 展示智能体名称 Badge
- "新建对话"跳转到 `/page/workspace/agents/${agent_id}/chats/new`
- URL 更新为 `#/page/workspace/agents/${agent_id}/chats/${threadId}`
---
### 4.3 NewAgentPage.tsx — 创建智能体(两步流程)
**Step 1:name(命名 + 选技能)**
```typescript
// 自动预选前 3 个启用的技能
useEffect(() => {
if (!isSkillSelectionTouched) {
setSelectedSkills(enabledSkills.slice(0, 3).map(s => s.name));
}
}, [enabledSkills]);
// 提交:验证名称 → 创建智能体
await checkAgentName(name); // 检查重名
await createAgent(request); // 创建,返回 agent.id
setStep("chat"); // 进入 Step 2
```
**Step 2:chat(初始化对话)**
```typescript
// 发送 bootstrap 消息触发智能体初始化
sendMessage(threadId, bootstrapMessage, { agent_id: agentId });
// 监听 setup_agent 工具完成事件
onToolEnd: (event) => {
if (event.name === "setup_agent") {
// 带重试轮询加载 agent 最新信息
retryLoadAgent(agentId);
}
}
```
---
### 4.4 ChatsPage.tsx — 对话列表
```typescript
const { data: threads } = useThreads({ limit: 50, sortBy: "updated_at" });
// 客户端过滤:排除定时任务线程 + 搜索关键词
const filteredThreads = useMemo(() =>
threads?.filter(t =>
t.metadata?.thread_type !== "scheduler" &&
titleOfThread(t).toLowerCase().includes(search)
), [threads, search]
);
```
---
### 4.5 LoginPage.tsx — 登录
```typescript
// 自动登录,无需用户操作
useEffect(() => {
loginByUsername(username || "guest")
.then(auth => {
setStoredAuth(auth);
router.replace("/page/workspace/chats/new");
})
.catch(err => setStatus("error"));
}, [username]);
```
---
### 4.6 ScheduledTasksPage.tsx — 定时任务
**功能:** 创建/编辑/发布/暂停/恢复定时任务,查看执行历史。
**Cron 表达式构建:**
```typescript
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
function buildCron(config): string {
// hourly → "15 * * * *"
// daily → "0 9 * * *"
// weekly → "0 9 * * 1"
// monthly → "0 9 1 * *"
}
```
**核心操作:**
```typescript
const actions = useScheduledTaskActions();
actions.create.mutateAsync(payload);
actions.publish.mutateAsync(taskId);
actions.pause.mutateAsync(taskId);
actions.trigger.mutateAsync(taskId); // 立即执行一次
actions.subscribe.mutateAsync(params); // 订阅推送
```
---
## 5. 核心业务组件
### 5.1 消息系统(`components/workspace/messages/`)
| 文件 | 职责 |
|------|------|
| `message-list.tsx` | 消息列表容器,调用 `groupMessages` 分组后渲染 |
| `message-list-item.tsx` | 单条消息渲染(含 role 判断) |
| `message-group.tsx` | 消息组(human / assistant / processing 等) |
| `markdown-content.tsx` | Markdown 渲染(rehype 系列插件) |
| `subtask-card.tsx` | 子任务状态卡片 |
| `message-token-usage.tsx` | 单条消息 Token 用量 |
| `skeleton.tsx` | 流式加载骨架屏 |
| `context.tsx` | ThreadContext,向下传递 thread 对象 |
**消息分组类型(`groupMessages`):**
```typescript
type MessageGroup =
| "human" // 用户输入
| "assistant:processing" // 推理/工具调用中
| "assistant" // 最终响应
| "assistant:clarification" // 澄清请求
| "assistant:present-files" // 文件呈现
| "assistant:subagent"; // 子智能体消息
```
---
### 5.2 输入框(`components/workspace/input-box.tsx`)
**子组件层级:**
```
InputBox
└── PromptInput(Context Provider)
├── PromptInputBody
│ ├── PromptInputTextarea(文本输入)
│ ├── PromptInputAttachments(附件列表)
│ └── PromptInputTools(工具栏:附件上传、模式选择)
├── PromptInputFooter
│ ├── PromptInputSubmit(发送/停止按钮)
│ └── Suggestions(追问建议列表)
└── ModelSelector(模型下拉选择)
```
**四种会话模式:**
| Mode | 说明 |
|------|------|
| `flash` | 快速模式,无推理步骤 |
| `thinking` | 推理模式,开启深度思考 |
| `pro` | 专业模式,多模态 + 规划 |
| `ultra` | 超级模式,启用子智能体 |
**推理努力等级(mode ≠ flash 时可选):** `minimal` / `low` / `medium` / `high`
---
### 5.3 侧边栏(`components/workspace/workspace-sidebar.tsx`)
```
WorkspaceSidebar
├── SidebarHeader → WorkspaceHeader
│ ├── Logo(可点击,跳转 /page/workspace/chats/new)
│ └── 新建对话按钮
├── SidebarContent
│ ├── WorkspaceNavChatList
│ │ ├── 新建对话(/page/workspace/chats/new)
│ │ ├── 所有对话(/page/workspace/chats)
│ │ ├── 智能体(/page/workspace/agents)
│ │ └── 任务管理(/page/workspace/scheduled/tasks)
│ └── RecentChatList(最近会话,含搜索)
└── SidebarFooter → WorkspaceNavMenu
├── Settings(打开设置 Dialog)
├── About
└── GitHub
```
---
### 5.4 制品管理(`components/workspace/artifacts/`)
```typescript
interface ArtifactsContextType {
artifacts: string[]; // artifact ID 列表
selectedArtifact: string | null;
autoSelect: boolean; // 是否自动选中最新制品
open: boolean; // 面板是否展开
autoOpen: boolean;
select(artifact: string, autoSelect?: boolean): void;
deselect(): void;
setOpen(open: boolean): void;
}
```
**文件:**
| 文件 | 职责 |
|------|------|
| `context.tsx` | ArtifactsContext + Provider |
| `artifact-trigger.tsx` | 面板展开/收起按钮 |
| `artifact-file-list.tsx` | 制品文件列表 |
| `artifact-file-detail.tsx` | 单个制品详情(含代码编辑器) |
---
### 5.5 设置面板(`components/workspace/settings/`)
| 页面文件 | 内容 |
|---------|------|
| `appearance-settings-page.tsx` | 主题切换(light/dark/system)、语言切换 |
| `memory-settings-page.tsx` | 用户记忆(事实列表、总结管理) |
| `notification-settings-page.tsx` | 桌面通知开关 |
| `skill-settings-page.tsx` | 技能启用/禁用 |
| `tool-settings-page.tsx` | 工具配置 |
| `about-settings-page.tsx` | 版本号、仓库链接 |
---
### 5.6 命令面板(`components/workspace/command-palette.tsx`)
通过 `Cmd/Ctrl + K` 触发,提供快速导航和操作入口。
```typescript
const handleNewChat = useCallback(() => {
router.push("/page/workspace/chats/new");
setOpen(false);
}, [router]);
```
---
## 6. 核心 Hooks 与工具函数
### 6.1 useThreadStream(最核心 Hook)
**位置:** `core/threads/hooks.ts`
管理 LangGraph WebSocket 连接、消息流、乐观更新。
**签名:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId?: string, // 有值则连接现有线程,无值表示新建
context: LocalSettings["context"], // 模型、模式等
isMock?: boolean,
assistantId?: string, // 默认 "lead_agent"
onStart?: (threadId: string) => void,
onFinish?: (state: AgentThreadState) => void,
onToolEnd?: (event: ToolEndEvent) => void,
});
```
**thread 对象关键属性:**
```typescript
thread: {
messages: Message[], // 含乐观消息的完整列表
isLoading: boolean, // 流是否进行中
isThreadLoading: boolean, // 初始线程数据是否加载中
values: AgentThreadState, // 线程最终状态
error?: Error,
stop: async () => void, // 终止当前流
}
```
**sendMessage 签名:**
```typescript
sendMessage(
threadId: string | undefined,
message: PromptInputMessage,
extraContext?: Partial<AgentThreadContext>,
options?: { isMock?: boolean }
): Promise<void>
```
**内部机制:**
- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应
- **文件上传:** 自动调用 `uploadFiles()` 后再提交
- **run_id 持久化:** sessionStorage 保存,支持页面刷新后断线重连
- **消息同步:** 流结束后通过 `queryClient.invalidateQueries(["threads"])` 刷新列表
---
### 6.2 useThreadSettings
**位置:** `core/settings/hooks.ts`
```typescript
const [settings, setSettings] = useThreadSettings(threadId);
// settings 结构
{
notification: { enabled: boolean },
context: {
model_name?: string,
mode: "flash" | "thinking" | "pro" | "ultra",
reasoning_effort?: "minimal" | "low" | "medium" | "high"
}
}
// 修改(合并更新)
setSettings("context", { mode: "pro" });
setSettings("notification", { enabled: true });
```
**工作原理:** `useSyncExternalStore` 监听 localStorage 变化,跨标签页同步。
---
### 6.3 useI18n
**位置:** `core/i18n/hooks.ts`
```typescript
const { t, locale, changeLocale } = useI18n();
t.sidebar.newChat // "New Chat"
t.agents.createPageTitle // "Create Agent"
t.toolCalls.moreSteps(3) // "Show 3 more steps"
changeLocale("zh-CN"); // 切换语言,自动存 cookie
```
---
### 6.4 useScheduledTaskActions
**位置:** `core/scheduled-tasks/hooks.ts`
```typescript
const actions = useScheduledTaskActions();
// 所有返回值均为 TanStack Query Mutation
actions.create // 创建定时任务
actions.update // 更新任务配置
actions.remove // 删除任务
actions.removeRun // 删除执行记录
actions.pause // 暂停
actions.resume // 恢复
actions.trigger // 立即触发一次
actions.publish // 发布(激活)
actions.unpublish // 取消发布
actions.subscribe // 订阅推送
actions.updateSubscription
actions.unsubscribe
```
---
### 6.5 工具函数
**消息处理(`core/messages/utils.ts`):**
```typescript
groupMessages(messages, mapper) // 按类型分组消息列表
extractTextFromMessage(message) // 提取纯文本
extractContentFromMessage(message) // 提取 Markdown/图像内容
extractReasoningContentFromMessage(msg) // 提取推理过程
hasToolCalls(message) // 是否含工具调用
hasPresentFiles(message) // 是否包含文件呈现
extractPresentFilesFromMessage(message) // 获取文件列表
isClarificationToolMessage(message) // 是否为澄清请求
```
**线程路由(`core/threads/utils.ts`):**
```typescript
pathOfThread(thread, context?)
// → "/page/workspace/agents/{agentRef}/chats/{threadId}"
// 或 "/page/workspace/chats/{threadId}"
titleOfThread(thread)
// → thread.values?.title ?? "Untitled"
textOfMessage(message)
// → 消息文本内容(string 或 content 数组中第一个 text)
```
**其他工具:**
```typescript
// core/utils/files.tsx
downloadFile(blob: Blob, filename: string)
// core/utils/datetime.ts
formatTimeAgo(dateString: string) // → "2 小时前" / "3 days ago"
```
---
## 7. API 调用层
### 7.1 API 客户端架构
**两层设计:**
**第一层:LangGraph SDK Client**(`core/api/api-client.ts`)
```typescript
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(url, init) {
const headers = new Headers(init.headers ?? {});
headers.set("Authorization", getAuthorizationHeaderValue());
return { ...init, headers };
}
});
export function getAPIClient(isMock?: boolean): LangGraphClient
```
**第二层:Fetch Client**(`core/api/fetch-client.ts`)
```typescript
function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
return fetch(input, {
...init,
headers: withAuthHeaders(init?.headers)
});
}
```
### 7.2 主要 API 模块
| 模块 | 文件 | 关键函数 |
|------|------|---------|
| **Auth** | `core/auth/api.ts` | `loginByUsername(username)` → `LoginByUsernameResponse` |
| **Agents** | `core/agents/api.ts` | `createAgent()`, `updateAgent()`, `listAgents()`, `deleteAgent()`, `checkAgentName()` |
| **Models** | `core/models/api.ts` | `loadModels()` → `Model[]` |
| **Threads** | (via LangGraph SDK) | `useThreadStream()`, `useThreads()`, `useDeleteThread()`, `useRenameThread()` |
| **Skills** | `core/skills/api.ts` | `loadSkills()`, `enableSkill(name, enabled)` |
| **Uploads** | `core/uploads/api.ts` | `uploadFiles(threadId, files[])`, `listUploadedFiles()`, `deleteUploadedFile()` |
| **Memory** | `core/memory/api.ts` | `loadMemory()`, `createMemoryFact()`, `updateMemoryFact()`, `deleteMemoryFact()` |
| **Scheduled Tasks** | `core/scheduled-tasks/api.ts` | CRUD、publish、pause、resume、trigger、subscribe |
### 7.3 Base URL 配置
**文件:** `core/config/index.ts`
```typescript
export function getBackendBaseURL(): string {
// env.NEXT_PUBLIC_BACKEND_BASE_URL
// 默认:""(当前域名根路径)
}
export function getLangGraphBaseURL(isMock?: boolean): string {
// mock 模式:${origin}/mock/api
// 正常:${origin}/api/langgraph
// 或读取 env.NEXT_PUBLIC_LANGGRAPH_BASE_URL
}
```
### 7.4 认证流程
```
LoginPage 调用 loginByUsername(username)
↓ POST /api/auth/login
↓ 返回 { access_token, token_type, user_id, email }
↓ setStoredAuth() 存入 localStorage["deerflow.auth"]
↓
后续所有 API 请求
↓ getAuthorizationHeaderValue()
↓ → "Bearer {access_token}"
↓ 自动添加到 Authorization header
```
---
## 8. 国际化机制
### 8.1 文件结构
```
core/i18n/
├── context.tsx # I18nProvider(包裹 App)
├── hooks.ts # useI18n()
├── locale.ts # detectLocale(),Locale = "en-US" | "zh-CN"
├── cookies.ts # cookie 读写(持久化语言选择)
├── translations.ts # 翻译映射表入口
└── locales/
├── types.ts # Translations 接口定义(所有翻译 key 的类型)
├── en-US.ts # 英文翻译
└── zh-CN.ts # 简体中文翻译
```
### 8.2 Translations 接口分类
```typescript
interface Translations {
locale: { localName: string } // 语言自身名称
common: { ... } // 通用词汇(确认、取消等)
welcome: { ... } // 欢迎屏
inputBox: { ... } // 输入框(模式、占位符)
sidebar: { ... } // 侧边栏导航
agents: { ... } // 智能体相关
chats: { ... } // 对话相关
workspace: { ... } // 工作区通用
settings: { ... } // 设置面板
toolCalls: { ... } // 工具调用展示
uploads: { ... } // 文件上传
subtasks: { ... } // 子任务
tokenUsage: { ... } // Token 用量
shortcuts: { ... } // 快捷键说明
}
```
### 8.3 语言检测优先级
1. 读取 cookie `deerflow.locale`
2. 读取浏览器 `navigator.language`
3. 回退到 `en-US`
---
## 9. 主题与样式体系
### 9.1 主题系统
**库:** `next-themes`
```typescript
// App.tsx
<NextThemesProvider
attribute="class" // 通过 <html class="dark"> 应用主题
enableSystem // 跟随系统偏好
disableTransitionOnChange // 切换时禁用过渡动画
forcedTheme={pathname === "/landing" ? "dark" : undefined}
>
```
**可选主题值:** `light` / `dark` / `system`
### 9.2 样式方案
**基础:** Tailwind CSS
**组件库:** shadcn/ui(`components/ui/` 目录,40+ 基础组件)
**主要 CSS 自定义属性:**
```css
--container-width-sm /* 小容器宽度 */
--container-width-md /* 中等容器宽度(对话主区域) */
--primary /* 主色 */
--background /* 页面背景 */
--foreground /* 主文本色 */
--muted-foreground /* 次要文本色 */
--border /* 边框色 */
--sidebar-foreground /* 侧边栏文本 */
```
### 9.3 样式使用规范
- 工具类优先(Tailwind)
- 动态样式用 `cn()` 工具函数合并(基于 `clsx` + `tailwind-merge`)
- 主题感知样式使用 CSS 变量(如 `text-muted-foreground`)
---
## 10. 关键业务流程
### 10.1 新建对话流程
```
1. 点击"新建对话" → navigate("/page/workspace/chats/new")
2. ChatPage 挂载,threadId = uuid(),isNewThread = true
3. 用户输入消息,点击发送
4. sendMessage(undefined, message) 调用
5. useThreadStream 内部:
a. 上传文件(若有)
b. 创建 LangGraph 线程,后端返回 threadId
c. onStart(threadId) 回调:
- setIsNewThread(false)
- history.replaceState 更新 URL hash
6. WebSocket 流开始接收 AI 消息
7. MessageList 实时渲染消息
8. 流结束,onFinish 回调,可能触发桌面通知
```
### 10.2 创建智能体流程
```
Step 1(name 步骤)
↓ 用户输入名称
↓ checkAgentName(name) → 验证是否重名
↓ createAgent({ name, skills: selectedSkills })
↓ 后端返回 agent.id,setStep("chat")
Step 2(chat 步骤)
↓ 生成临时 threadId
↓ useThreadStream({ assistantId: agent.id })
↓ 发送 bootstrap 消息(触发智能体自我设置)
↓ onToolEnd 监听:event.name === "setup_agent"
↓ 指数退避重试加载 agent 信息,确认已保存
↓ 显示"Agent Created"完成界面
↓ 用户可点击"开始对话"或"返回列表"
```
### 10.3 文件上传流程
```
1. 用户在 InputBox 选择/粘贴文件
2. PromptInputAttachments 展示预览列表
3. 用户点击发送
4. sendMessage 内部调用 uploadFiles(threadId, files)
5. POST /api/threads/{threadId}/upload
6. 后端返回 { files: [{ filename, size, virtual_path }] }
7. 构建 Message additional_kwargs.files
8. 完整 Message 提交到 LangGraph
```
---
## 11. 项目配置与依赖
### 11.1 主要依赖
| 包 | 版本 | 用途 |
|----|------|------|
| `react` | ^18 | 核心框架 |
| `react-router-dom` | ^6 | 客户端路由(HashRouter) |
| `@tanstack/react-query` | - | 服务端状态缓存 |
| `@langchain/langgraph-sdk` | - | LangGraph WebSocket 连接 |
| `next-themes` | - | 主题管理 |
| `sonner` | - | Toast 通知 |
| `lucide-react` | - | 图标库 |
| `tailwindcss` | - | CSS 框架 |
| `rehype` 系列 | - | Markdown 渲染 |
| `uuid` | - | 生成临时 thread ID |
### 11.2 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `NEXT_PUBLIC_BACKEND_BASE_URL` | 后端接口基础 URL | `""` (当前域名) |
| `NEXT_PUBLIC_LANGGRAPH_BASE_URL` | LangGraph API URL | `/api/langgraph` |
| `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` | 纯静态演示模式 | `"false"` |
---
## 12. 常见开发模式
### 12.1 添加新工作区页面
```
1. 创建 src/pages/MyPage.tsx
2. 在 WorkspaceRoutes.tsx 中添加路由:
<Route path="my-page" element={<WorkspaceLayout><MyPage /></WorkspaceLayout>} />
3. 在 workspace-nav-chat-list.tsx 中添加导航入口(如需要)
4. 更新相关的跳转路径
```
### 12.2 添加新 API 接口
```
1. 在 core/xxx/api.ts 中定义 API 函数(使用 apiFetch 或 getAPIClient())
2. 在 core/xxx/hooks.ts 中封装 useQuery / useMutation
3. 在页面或组件中调用 hook
```
### 12.3 添加新 Context
```
1. 创建 core/xxx/context.tsx:定义 Context + Provider + 默认值
2. 创建消费 hook(如 useXxx())
3. 在合适的组件树根部插入 Provider(WorkspaceRoutes 或 ChatRuntime)
```
### 12.4 添加国际化文本
```
1. 在 core/i18n/locales/types.ts 的 Translations 接口中添加字段
2. 在 core/i18n/locales/en-US.ts 添加英文内容
3. 在 core/i18n/locales/zh-CN.ts 添加中文内容
4. 组件中通过 useI18n() 访问:const { t } = useI18n(); t.xxx.yyy
```
### 12.5 引入外部系统路由
在 `src/pages/PageRoutes.tsx` 中找到 TODO 注释位置,填入:
```typescript
import AnotherSystemRoutes from "外部系统路径";
// 在 Routes 中添加:
<Route path="other/*" element={<AnotherSystemRoutes />} />
```
如外部系统有独立 Provider 依赖,在此文件或 App.tsx 中包裹。
---
## 附:关键文件速查
| 需求 | 关键文件 |
|------|---------|
| 修改路由结构 | `App.tsx`, `PageRoutes.tsx`, `WorkspaceRoutes.tsx` |
| 修改侧边栏导航 | `components/workspace/workspace-nav-chat-list.tsx` |
| 修改对话逻辑 | `pages/ChatPage.tsx`, `core/threads/hooks.ts` |
| 修改输入框 | `components/workspace/input-box.tsx`, `components/ai-elements/prompt-input/` |
| 修改消息渲染 | `components/workspace/messages/` |
| 添加 API 接口 | `core/xxx/api.ts` + `core/xxx/hooks.ts` |
| 修改全局设置 | `core/settings/store.ts`, `core/settings/hooks.ts` |
| 修改翻译文本 | `core/i18n/locales/en-US.ts`, `core/i18n/locales/zh-CN.ts` |
| 修改主题 | `App.tsx` (NextThemesProvider) |
| 修改认证逻辑 | `core/auth/api.ts`, `pages/LoginPage.tsx` |
| 定时任务 | `pages/ScheduledTasksPage.tsx`, `core/scheduled-tasks/` |
| 智能体管理 | `pages/NewAgentPage.tsx`, `core/agents/` |
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CM 助手</title>
<title>DeerFlow Vite</title>
</head>
<body>
<div id="root"></div>
-1
View File
@@ -50,7 +50,6 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
+155 -50
View File
@@ -1,50 +1,155 @@
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return (
<NextThemesProvider
attribute="class"
enableSystem
disableTransitionOnChange
forcedTheme={forcedTheme}
>
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
</NextThemesProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import ChatsPage from "./pages/ChatsPage";
import ChatPage from "./pages/ChatPage";
import AgentsPage from "./pages/AgentsPage";
import AgentChatPage from "./pages/AgentChatPage";
import NewAgentPage from "./pages/NewAgentPage";
import LoginPage from "./pages/LoginPage";
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return (
<NextThemesProvider
attribute="class"
enableSystem
disableTransitionOnChange
forcedTheme={forcedTheme}
>
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
</NextThemesProvider>
);
}
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className="h-screen" defaultOpen>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: React.ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
<Route
path="/workspace"
element={<Navigate to="/workspace/chats/new" replace />}
/>
<Route
path="/workspace/chats"
element={
<WorkspaceLayout>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents"
element={
<WorkspaceLayout>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled"
element={<Navigate to="/workspace/scheduled/tasks" replace />}
/>
<Route
path="/workspace/scheduled/tasks"
element={
<WorkspaceLayout>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled/runs/:run_id"
element={
<WorkspaceLayout>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/new"
element={
<WorkspaceLayout>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/:agent_id/chats"
element={<Navigate to="/workspace/agents" replace />}
/>
<Route
path="/workspace/agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
@@ -674,22 +674,12 @@ export const PromptInput = ({
useEffect(() => {
if (!globalDrop) return;
// Suppress the global chat-upload handler when the drop happens inside an open
// modal dialog or popover — those owners (e.g. the skill-install dialog) handle
// their own file uploads and should not leak into the active chat thread.
const isInsideModal = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
return target.closest('[role="dialog"], [role="alertdialog"]') !== null;
};
const onDragOver = (e: DragEvent) => {
if (isInsideModal(e.target)) return;
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (isInsideModal(e.target)) return;
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
@@ -12,7 +12,7 @@ export function Footer() {
</div>
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
<p>Licensed under MIT License</p>
<p>&copy; {year} CM 助手</p>
<p>&copy; {year} CM业务中心-问答管理</p>
</div>
</footer>
);
+77 -2
View File
@@ -1,9 +1,14 @@
"use client";
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import type { Locale } from "@/core/i18n/locale";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export type HeaderProps = {
@@ -25,11 +30,11 @@ export function Header({ className, homeURL }: HeaderProps) {
>
<div className="flex items-center gap-6">
<a
href={homeURL ?? "/"}
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
target={isExternalHome ? "_blank" : "_self"}
rel={isExternalHome ? "noopener noreferrer" : undefined}
>
<h1 className="font-serif text-xl">CM 助手</h1>
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
</a>
</div>
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
@@ -46,7 +51,77 @@ export function Header({ className, homeURL }: HeaderProps) {
{t.home.blog}
</Link>
</nav>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(16px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a>
</Button>
</div>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
);
}
function StarCounter() {
const [stars, setStars] = useState(10000);
useEffect(() => {
let cancelled = false;
async function loadStars() {
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
},
);
if (!response.ok) return;
const data = await response.json();
if (!cancelled && typeof data?.stargazers_count === "number") {
setStars(data.stargazers_count);
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
}
void loadStars();
return () => {
cancelled = true;
};
}, []);
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
<NumberTicker className="font-mono tabular-nums" value={stars} />
</>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function Hero({ className }: { className?: string }) {
"Learn Anything",
]}
/>{" "}
<div>CM 助手</div>
<div>CM业务中心-问答管理</div>
</h1>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<a
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
<div className="border-b border-zinc-800 p-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-sm text-zinc-400">CM 助手 Agent</span>
<span className="text-sm text-zinc-400">CM业务中心-问答管理 Agent</span>
</div>
</div>
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
{/* Chat Input (decorative) */}
<div className="border-t border-zinc-800 p-4">
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-500">
Ask CM 助手 anything...
Ask CM业务中心-问答管理 anything...
</div>
</div>
</div>
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
<Section
className={className}
title="Case Studies"
subtitle="CM 助手"
subtitle="CM业务中心-问答管理"
>
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
{caseStudies.map((caseStudy) => (
@@ -1,6 +1,10 @@
"use client";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button";
import { Section } from "../section";
@@ -12,9 +16,20 @@ export function CommunitySection() {
Join the Community
</AuroraText>
}
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
>
<div />
<div className="flex justify-center">
<Button className="text-xl" size="lg" asChild>
<Link
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon />
Contribute Now
</Link>
</Button>
</div>
</Section>
);
}
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
title="Agent Runtime Environment"
subtitle={
<p>
We give CM Assistant a &quot;computer&quot;, which can execute commands,
We give DeerFlow a &quot;computer&quot;, which can execute commands,
manage files, and run long tasks — all in a secure Docker-based
sandbox
</p>
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
Agent Skills are loaded progressively — only what&apos;s needed, when
it&apos;s needed.
<br />
Extend CM Assistant with your own skill files, or use our built-in
Extend DeerFlow with your own skill files, or use our built-in
library.
</div>
}
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
return (
<Section
className={cn("", className)}
title="CM 助手"
subtitle="CM 助手"
title="CM业务中心-问答管理"
subtitle="CM业务中心-问答管理"
>
<div className="flex w-full items-center justify-center">
<MagicBento data={features} />
@@ -1,387 +0,0 @@
import { useState } from "react";
import type { ElementType } from "react";
import {
BarChart3,
Bot,
Brain,
ChevronLeft,
ChevronRight,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
ListTodo,
MessageSquare,
MoreHorizontal,
Network,
PlayCircle,
Plus,
Scale,
Settings,
Target,
TrendingUp,
Users,
Workflow,
} from "lucide-react";
type MenuItem = {
id: string;
label: string;
icon: ElementType;
children?: MenuItem[];
};
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "QA Center",
icon: HelpCircle,
children: [
{ id: "new-qa", label: "New QA", icon: Plus },
{ id: "general-qa", label: "General QA", icon: CircleHelp },
{ id: "agent-qa", label: "Agent QA", icon: Bot },
{ id: "multi-agent-qa", label: "Multi Agent", icon: Layers },
{ id: "sixstep-chat", label: "Six Step Chat", icon: Workflow },
{ id: "qa-records", label: "QA Records", icon: History },
{ id: "template-editor", label: "Template Config", icon: Settings },
],
},
{
id: "light-app-center",
label: "Light Apps",
icon: Grid3X3,
children: [
{ id: "light-app-square", label: "App Square", icon: Layout },
{ id: "light-app-records", label: "Run Records", icon: History },
],
},
{
id: "task-management",
label: "Task Management",
icon: Target,
children: [
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
{
id: "xk-task",
label: "XK Tasks",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "Task JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo },
],
},
{
id: "plan-task",
label: "Planning Tasks",
icon: Folder,
children: [
{ id: "plan-task-fx", label: "Task Analysis", icon: ListTodo },
{ id: "plan-task-action", label: "Action Strategy", icon: ListTodo },
],
},
{
id: "mnty-task",
label: "MNTY Tasks",
icon: Folder,
children: [
{ id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo },
{ id: "mnty-task-plan", label: "Plan Task", icon: ListTodo },
{ id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo },
],
},
{
id: "effect-task",
label: "Effect PG Tasks",
icon: Folder,
children: [
{ id: "effect-task-pg", label: "Task PG", icon: ListTodo },
{ id: "effect-action-pg", label: "Action PG", icon: ListTodo },
{ id: "effect-info-pg", label: "Info PG", icon: ListTodo },
{ id: "effect-account-pg", label: "Account PG", icon: ListTodo },
],
},
{ id: "task-create", label: "Create Task", icon: Plus },
{ id: "task-single", label: "Single Agent Task", icon: Bot },
{ id: "task-scene", label: "Scene Task", icon: Workflow },
{ id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare },
{ id: "final-plan", label: "Final Output", icon: FileOutput },
],
},
{ id: "scene-list", label: "Scene Management", icon: Layers },
{
id: "agent-list",
label: "Agent Management",
icon: Bot,
children: [
{ id: "agent-overview", label: "Agent List", icon: Users },
{ id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle },
],
},
{
id: "result-management",
label: "Result Management",
icon: BarChart3,
children: [
{
id: "result-planning",
label: "Planning Results",
icon: Target,
children: [
{ id: "result-planning-all", label: "All", icon: BarChart3 },
{ id: "result-planning-goal", label: "Goal Analysis", icon: Target },
{ id: "result-planning-brand", label: "Brand Strategy", icon: Target },
{ id: "result-planning-content", label: "Content Planning", icon: Target },
{ id: "result-planning-channel", label: "Channel Planning", icon: Target },
{ id: "result-planning-narrative", label: "Narrative Strategy", icon: Target },
],
},
{
id: "result-mnty",
label: "MNTY Results",
icon: Brain,
children: [
{ id: "result-mnty-all", label: "All", icon: BarChart3 },
{ id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain },
{ id: "result-mnty-plan", label: "Plan Design", icon: Brain },
{ id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain },
],
},
{
id: "result-effect-pg",
label: "Effect PG Results",
icon: TrendingUp,
children: [
{ id: "result-effect-pg-all", label: "All", icon: BarChart3 },
{ id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp },
{ id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp },
{ id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp },
{ id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp },
],
},
{
id: "result-xk",
label: "XK Results",
icon: Compass,
children: [
{ id: "result-xk-all", label: "All", icon: BarChart3 },
{ id: "result-xk-jc", label: "Task JC", icon: Compass },
{ id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass },
],
},
{
id: "result-dq",
label: "DQ Results",
icon: Database,
children: [
{ id: "result-dq-all", label: "All", icon: BarChart3 },
{ id: "result-dq-audience", label: "Audience Analysis", icon: Database },
{ id: "result-dq-kb", label: "Knowledge Research", icon: Database },
{ id: "result-dq-trend", label: "Trend Research", icon: Database },
],
},
{
id: "result-other",
label: "Other Results",
icon: MoreHorizontal,
children: [
{ id: "result-other-all", label: "All", icon: BarChart3 },
{ id: "result-other-general", label: "General Analysis", icon: MoreHorizontal },
{ id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal },
{ id: "result-other-other", label: "Other", icon: MoreHorizontal },
],
},
],
},
{
id: "resource-management",
label: "Resource Management",
icon: Database,
children: [
{ id: "resource-zz", label: "ZZ Resources", icon: FileBox },
{ id: "resource-network-actor", label: "Network Actor", icon: Network },
{ id: "resource-knowledge-base", label: "Knowledge Base", icon: Library },
{ id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"task-management",
"xk-task",
"result-management",
"result-planning",
]);
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const iconButtonClass =
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
}`}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
</button>
);
};
const renderBranch = (item: MenuItem, depth = 0) => {
const expanded = expandedIds.has(item.id);
const isTopLevel = depth === 0;
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
}`}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<aside
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
collapsed ? "w-16" : "w-64"
}`}
>
<div
className={`flex h-16 items-center border-b border-red-100 ${
collapsed ? "justify-center px-2" : "justify-between px-6"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
<p className="text-xs text-red-400/80">Page Navigation</p>
</div>
)}
</div>
{!collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={iconButtonClass}
title="Collapse sidebar"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
</div>
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={`flex w-full items-center justify-center ${iconButtonClass}`}
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
{menuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
</aside>
);
}
@@ -59,17 +59,11 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [],
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
const isBuiltin = agent.builtin;
const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit built-in agents in place. Delete and
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
// they are inherently visible to everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin);
useEffect(() => {
if (!editOpen) {
@@ -241,46 +235,40 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || isOwned) && (
{isOwned && (
<div className="flex gap-1">
{isOwned && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{canEdit && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
)}
</CardFooter>
@@ -1,69 +1,69 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}
@@ -2,15 +2,16 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
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";
@@ -33,11 +34,14 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
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 { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
@@ -113,13 +117,18 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const displayContent = 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 hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
@@ -139,8 +148,20 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | 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);
@@ -308,7 +329,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -328,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)}>
@@ -405,10 +452,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<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">
{!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}
@@ -494,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"
@@ -512,21 +574,31 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly
/>
</div>
)}
<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} />
@@ -541,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(() => {
@@ -580,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 (
@@ -34,23 +34,27 @@ const customLightTheme = basicLightInit({
},
});
export function CodeEditor({
className,
placeholder,
value,
readonly,
disabled,
autoFocus,
settings,
}: {
className?: string;
placeholder?: string;
value: string;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
}) {
export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
} = useThread();
@@ -77,8 +81,8 @@ export function CodeEditor({
className,
)}
>
{isLoading ? (
<Textarea
{isLoading && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
@@ -105,10 +109,11 @@ export function CodeEditor({
lineNumbers:
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
}}
autoFocus={autoFocus}
value={value}
/>
)}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
</div>
);
}
@@ -1,134 +1,134 @@
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
import { SettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/page/workspace/chats/new");
setOpen(false);
}, [router]);
const handleOpenSettings = useCallback(() => {
setOpen(false);
setSettingsOpen(true);
}, []);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
setShortcutsOpen(true);
}, []);
const shortcuts = useMemo(
() => [
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
{ key: "n", meta: true, shift: true, action: handleNewChat },
{ key: ",", meta: true, action: handleOpenSettings },
{ key: "/", meta: true, action: handleShowShortcuts },
],
[handleNewChat, handleOpenSettings, handleShowShortcuts],
);
useGlobalShortcuts(shortcuts);
useEffect(() => {
setIsMac(navigator.userAgent.includes("Mac"));
}, []);
const metaKey = isMac ? "⌘" : "Ctrl+";
const shiftKey = isMac ? "⇧" : "Shift+";
return (
<>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t.shortcuts.searchActions} />
<CommandList>
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
<CommandGroup heading={t.shortcuts.actions}>
<CommandItem onSelect={handleNewChat}>
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
{t.sidebar.newChat}
<CommandShortcut>
{metaKey}
{shiftKey}N
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<SettingsIcon className="mr-2 h-4 w-4" />
{t.common.settings}
<CommandShortcut>{metaKey},</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowShortcuts}>
<KeyboardIcon className="mr-2 h-4 w-4" />
{t.shortcuts.keyboardShortcuts}
<CommandShortcut>{metaKey}/</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
<DialogDescription>
{t.shortcuts.keyboardShortcutsDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{[
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
{ keys: `${metaKey},`, label: t.common.settings },
{
keys: `${metaKey}/`,
label: t.shortcuts.keyboardShortcuts,
},
].map(({ keys, label }) => (
<div key={keys} className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
{keys}
</kbd>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
import { SettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/workspace/chats/new");
setOpen(false);
}, [router]);
const handleOpenSettings = useCallback(() => {
setOpen(false);
setSettingsOpen(true);
}, []);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
setShortcutsOpen(true);
}, []);
const shortcuts = useMemo(
() => [
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
{ key: "n", meta: true, shift: true, action: handleNewChat },
{ key: ",", meta: true, action: handleOpenSettings },
{ key: "/", meta: true, action: handleShowShortcuts },
],
[handleNewChat, handleOpenSettings, handleShowShortcuts],
);
useGlobalShortcuts(shortcuts);
useEffect(() => {
setIsMac(navigator.userAgent.includes("Mac"));
}, []);
const metaKey = isMac ? "⌘" : "Ctrl+";
const shiftKey = isMac ? "⇧" : "Shift+";
return (
<>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t.shortcuts.searchActions} />
<CommandList>
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
<CommandGroup heading={t.shortcuts.actions}>
<CommandItem onSelect={handleNewChat}>
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
{t.sidebar.newChat}
<CommandShortcut>
{metaKey}
{shiftKey}N
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<SettingsIcon className="mr-2 h-4 w-4" />
{t.common.settings}
<CommandShortcut>{metaKey},</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowShortcuts}>
<KeyboardIcon className="mr-2 h-4 w-4" />
{t.shortcuts.keyboardShortcuts}
<CommandShortcut>{metaKey}/</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
<DialogDescription>
{t.shortcuts.keyboardShortcutsDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{[
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
{ keys: `${metaKey},`, label: t.common.settings },
{
keys: `${metaKey}/`,
label: t.shortcuts.keyboardShortcuts,
},
].map(({ keys, label }) => (
<div key={keys} className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
{keys}
</kbd>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -2,8 +2,9 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
GraduationCapIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
PlusIcon,
@@ -122,9 +123,10 @@ export function InputBox({
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
threadId: string;
@@ -134,10 +136,11 @@ export function InputBox({
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
},
) => void;
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;
@@ -236,15 +239,22 @@ export function InputBox({
[onContextChange, context, supportThinking],
);
const handleReasoningEffortSelect = useCallback(
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -505,8 +515,28 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
<PromptInputActionMenu>
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
<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={
context.mode === "flash" ||
@@ -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}
@@ -1,14 +1,14 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -42,21 +42,17 @@ export function MessageList({
className,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
onClarificationSubmit,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
@@ -87,13 +83,13 @@ export function MessageList({
if (message && hasContent(message)) {
return (
<div key={group.id} className="w-full">
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
/>
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -103,23 +99,6 @@ export function MessageList({
);
}
return null;
} else if (group.type === "assistant:setup-agent-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupAgentApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupAgentApprove}
onReject={onSetupAgentReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -267,289 +246,150 @@ export function MessageList({
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
);
}
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupAgentApprovalPayload;
}
function SetupAgentApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupAgentApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建专家审批
</div>
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
</div>
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">名称</div>
<p className="text-sm font-medium">{approvalPayload.name}</p>
</div>
) : null}
{approvalPayload.description ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.description}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">技能</div>
<div className="flex flex-wrap gap-1.5">
{skills.map((skill) => (
<span
key={skill}
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
>
{skill}
</span>
))}
</div>
</div>
) : null}
{approvalPayload.soul ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">SOUL.md</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.soul}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
拒绝
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
批准创建
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = isLoading || submitting || !onSubmit;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
);
}
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
};
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = isLoading || submitting || !onSubmit;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -117,7 +117,14 @@ export function RecentChatList() {
const handleShare = useCallback(
async (thread: AgentThread) => {
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
// Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied);
@@ -1,18 +1,67 @@
/**
* About CM Assistant markdown content. Inlined to avoid raw-loader dependency
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
* (Turbopack cannot resolve raw-loader for .md imports).
*/
export const aboutMarkdown = `# About CM Assistant
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
CM Assistant is an internal super-agent platform that orchestrates sub-agents, memory, and sandboxes — powered by extensible skills — to research, analyze, and create on your behalf.
> **From Open Source, Back to Open Source**
DeerFlow (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is an open-source **super agent harness** that orchestrates **sub-agents**, **memory**, and **sandboxes** to do almost anything — powered by **extensible skills**.
---
## 🚀 Core Features
* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant can do almost anything.
* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow can do almost anything.
* **Sub-Agents**: Sub-Agents help the main agent to do the tasks that are too complex to be done by the main agent.
* **Sandbox & File System**: Safely execute code and manipulate files in the sandbox.
* **Context Engineering**: Isolated sub-agent context, summarization to keep the context window sharp.
* **Long-Term Memory**: Keep recording the user's profile, top of mind, and conversation history.
---
## 🌟 GitHub Repository
![Star History Chart](https://api.star-history.com/svg?repos=bytedance/deer-flow&type=Date)
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
## 🌐 Official Website
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
## 📧 Support
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
---
## 📜 License
DeerFlow is proudly open source and distributed under the **MIT License**.
---
## 🙌 Acknowledgments
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
- **[Henry Li](https://github.com/magiccube/)**
Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today.
`;
@@ -1,15 +1,55 @@
# About CM Assistant
# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
CM Assistant is an internal super-agent platform that orchestrates sub-agents,
memory, and sandboxes — powered by extensible skills — to research, analyze,
and create on your behalf.
> **From Open Source, Back to Open Source**
**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates.
With the help of sandboxes, memories, tools and skills, it handles
different levels of tasks that could take minutes to hours.
---
## Core Features
## 🌟 GitHub Repository
* **Skills & Tools**: Built-in and extensible skills let CM Assistant tackle a wide range of tasks.
* **Sub-Agents**: Specialized sub-agents handle complex jobs the main agent delegates to them.
* **Sandbox & File System**: Safely execute code and manipulate files inside an isolated sandbox.
* **Context Engineering**: Isolated sub-agent context plus summarization keeps the context window sharp.
* **Long-Term Memory**: Continuously records your profile, top-of-mind notes, and conversation history.
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
## 🌐 Official Website
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
## 📧 Support
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
---
## 📜 License
DeerFlow is proudly open source and distributed under the **MIT License**.
---
## 🙌 Acknowledgments
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
- **[Henry Li](https://github.com/magiccube/)**
Without their vision, passion and dedication, `DeerFlow` would not be what it is today.
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
try {
setIsExporting(true);
const exportedMemory = await exportMemory();
const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
type: "application/json",
});
@@ -1,19 +1,10 @@
"use client";
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
import { SparklesIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
@@ -32,16 +23,9 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useEnableSkill,
useInstallSkillUpload,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -74,7 +58,6 @@ function SkillSettingsList({
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const [installOpen, setInstallOpen] = useState(false);
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
@@ -95,16 +78,7 @@ function SkillSettingsList({
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setInstallOpen(true)}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
>
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<div>
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
@@ -136,10 +110,6 @@ function SkillSettingsList({
</ItemActions>
</Item>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
/>
</div>
);
}
@@ -163,265 +133,3 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
</Empty>
);
}
function InstallSkillDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
const inputRef = useRef<HTMLInputElement | null>(null);
const [file, setFile] = useState<File | null>(null);
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const reset = useCallback(() => {
setFile(null);
setValidation(null);
setValidationError(null);
setIsDragging(false);
if (inputRef.current) inputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
if (validateMutation.isPending || installMutation.isPending) return;
reset();
onClose();
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
const acceptFile = useCallback(
async (selected: File) => {
const lower = selected.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync(selected);
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[dialog.rejectInvalidExt, validateMutation],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
// Stop the global chat-upload listener on `document` from also receiving this drop.
// React's stopPropagation alone doesn't reach native document listeners — we need to
// halt the underlying DOM event too.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) void acceptFile(dropped);
},
[acceptFile],
);
const onPick = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (picked) void acceptFile(picked);
},
[acceptFile],
);
const handleInstall = useCallback(
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
result.skill_name,
),
);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
const canOverwrite = !!validation && !validationError && isCustomConflict;
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dialog.title}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
)}
>
<FileArchive className="text-muted-foreground size-8" />
<div className="text-muted-foreground text-sm">
{dialog.dropHint}
<button
type="button"
className="text-primary ml-1 underline underline-offset-4"
onClick={() => inputRef.current?.click()}
>
{dialog.chooseFile}
</button>
</div>
{file && (
<div className="text-muted-foreground mt-1 text-xs">
{file.name} · {(file.size / 1024).toFixed(1)} KB
</div>
)}
<input
ref={inputRef}
type="file"
accept=".zip,.skill,application/zip"
hidden
onChange={onPick}
/>
</div>
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{dialog.validating}
</div>
)}
{validationError && (
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{validationError}</span>
</div>
)}
{validation && !validationError && (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="size-4" />
<span className="font-medium">{validation.skill_name}</span>
</div>
<Field label={dialog.descriptionLabel}>
<span className="line-clamp-3 whitespace-pre-wrap">
{validation.description || "—"}
</span>
</Field>
{validation.license && (
<Field label={dialog.licenseLabel}>{validation.license}</Field>
)}
{validation.allowed_tools.length > 0 && (
<Field label={dialog.toolsLabel}>
<div className="flex flex-wrap gap-1">
{validation.allowed_tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs">
{tool}
</Badge>
))}
</div>
</Field>
)}
{validation.warnings.length > 0 && (
<Field label={dialog.warningsLabel}>
<ul className="list-disc pl-4 text-xs">
{validation.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</Field>
)}
{isCustomConflict && (
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
</span>
</div>
)}
{isPublicConflict && (
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
</span>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={installMutation.isPending}
>
{dialog.cancel}
</Button>
{canOverwrite && (
<Button
variant="destructive"
onClick={() => handleInstall(true)}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{dialog.replace}
</Button>
)}
<Button
onClick={() => handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
@@ -15,6 +15,9 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { GithubIcon } from "./github-icon";
import { Tooltip } from "./tooltip";
export function WorkspaceContainer({
className,
children,
@@ -87,6 +90,18 @@ export function WorkspaceHeader({
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="pr-4">
<Tooltip content={t.workspace.githubTooltip}>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
className="opacity-75 transition hover:opacity-100"
>
<GithubIcon className="size-6" />
</a>
</Tooltip>
</div>
</header>
);
}
@@ -1,97 +1,97 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
@@ -1,65 +1,65 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
+2 -2
View File
@@ -74,7 +74,7 @@ export async function checkAgentName(
);
} catch {
throw new AgentNameCheckError(
"Could not reach the CM Assistant backend.",
"Could not reach the DeerFlow backend.",
"backend_unreachable",
);
}
@@ -83,7 +83,7 @@ export async function checkAgentName(
const err = (await res.json().catch(() => ({}))) as { detail?: string };
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
throw new AgentNameCheckError(
"Could not reach the CM Assistant backend.",
"Could not reach the DeerFlow backend.",
"backend_unreachable",
);
}
+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";
+16 -39
View File
@@ -61,11 +61,11 @@ export const enUS: Translations = {
welcome: {
greeting: "Hello, again!",
description:
"Welcome to CM Assistant. With built-in and custom skills, CM Assistant helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
"Welcome to 🦌 DeerFlow, an open source super agent. With built-in and custom skills, DeerFlow helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
createYourOwnSkill: "Create Your Own Skill",
createYourOwnSkillDescription:
"Create your own skill to release the power of CM Assistant. With customized skills,\nCM Assistant can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
"Create your own skill to release the power of DeerFlow. With customized skills,\nDeerFlow can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
},
// Clipboard
@@ -208,13 +208,13 @@ export const enUS: Translations = {
save: "Save agent",
saving: "Saving agent...",
saveRequested:
"Save requested. CM Assistant is generating and saving an initial version now.",
"Save requested. DeerFlow is generating and saving an initial version now.",
saveHint:
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
saveCommandMessage:
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
agentCreatedPendingRefresh:
"The agent was created, but CM Assistant could not load it yet. Please refresh this page in a moment.",
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
more: "More actions",
agentCreated: "Agent created!",
startChatting: "Start chatting",
@@ -229,13 +229,13 @@ export const enUS: Translations = {
// Workspace
workspace: {
officialWebsite: "CM Assistant's official website",
githubTooltip: "CM Assistant on Github",
officialWebsite: "DeerFlow's official website",
githubTooltip: "DeerFlow on Github",
settingsAndMore: "Settings and more",
visitGithub: "CM Assistant on GitHub",
visitGithub: "DeerFlow on GitHub",
reportIssue: "Report a issue",
contactUs: "Contact us",
about: "About CM Assistant",
about: "About DeerFlow",
},
// Conversation
@@ -251,7 +251,7 @@ export const enUS: Translations = {
// Page titles (document title)
pages: {
appName: "CM Assistant",
appName: "DeerFlow",
brandSubtitle: "Smart CM Platform",
chats: "Chats",
newChat: "New chat",
@@ -278,7 +278,7 @@ export const enUS: Translations = {
writeFile: "Write file",
clickToViewContent: "Click to view file content",
writeTodos: "Update to-do list",
skillInstallTooltip: "Install skill and make it available to CM Assistant",
skillInstallTooltip: "Install skill and make it available to DeerFlow",
},
// Subtasks
@@ -315,7 +315,7 @@ export const enUS: Translations = {
actions: "Actions",
keyboardShortcuts: "Keyboard Shortcuts",
keyboardShortcutsDescription:
"Navigate CM Assistant faster with keyboard shortcuts.",
"Navigate DeerFlow faster with keyboard shortcuts.",
openCommandPalette: "Open Command Palette",
toggleSidebar: "Toggle Sidebar",
},
@@ -323,7 +323,7 @@ export const enUS: Translations = {
// Settings
settings: {
title: "Settings",
description: "Adjust how CM Assistant looks and behaves for you.",
description: "Adjust how DeerFlow looks and behaves for you.",
sections: {
appearance: "Appearance",
memory: "Memory",
@@ -335,7 +335,7 @@ export const enUS: Translations = {
memory: {
title: "Memory",
description:
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
empty: "No memory data to display.",
rawJson: "Raw JSON",
exportButton: "Export memory",
@@ -433,43 +433,20 @@ export const enUS: Translations = {
description:
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
emptyButton: "Create Your First Skill",
installDialog: {
title: "Install skill",
dropHint: "Drop a .zip or .skill archive here, or",
chooseFile: "Choose file",
validating: "Validating archive…",
installing: "Installing…",
replace: "Replace existing",
install: "Install",
cancel: "Cancel",
nameLabel: "Name",
descriptionLabel: "Description",
licenseLabel: "License",
toolsLabel: "Allowed tools",
warningsLabel: "Warnings",
conflictCustom:
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
},
},
notification: {
title: "Notification",
description:
"CM Assistant only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
"DeerFlow only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
requestPermission: "Request notification permission",
deniedHint:
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
testButton: "Send test notification",
testTitle: "CM Assistant",
testTitle: "DeerFlow",
testBody: "This is a test notification.",
notSupported: "Your browser does not support notifications.",
disableNotification: "Disable notification",
@@ -354,30 +354,9 @@ export interface Translations {
title: string;
description: string;
createSkill: string;
installSkill: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
installDialog: {
title: string;
dropHint: string;
chooseFile: string;
validating: string;
installing: string;
replace: string;
install: string;
cancel: string;
nameLabel: string;
descriptionLabel: string;
licenseLabel: string;
toolsLabel: string;
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
};
};
notification: {
title: string;
+16 -38
View File
@@ -61,11 +61,11 @@ export const zhCN: Translations = {
welcome: {
greeting: "你好,欢迎回来!",
description:
"欢迎使用 CM 助手",
"欢迎使用 CM业务中心-问答管理",
createYourOwnSkill: "创建你自己的 Agent SKill",
createYourOwnSkillDescription:
"创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
"创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
},
// Clipboard
@@ -196,13 +196,13 @@ export const zhCN: Translations = {
save: "保存智能体",
saving: "正在保存智能体...",
saveRequested:
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
"已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。",
saveHint:
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
saveCommandMessage:
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
agentCreatedPendingRefresh:
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
"智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。",
more: "更多操作",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
@@ -217,13 +217,13 @@ export const zhCN: Translations = {
// Workspace
workspace: {
officialWebsite: "访问 CM 助手 官方网站",
githubTooltip: "访问 CM 助手 的 Github 仓库",
officialWebsite: "访问 CM业务中心-问答管理 官方网站",
githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库",
settingsAndMore: "设置和更多",
visitGithub: "在 Github 上查看 CM 助手",
visitGithub: "在 Github 上查看 CM业务中心-问答管理",
reportIssue: "报告问题",
contactUs: "联系我们",
about: "关于 CM 助手",
about: "关于 CM业务中心-问答管理",
},
// Conversation
@@ -239,7 +239,7 @@ export const zhCN: Translations = {
// Page titles (document title)
pages: {
appName: "CM 助手",
appName: "CM业务中心-问答管理",
brandSubtitle: "Smart CM Platform",
chats: "对话",
newChat: "新对话",
@@ -265,7 +265,7 @@ export const zhCN: Translations = {
writeFile: "写入文件",
clickToViewContent: "点击查看文件内容",
writeTodos: "更新 To-do 列表",
skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用",
skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用",
},
uploads: {
@@ -300,7 +300,7 @@ export const zhCN: Translations = {
noResults: "未找到结果。",
actions: "操作",
keyboardShortcuts: "键盘快捷键",
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。",
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。",
openCommandPalette: "打开命令面板",
toggleSidebar: "切换侧边栏",
},
@@ -308,7 +308,7 @@ export const zhCN: Translations = {
// Settings
settings: {
title: "设置",
description: "根据你的偏好调整 CM 助手 的界面和行为。",
description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。",
sections: {
appearance: "外观",
memory: "记忆",
@@ -320,7 +320,7 @@ export const zhCN: Translations = {
memory: {
title: "记忆",
description:
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
"CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。",
empty: "暂无可展示的记忆数据。",
rawJson: "原始 JSON",
exportButton: "导出记忆",
@@ -414,42 +414,20 @@ export const zhCN: Translations = {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
"将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能",
installDialog: {
title: "安装技能",
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
chooseFile: "选择文件",
validating: "正在校验文件…",
installing: "安装中…",
replace: "覆盖已有",
install: "安装",
cancel: "取消",
nameLabel: "名称",
descriptionLabel: "描述",
licenseLabel: "许可证",
toolsLabel: "允许的工具",
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
installSuccess: "技能 “{name}” 已安装。",
installSuccessReplaced: "技能 “{name}” 已替换。",
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
},
},
notification: {
title: "通知",
description:
"CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
"CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
requestPermission: "请求通知权限",
deniedHint:
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
testButton: "发送测试通知",
testTitle: "CM 助手",
testTitle: "CM业务中心-问答管理",
testBody: "这是一条测试通知。",
notSupported: "当前浏览器不支持通知功能。",
disableNotification: "关闭通知",
+18 -38
View File
@@ -14,20 +14,17 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
@@ -44,11 +41,10 @@ export function groupMessages<T>(
function lastOpenGroup() {
const last = groups[groups.length - 1];
if (
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification"
) {
return last;
}
@@ -70,23 +66,16 @@ export function groupMessages<T>(
}
if (message.type === "tool") {
if (isClarificationToolMessage(message)) {
if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:clarification",
messages: [message],
});
} else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
messages: [message],
});
} else {
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
open.messages.push(message);
@@ -297,19 +286,10 @@ export function hasPresentFiles(message: Message) {
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function isSetupAgentApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
+216 -287
View File
@@ -1,287 +1,216 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskRunFileUpdateResponse,
ScheduledTaskSubscription,
SchedulerThread,
} from "./types";
const baseUrl = () => `${getBackendBaseURL()}/api/scheduled-tasks`;
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Request failed" }));
throw new Error(error.detail ?? "Request failed");
}
return response.json() as Promise<T>;
}
export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
}
export async function createScheduledTask(data: {
name: string;
prompt: string;
schedule: string;
timezone?: string | null;
execution_agent_name?: string | null;
}) {
return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function listScheduledTaskRuns(taskId: string) {
return parseResponse<ScheduledTaskRun[]>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/runs`),
);
}
export async function getScheduledTaskRun(runId: string) {
return parseResponse<ScheduledTaskRun>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
);
}
export async function deleteScheduledTaskRun(runId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
}),
);
}
export function scheduledTaskRunDownloadUrl(runId: string) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
export function scheduledTaskRunFileDownloadUrl(runId: string, fileIndex: number) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}/download`;
}
export async function getScheduledTaskRunFileContent(runId: string, fileIndex: number) {
return parseResponse<ScheduledTaskResultFileContent>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`),
);
}
export async function updateScheduledTaskRunFile(
runId: string,
fileIndex: number,
content: string,
) {
return parseResponse<ScheduledTaskRunFileUpdateResponse>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
}),
);
}
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
async function downloadResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Download failed" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
if (
detail &&
typeof detail === "object" &&
"message" in detail &&
typeof detail.message === "string"
) {
throw new Error(detail.message);
}
throw new Error("Download failed");
}
const blob = await response.blob();
const contentType = response.headers.get("Content-Type") ?? blob.type;
const fallbackByType =
contentType.includes("application/zip") || contentType.includes("application/x-zip-compressed")
? fallbackFilename.replace(/\.[^.]+$/, ".zip")
: fallbackFilename;
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackByType;
const url = URL.createObjectURL(blob);
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);
}
export async function downloadScheduledTaskRun(runId: string) {
await downloadResponse(
await apiFetch(scheduledTaskRunDownloadUrl(runId)),
"scheduled-task-result.zip",
);
}
export async function downloadScheduledTaskRunFile(runId: string, fileIndex: number) {
await downloadResponse(
await apiFetch(scheduledTaskRunFileDownloadUrl(runId, fileIndex)),
"scheduled-task-file",
);
}
export async function getScheduledTaskDeliveryProfile() {
return parseResponse<ScheduledTaskDeliveryProfile>(
await apiFetch(`${baseUrl()}/delivery-profile`),
);
}
export async function updateScheduledTaskDeliveryProfile(data: {
element_user_id?: string | null;
}) {
return parseResponse<ScheduledTaskDeliveryProfile>(
await apiFetch(`${baseUrl()}/delivery-profile`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function updateScheduledTask(
taskId: string,
data: Partial<{
name: string;
prompt: string;
schedule: string;
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
}>,
) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function deleteScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "DELETE",
}),
);
}
export async function pauseScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
method: "POST",
}),
);
}
export async function resumeScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
method: "POST",
}),
);
}
export async function triggerScheduledTask(taskId: string) {
return parseResponse<{ success: boolean; task: ScheduledTask }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
method: "POST",
}),
);
}
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
const query = new URLSearchParams();
if (params.limit != null) query.set("limit", String(params.limit));
if (params.offset != null) query.set("offset", String(params.offset));
const suffix = query.toString();
return parseResponse<PublishedScheduledTask[]>(
await apiFetch(`${baseUrl()}/published${suffix ? `?${suffix}` : ""}`),
);
}
export async function publishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/publish`, {
method: "POST",
}),
);
}
export async function unpublishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/unpublish`, {
method: "POST",
}),
);
}
export async function subscribeScheduledTask(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function updateScheduledTaskSubscription(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function unsubscribeScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "DELETE",
}),
);
}
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
ScheduledTask,
ScheduledTaskDeliveryProfile,
ScheduledTaskResultFileContent,
ScheduledTaskRun,
SchedulerThread,
} from "./types";
const baseUrl = () => `${getBackendBaseURL()}/api/scheduled-tasks`;
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Request failed" }));
throw new Error(error.detail ?? "Request failed");
}
return response.json() as Promise<T>;
}
export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
}
export async function createScheduledTask(data: {
name: string;
prompt: string;
schedule: string;
timezone?: string | null;
execution_agent_name?: string | null;
}) {
return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function listScheduledTaskRuns(taskId: string) {
return parseResponse<ScheduledTaskRun[]>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/runs`),
);
}
export async function getScheduledTaskRun(runId: string) {
return parseResponse<ScheduledTaskRun>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
);
}
export async function deleteScheduledTaskRun(runId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
}),
);
}
export function scheduledTaskRunDownloadUrl(runId: string) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
export function scheduledTaskRunFileDownloadUrl(runId: string, fileIndex: number) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}/download`;
}
export async function getScheduledTaskRunFileContent(runId: string, fileIndex: number) {
return parseResponse<ScheduledTaskResultFileContent>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`),
);
}
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
async function downloadResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Download failed" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
if (
detail &&
typeof detail === "object" &&
"message" in detail &&
typeof detail.message === "string"
) {
throw new Error(detail.message);
}
throw new Error("Download failed");
}
const blob = await response.blob();
const contentType = response.headers.get("Content-Type") ?? blob.type;
const fallbackByType =
contentType.includes("application/zip") || contentType.includes("application/x-zip-compressed")
? fallbackFilename.replace(/\.[^.]+$/, ".zip")
: fallbackFilename;
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackByType;
const url = URL.createObjectURL(blob);
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);
}
export async function downloadScheduledTaskRun(runId: string) {
await downloadResponse(
await apiFetch(scheduledTaskRunDownloadUrl(runId)),
"scheduled-task-result.zip",
);
}
export async function downloadScheduledTaskRunFile(runId: string, fileIndex: number) {
await downloadResponse(
await apiFetch(scheduledTaskRunFileDownloadUrl(runId, fileIndex)),
"scheduled-task-file",
);
}
export async function getScheduledTaskDeliveryProfile() {
return parseResponse<ScheduledTaskDeliveryProfile>(
await apiFetch(`${baseUrl()}/delivery-profile`),
);
}
export async function updateScheduledTaskDeliveryProfile(data: {
element_user_id?: string | null;
}) {
return parseResponse<ScheduledTaskDeliveryProfile>(
await apiFetch(`${baseUrl()}/delivery-profile`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function updateScheduledTask(
taskId: string,
data: Partial<{
name: string;
prompt: string;
schedule: string;
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
}>,
) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function deleteScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "DELETE",
}),
);
}
export async function pauseScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
method: "POST",
}),
);
}
export async function resumeScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
method: "POST",
}),
);
}
export async function triggerScheduledTask(taskId: string) {
return parseResponse<{ success: boolean; task: ScheduledTask }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
method: "POST",
}),
);
}
+111 -148
View File
@@ -1,148 +1,111 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createScheduledTask,
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
pauseScheduledTask,
publishScheduledTask,
resumeScheduledTask,
subscribeScheduledTask,
triggerScheduledTask,
unpublishScheduledTask,
unsubscribeScheduledTask,
updateScheduledTaskDeliveryProfile,
updateScheduledTask,
updateScheduledTaskSubscription,
} from "./api";
export function useSchedulerThread() {
return useQuery({
queryKey: ["scheduled-tasks", "thread"],
queryFn: getSchedulerThread,
refetchOnWindowFocus: false,
});
}
export function useScheduledTasks() {
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskRuns(taskId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", taskId, "runs"],
queryFn: () => listScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskRun(runId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId],
queryFn: () => getScheduledTaskRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskDeliveryProfile() {
return useQuery({
queryKey: ["scheduled-tasks", "delivery-profile"],
queryFn: getScheduledTaskDeliveryProfile,
refetchOnWindowFocus: false,
});
}
export function usePublishedScheduledTasks(enabled: boolean = true) {
return useQuery({
queryKey: ["scheduled-tasks", "published"],
queryFn: () => listPublishedScheduledTasks({ limit: 100 }),
enabled,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
const invalidateRuns = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
return {
update: useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string;
data: Parameters<typeof updateScheduledTask>[1];
}) => updateScheduledTask(taskId, data),
onSuccess: invalidate,
}),
create: useMutation({
mutationFn: createScheduledTask,
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: deleteScheduledTask,
onSuccess: invalidate,
}),
removeRun: useMutation({
mutationFn: deleteScheduledTaskRun,
onSuccess: invalidate,
}),
pause: useMutation({
mutationFn: pauseScheduledTask,
onSuccess: invalidate,
}),
resume: useMutation({
mutationFn: resumeScheduledTask,
onSuccess: invalidate,
}),
trigger: useMutation({
mutationFn: triggerScheduledTask,
onSuccess: invalidateRuns,
}),
updateDeliveryProfile: useMutation({
mutationFn: updateScheduledTaskDeliveryProfile,
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "delivery-profile"],
}),
}),
publish: useMutation({
mutationFn: publishScheduledTask,
onSuccess: invalidate,
}),
unpublish: useMutation({
mutationFn: unpublishScheduledTask,
onSuccess: invalidate,
}),
subscribe: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
subscribeScheduledTask(taskId, notifyElement),
onSuccess: invalidate,
}),
updateSubscription: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
updateScheduledTaskSubscription(taskId, notifyElement),
onSuccess: invalidate,
}),
unsubscribe: useMutation({
mutationFn: unsubscribeScheduledTask,
onSuccess: invalidate,
}),
};
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createScheduledTask,
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
listScheduledTaskRuns,
listScheduledTasks,
pauseScheduledTask,
resumeScheduledTask,
triggerScheduledTask,
updateScheduledTaskDeliveryProfile,
updateScheduledTask,
} from "./api";
export function useSchedulerThread() {
return useQuery({
queryKey: ["scheduled-tasks", "thread"],
queryFn: getSchedulerThread,
refetchOnWindowFocus: false,
});
}
export function useScheduledTasks() {
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskRuns(taskId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", taskId, "runs"],
queryFn: () => listScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskRun(runId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId],
queryFn: () => getScheduledTaskRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskDeliveryProfile() {
return useQuery({
queryKey: ["scheduled-tasks", "delivery-profile"],
queryFn: getScheduledTaskDeliveryProfile,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
const invalidateRuns = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
return {
update: useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string;
data: Parameters<typeof updateScheduledTask>[1];
}) => updateScheduledTask(taskId, data),
onSuccess: invalidate,
}),
create: useMutation({
mutationFn: createScheduledTask,
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: deleteScheduledTask,
onSuccess: invalidate,
}),
removeRun: useMutation({
mutationFn: deleteScheduledTaskRun,
onSuccess: invalidate,
}),
pause: useMutation({
mutationFn: pauseScheduledTask,
onSuccess: invalidate,
}),
resume: useMutation({
mutationFn: resumeScheduledTask,
onSuccess: invalidate,
}),
trigger: useMutation({
mutationFn: triggerScheduledTask,
onSuccess: invalidateRuns,
}),
updateDeliveryProfile: useMutation({
mutationFn: updateScheduledTaskDeliveryProfile,
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "delivery-profile"],
}),
}),
};
}
@@ -1,274 +0,0 @@
/**
* 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.
*/
import {
AlignmentType,
Document,
HeadingLevel,
Packer,
Paragraph,
TextRun,
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,
];
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
/**
* 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.
*/
function parseInlineSegments(text: string): InlineSegment[] {
const tokens: InlineSegment[] = [];
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) {
tokens.push({ text: text.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) {
tokens.push({ text: match[5], italic: true });
} 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) });
}
return tokens.length > 0 ? tokens : [{ text }];
}
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
return segments.map((seg) => {
const opts: IRunOptions = {
text: seg.text,
bold: seg.bold,
italics: seg.italic,
...extra,
};
if (seg.code) {
opts.font = { name: "Consolas" };
}
return new TextRun(opts);
});
}
function makeParagraphFromInline(text: string): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeHeadingParagraph(level: number, text: string): Paragraph {
return new Paragraph({
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text)),
numbering: ordered
? { reference: "ordered-list", level: Math.min(level, 8) }
: undefined,
bullet: ordered ? undefined : { level: Math.min(level, 8) },
});
}
function makeCodeParagraph(line: string): Paragraph {
return new Paragraph({
children: [
new TextRun({
text: line || " ",
font: { name: "Consolas" },
}),
],
});
}
function makeQuoteParagraph(text: string): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
indent: { left: 720 },
});
}
function makeHorizontalRule(): Paragraph {
return new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "────────" })],
});
}
/**
* 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)
*/
function markdownToParagraphs(markdown: string): Paragraph[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const paragraphs: Paragraph[] = [];
let inFence = false;
let paragraphBuffer: string[] = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
paragraphBuffer = [];
};
for (const rawLine of lines) {
const line = rawLine.replace(/\t/g, " ");
if (line.trim().startsWith("```")) {
flushParagraph();
inFence = !inFence;
continue;
}
if (inFence) {
paragraphs.push(makeCodeParagraph(line));
continue;
}
if (!line.trim()) {
flushParagraph();
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
continue;
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
flushParagraph();
paragraphs.push(makeHorizontalRule());
continue;
}
const blockquote = /^>\s?(.*)$/.exec(line);
if (blockquote) {
flushParagraph();
paragraphs.push(makeQuoteParagraph(blockquote[1]));
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));
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));
continue;
}
paragraphBuffer.push(line.trim());
}
flushParagraph();
return paragraphs;
}
/**
* Convert a markdown string into a .docx Blob suitable for download.
*/
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
const children: Paragraph[] = [];
if (title?.trim()) {
children.push(
new Paragraph({
heading: HeadingLevel.TITLE,
children: [new TextRun({ text: title.trim(), bold: true })],
}),
);
}
children.push(...markdownToParagraphs(markdown));
const doc = new Document({
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 },
],
},
],
},
sections: [{ children }],
});
return Packer.toBlob(doc);
}
/**
* Trigger a browser download of the markdown source converted to .docx.
*/
export async function downloadMarkdownAsDocx(
markdown: string,
fileName: string,
title?: string,
): Promise<void> {
const blob = await markdownToDocxBlob(markdown, title);
const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document";
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeName}.docx`;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
+67 -101
View File
@@ -1,101 +1,67 @@
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
id: string;
task_id: string;
user_id: string;
notify_element: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type ScheduledTask = {
task_id: string;
name: string;
prompt: string;
schedule_text: string;
cron_expr: string;
timezone: string;
enabled: boolean;
next_run_at?: string | null;
last_run_at?: string | null;
last_status?: string | null;
source_agent_name?: string | null;
execution_agent_name?: string | null;
scheduler_thread_id: string;
published?: boolean;
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
};
export type PublishedScheduledTask = {
task_id: string;
name: string;
prompt_summary: string;
schedule_text: string;
timezone: string;
published_at?: string | null;
owner_user_id: string;
};
export type SchedulerThread = {
user_id: string;
thread_id: string;
};
export type ScheduledTaskDeliveryProfile = {
user_id: string;
element_user_id?: string | null;
};
export type ScheduledTaskResultFile = {
name: string;
path?: string | null;
mime_type?: string | null;
size?: number | null;
kind?: "markdown" | "attachment" | "artifact" | null;
};
export type ScheduledTaskResultFileContent = {
index: number;
name: string;
mime_type?: string | null;
kind?: string | null;
size?: number | null;
previewable: boolean;
content?: string | null;
};
export type ScheduledTaskRunResult = {
title: string;
content: string;
format: "markdown" | "text" | "json" | "mixed";
files: ScheduledTaskResultFile[];
created_at: string;
};
export type ScheduledTaskRun = {
id: string;
task_id: string;
user_id: string;
scheduler_thread_id: string;
agent_run_id?: string | null;
scheduled_for: string;
started_at?: string | null;
finished_at?: string | null;
status: "pending" | "running" | "succeeded" | "failed" | string;
error?: string | null;
result?: ScheduledTaskRunResult | null;
created_at?: string | null;
updated_at?: string | null;
can_edit?: boolean;
};
export type ScheduledTaskRunFileUpdateResponse = {
index: number;
name: string;
size: number;
mime_type?: string | null;
kind?: string | null;
};
export type ScheduledTask = {
task_id: string;
name: string;
prompt: string;
schedule_text: string;
cron_expr: string;
timezone: string;
enabled: boolean;
next_run_at?: string | null;
last_run_at?: string | null;
last_status?: string | null;
source_agent_name?: string | null;
execution_agent_name?: string | null;
scheduler_thread_id: string;
};
export type SchedulerThread = {
user_id: string;
thread_id: string;
};
export type ScheduledTaskDeliveryProfile = {
user_id: string;
element_user_id?: string | null;
};
export type ScheduledTaskResultFile = {
name: string;
path?: string | null;
mime_type?: string | null;
size?: number | null;
kind?: "markdown" | "attachment" | "artifact" | null;
};
export type ScheduledTaskResultFileContent = {
index: number;
name: string;
mime_type?: string | null;
kind?: string | null;
size?: number | null;
previewable: boolean;
content?: string | null;
};
export type ScheduledTaskRunResult = {
title: string;
content: string;
format: "markdown" | "text" | "json" | "mixed";
files: ScheduledTaskResultFile[];
created_at: string;
};
export type ScheduledTaskRun = {
id: string;
task_id: string;
user_id: string;
scheduler_thread_id: string;
agent_run_id?: string | null;
scheduled_for: string;
started_at?: string | null;
finished_at?: string | null;
status: "pending" | "running" | "succeeded" | "failed" | string;
error?: string | null;
result?: ScheduledTaskRunResult | null;
created_at?: string | null;
updated_at?: string | null;
};
+6 -5
View File
@@ -5,11 +5,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
enabled: true,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
};
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.";
-49
View File
@@ -34,18 +34,6 @@ export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface ValidateSkillUploadResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
conflict: boolean;
conflict_kind: "public" | "custom" | null;
warnings: string[];
}
export async function installSkill(
@@ -73,40 +61,3 @@ export async function installSkill(
return response.json();
}
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
const data = await response.json().catch(() => ({}));
const detail = (data as { detail?: unknown }).detail;
const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
export async function validateSkillUpload(file: File): Promise<ValidateSkillUploadResponse> {
const form = new FormData();
form.append("file", file, file.name);
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, {
method: "POST",
body: form,
});
if (!response.ok) {
throw await parseDetailedError(response, "Validation failed");
}
return response.json() as Promise<ValidateSkillUploadResponse>;
}
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const query = options.overwrite ? "?overwrite=true" : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
);
if (!response.ok) {
throw await parseDetailedError(response, "Install failed");
}
return response.json() as Promise<InstallSkillResponse>;
}
+1 -18
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
import { enableSkill } from "./api";
import { loadSkills } from ".";
@@ -29,20 +29,3 @@ export function useEnableSkill() {
},
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: (file: File) => validateSkillUpload(file),
});
}
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
installSkillUpload(file, { overwrite }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
+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;
}
+58 -58
View File
@@ -1,58 +1,58 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | null | undefined,
): string | undefined {
if (context?.agent_id) return context.agent_id;
if (context?.agent_name) return context.agent_name;
const metaId = metadata?.agent_id;
if (typeof metaId === "string" && metaId) return metaId;
const metaName = metadata?.agent_name;
if (typeof metaName === "string" && metaName) return metaName;
return undefined;
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata);
return agentRef
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
: `/page/workspace/chats/${threadId}`;
}
export function textOfMessage(message: Message) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === "text") {
return part.text;
}
}
}
return null;
}
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | null | undefined,
): string | undefined {
if (context?.agent_id) return context.agent_id;
if (context?.agent_name) return context.agent_name;
const metaId = metadata?.agent_id;
if (typeof metaId === "string" && metaId) return metaId;
const metaName = metadata?.agent_name;
if (typeof metaName === "string" && metaName) return metaName;
return undefined;
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata);
return agentRef
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
: `/workspace/chats/${threadId}`;
}
export function textOfMessage(message: Message) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === "text") {
return part.text;
}
}
}
return null;
}
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
+222 -213
View File
@@ -1,224 +1,233 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+216 -190
View File
@@ -1,211 +1,237 @@
"use client";
import { useCallback, useEffect, useState } from "react";
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#/workspace/chats/${createdThreadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
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) => {
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
void sendMessage(
threadId,
message,
settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath }
: undefined,
);
},
[sendMessage, settings.context.writing_mode, threadId, writingArtifactPath],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{mounted ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{mounted ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
) : (
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
)}
/>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+67 -67
View File
@@ -1,67 +1,67 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/page/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
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>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
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>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
+517 -477
View File
@@ -1,477 +1,517 @@
"use client";
import {
ArrowLeftIcon,
CheckCircleIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
MessageList,
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
AgentNameCheckError,
checkAgentName,
createAgent,
getAgent,
updateAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
const DEFAULT_SELECTED_SKILL_COUNT = 3;
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function getAgentWithRetry(agentId: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentId);
} catch {
// Retry until the file/database write settles.
}
}
return null;
}
function getCreateAgentErrorMessage(
error: unknown,
networkErrorMessage: string,
fallbackMessage: string,
) {
if (error instanceof TypeError && error.message === "Failed to fetch") {
return networkErrorMessage;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const threadId = useMemo(() => uuid(), []);
const [nameInput, setNameInput] = useState("");
const [descriptionInput, setDescriptionInput] = useState("");
const [soulInput, setSoulInput] = useState("");
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
}
setSelectedSkills(
enabledSkills
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
.map((skill) => skill.name),
);
}, [enabledSkills, isSkillSelectionTouched]);
const [thread, sendMessage] = useThreadStream({
threadId,
assistantId: agentId || undefined,
context: {
mode: "flash",
is_bootstrap: true,
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentId) {
return;
}
void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) {
setAgent(fetched);
setDescriptionInput(fetched.description ?? "");
setSoulInput(fetched.soul ?? "");
setSelectedSkills(fetched.skills ?? []);
toast.success("专家配置已创建");
}
});
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({ threadId });
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const validateForm = useCallback(() => {
// Name is intentionally NOT required here so the user can start chatting
// immediately. If they leave it blank, ensureAgent() generates a placeholder
// and writes it back into the input so they can rename it later.
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setFormError("请至少选择一个技能");
return null;
}
setFormError("");
return nameInput.trim();
}, [enabledSkills.length, nameInput, selectedSkills.length]);
const ensureAgent = useCallback(async () => {
const provided = validateForm();
if (provided === null) {
return null;
}
let name = provided;
if (!name) {
// Names are display-only and may collide, so a timestamped placeholder is
// safe. Reflect it in the form so the user sees what got created.
name = `未命名专家-${Date.now().toString(36)}`;
setNameInput(name);
}
if (agentId) {
setIsSavingDraft(true);
try {
const updated = await updateAgent(agentId, {
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
});
setAgent(updated);
return updated;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return null;
} finally {
setIsSavingDraft(false);
}
}
setIsCreatingAgent(true);
try {
await checkAgentName(name);
const created = await createAgent({
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
});
setAgentId(created.id);
setAgent(created);
return created;
} catch (error) {
if (
error instanceof AgentNameCheckError &&
error.reason === "backend_unreachable"
) {
setFormError(t.agents.nameStepNetworkError);
} else {
setFormError(
getCreateAgentErrorMessage(
error,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
}
return null;
} finally {
setIsCreatingAgent(false);
}
}, [
agentId,
descriptionInput,
selectedSkills,
soulInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
]);
const handleSaveAgent = useCallback(async () => {
const saved = await ensureAgent();
if (!saved) {
return;
}
toast.success(`专家「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) {
return;
}
// Lazily create the agent on first message so the user can just start
// typing — ensureAgent generates a placeholder name when none is provided.
let activeAgentId = agentId;
if (!activeAgentId) {
const created = await ensureAgent();
if (!created) {
return;
}
activeAgentId = created.id;
}
await ensureThreadExists();
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_id: activeAgentId },
);
},
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
if (!agentId) {
return;
}
// Approval persists directly to the backend — no LLM round-trip. The
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
// config.yaml on disk, which is exactly what setup_agent_tool would have
// done if we'd let the model re-invoke it.
const proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? [];
// Prefer the model's proposed name when present; fall back to whatever
// the user already typed, or the existing agent record.
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
setNameInput(resolvedName);
setDescriptionInput(newDescription);
setSoulInput(newSoul);
setSelectedSkills(newSkills);
setIsSkillSelectionTouched(true);
try {
const updated = await updateAgent(agentId, {
name: resolvedName,
description: newDescription,
soul: newSoul,
skills: newSkills,
});
setAgent(updated);
toast.success("专家配置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[agent, agentId, nameInput],
);
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
},
[handleChatSubmit],
);
const toggleSkill = useCallback((skill: Skill) => {
setIsSkillSelectionTouched(true);
setFormError("");
setSelectedSkills((prev) =>
prev.includes(skill.name)
? prev.filter((name) => name !== skill.name)
: [...prev, skill.name],
);
}, []);
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">创建专家</h1>
</div>
{agent ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
>
<CheckCircleIcon className="h-4 w-4" />
开始对话
</Button>
) : null}
</header>
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
<section className="flex min-h-0 flex-col border-r">
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
</div>
<div className="bg-background shrink-0 border-t px-4 py-4">
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</section>
<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>
<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="例如:合同审查专家"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
setFormError("");
}}
className={cn(formError && "border-destructive")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
}
className="min-h-24 resize-none"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 resize-y font-mono text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">技能</label>
<span className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-full",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
);
})}
</div>
)}
</div>
</div>
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}
<Button
className="w-full"
onClick={() => void handleSaveAgent()}
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
</Button>
</div>
</aside>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
"use client";
import {
ArrowLeftIcon,
BotIcon,
CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
AgentNameCheckError,
checkAgentName,
createAgent,
getAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
type Step = "name" | "chat";
type SetupAgentStatus = "idle" | "requested" | "completed";
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
const DEFAULT_SELECTED_SKILL_COUNT = 3;
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function getAgentWithRetry(agentId: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentId);
} catch {
// Retry until the write settles or the attempts are exhausted.
}
}
return null;
}
function getCreateAgentErrorMessage(
error: unknown,
networkErrorMessage: string,
fallbackMessage: string,
) {
if (error instanceof TypeError && error.message === "Failed to fetch") {
return networkErrorMessage;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
const threadId = useMemo(() => uuid(), []);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
}
const defaults = enabledSkills
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
.map((skill) => skill.name);
setSelectedSkills(defaults);
}, [enabledSkills, isSkillSelectionTouched]);
const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined,
assistantId: agentId || undefined,
context: {
mode: "flash",
is_bootstrap: true,
},
onFinish() {
if (!agent && setupAgentStatus === "requested") {
setSetupAgentStatus("idle");
}
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentId) return;
setSetupAgentStatus("completed");
void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) {
setAgent(fetched);
return;
}
toast.error(t.agents.agentCreatedPendingRefresh);
});
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({
threadId,
});
setThreadPrepared(true);
}, [threadPrepared, threadId]);
useEffect(() => {
if (typeof window === "undefined" || step !== "chat") {
return;
}
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
return;
}
setShowSaveHint(true);
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
}, [step]);
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
if (!trimmed) {
setNameError(t.agents.nameStepInvalidError);
return;
}
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setNameError("请至少选择一个技能");
return;
}
setNameError("");
setIsCheckingName(true);
try {
await checkAgentName(trimmed);
} catch (err) {
if (
err instanceof AgentNameCheckError &&
err.reason === "backend_unreachable"
) {
setNameError(t.agents.nameStepNetworkError);
} else {
setNameError(t.agents.nameStepCheckError);
}
return;
} finally {
setIsCheckingName(false);
}
let createdId: string;
let createdName: string;
setIsCreatingAgent(true);
try {
const created = await createAgent({
name: trimmed,
description: "",
skills: selectedSkills,
soul: "",
});
createdId = created.id;
createdName = created.name;
} catch (err) {
setNameError(
getCreateAgentErrorMessage(
err,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
return;
} finally {
setIsCreatingAgent(false);
}
setAgentId(createdId);
setStep("chat");
await ensureThreadExists();
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
files: [],
});
}, [
nameInput,
sendMessage,
t.agents.nameStepNetworkError,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepCheckError,
t.agents.nameStepInvalidError,
threadId,
enabledSkills.length,
selectedSkills,
ensureThreadExists,
]);
const toggleSkill = useCallback((skill: Skill) => {
setIsSkillSelectionTouched(true);
setNameError("");
setSelectedSkills((prev) => {
if (prev.includes(skill.name)) {
return prev.filter((name) => name !== skill.name);
}
return [...prev, skill.name];
});
}, []);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleConfirmName();
}
};
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await ensureThreadExists();
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_id: agentId },
);
},
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
);
const handleSaveAgent = useCallback(async () => {
if (
!agentId ||
agent ||
thread.isLoading ||
setupAgentStatus !== "idle"
) {
return;
}
setSetupAgentStatus("requested");
setShowSaveHint(false);
try {
await sendMessage(
threadId,
{ text: t.agents.saveCommandMessage, files: [] },
{ agent_id: agentId },
{ additionalKwargs: { hide_from_ui: true } },
);
toast.success(t.agents.saveRequested);
} catch (error) {
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error));
}
}, [
agent,
agentId,
sendMessage,
setupAgentStatus,
t.agents.saveCommandMessage,
t.agents.saveRequested,
thread.isLoading,
threadId,
]);
const header = (
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</div>
{step === "chat" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => void handleSaveAgent()}
disabled={
!!agent || thread.isLoading || setupAgentStatus !== "idle"
}
>
<SaveIcon className="h-4 w-4" />
{setupAgentStatus === "requested"
? t.agents.saving
: t.agents.save}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</header>
);
if (step === "name") {
return (
<div className="flex size-full flex-col">
{header}
<main className="flex flex-1 flex-col items-center justify-center px-4">
<div className="w-full max-w-sm space-y-8">
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
<div className="space-y-3">
<Input
autoFocus
placeholder={t.agents.nameStepPlaceholder}
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value);
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError ? (
<p className="text-destructive text-sm">{nameError}</p>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">默认技能(可自定义)</p>
<p className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</p>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能,将创建为无技能智能体。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
className={cn(
"h-8",
checked ? "" : "text-muted-foreground",
)}
title={skill.description}
>
{skill.name}
</Button>
);
})}
</div>
)}
</div>
</div>
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={
!nameInput.trim() ||
isCheckingName ||
isCreatingAgent ||
isSkillsLoading
}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
);
}
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
{header}
<main className="flex min-h-0 flex-1 flex-col">
{showSaveHint ? (
<div className="px-4 pt-4">
<div className="mx-auto w-full max-w-(--container-width-md)">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>{t.agents.saveHint}</AlertDescription>
</Alert>
</div>
</div>
) : null}
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
/>
</div>
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
<div className="w-full max-w-(--container-width-md)">
{agent ? (
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentId}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
</div>
</div>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
+20 -20
View File
@@ -1,20 +1,20 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/page/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="min-h-screen bg-muted/20">
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
<main
className={`min-h-screen transition-[padding] duration-300 ${
collapsed ? "pl-16" : "pl-16 md:pl-64"
}`}
>
<Routes>
<Route index element={<Navigate to="workspace" replace />} />
<Route
path="workspace/*"
element={<WorkspaceRoutes pageSidebarCollapsed={collapsed} />}
/>
</Routes>
</main>
</div>
);
}
export default function PageRoutes() {
return (
<Routes>
<Route path="*" element={<PageLayout />} />
</Routes>
);
}
+31 -31
View File
@@ -1,31 +1,31 @@
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/page/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -1,318 +1,318 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
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 {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/page/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<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>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</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` : ""}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
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 {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<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>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</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` : ""}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
File diff suppressed because it is too large. Load diff
-136
View File
@@ -1,136 +0,0 @@
import type { CSSProperties, ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
function WorkspaceLayout({
children,
pageSidebarCollapsed,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
}) {
return (
<SidebarProvider
className="h-screen w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]"
style={
{
"--page-sidebar-width": pageSidebarCollapsed ? "4rem" : "16rem",
} as CSSProperties
}
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
// This lets it slide together when the outer sidebar collapses or expands.
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen
>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
}: {
pageSidebarCollapsed?: boolean;
}) {
return (
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="chats"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/new"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled/runs/:run_id"
element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
</Routes>
);
}
File diff suppressed because it is too large. Load diff
+424 -425
View File
@@ -1,425 +1,424 @@
# DeerFlow Backend
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
---
## Architecture
```
┌──────────────────────────────────────┐
│ Nginx (Port 2026) │
│ Unified reverse proxy │
└───────┬──────────────────┬───────────┘
│ │
/api/langgraph/* │ │ /api/* (other)
▼ ▼
┌────────────────────┐ ┌────────────────────────┐
│ LangGraph Server │ │ Gateway API (8001) │
│ (Port 2024) │ │ FastAPI REST │
│ │ │ │
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
│ │ Lead Agent │ │ │ Memory, Uploads, │
│ │ ┌──────────┐ │ │ │ Artifacts │
│ │ │Middleware│ │ │ └────────────────────────┘
│ │ │ Chain │ │ │
│ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │ Tools │ │ │
│ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │Subagents │ │ │
│ │ └──────────┘ │ │
│ └────────────────┘ │
└────────────────────┘
```
**Request Routing** (via Nginx):
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
- `/` (non-API) → Frontend - Next.js web interface
---
## Core Components
### Lead Agent
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
- **Dynamic model selection** with thinking and vision support
- **Middleware chain** for cross-cutting concerns (9 middlewares)
- **Tool system** with sandbox, MCP, community, and built-in tools
- **Subagent delegation** for parallel task execution
- **System prompt** with skills injection, memory context, and working directory guidance
### Middleware Chain
Middlewares execute in strict order, each handling a specific concern:
| # | Middleware | Purpose |
|---|-----------|---------|
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
### Sandbox System
Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
### Subagent System
Async task delegation with concurrent execution:
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist, exposed only when shell access is available)
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
- **Execution**: Background thread pools with status tracking and SSE events
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result
### Memory System
LLM-powered persistent context retention across conversations:
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
- **System prompt injection**: Top facts + context injected into agent prompts
- **Storage**: JSON file with mtime-based cache invalidation
### Tool Ecosystem
| Category | Tools |
|----------|-------|
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
| **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
| **Skills** | Domain-specific workflows injected via system prompt |
### Gateway API
FastAPI application providing REST endpoints for frontend integration:
| Route | Purpose |
|-------|---------|
| `GET /api/models` | List available LLM models |
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
| `GET/PUT /api/skills` | List and manage skills |
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration |
| `GET /api/memory/status` | Combined config + data |
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
### Custom Agents
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
### IM Channels
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow.
---
## Quick Start
### Prerequisites
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) package manager
- API keys for your chosen LLM provider
### Installation
```bash
cd deer-flow
# Copy configuration files
cp config.example.yaml config.yaml
# Install backend dependencies
cd backend
make install
```
### Configuration
Edit `config.yaml` in the project root:
```yaml
models:
- name: gpt-4o
display_name: GPT-4o
use: langchain_openai:ChatOpenAI
model: gpt-4o
api_key: $OPENAI_API_KEY
supports_thinking: false
supports_vision: true
- name: gpt-5-responses
display_name: GPT-5 (Responses API)
use: langchain_openai:ChatOpenAI
model: gpt-5
api_key: $OPENAI_API_KEY
use_responses_api: true
output_version: responses/v1
supports_vision: true
```
Set your API keys:
```bash
export OPENAI_API_KEY="your-api-key-here"
```
### Running
**Full Application** (from project root):
```bash
make dev # Starts LangGraph + Gateway + Frontend + Nginx
```
Access at: http://localhost:2026
**Backend Only** (from backend directory):
```bash
# Terminal 1: LangGraph server
make dev
# Terminal 2: Gateway API
make gateway
```
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
---
## Project Structure
```
backend/
├── src/
│ ├── agents/ # Agent system
│ │ ├── lead_agent/ # Main agent (factory, prompts)
│ │ ├── middlewares/ # 9 middleware components
│ │ ├── memory/ # Memory extraction & storage
│ │ └── thread_state.py # ThreadState schema
│ ├── gateway/ # FastAPI Gateway API
│ │ ├── app.py # Application setup
│ │ └── routers/ # 6 route modules
│ ├── sandbox/ # Sandbox execution
│ │ ├── local/ # Local filesystem provider
│ │ ├── sandbox.py # Abstract interface
│ │ ├── tools.py # bash, ls, read/write/str_replace
│ │ └── middleware.py # Sandbox lifecycle
│ ├── subagents/ # Subagent delegation
│ │ ├── builtins/ # general-purpose, bash agents
│ │ ├── executor.py # Background execution engine
│ │ └── registry.py # Agent registry
│ ├── tools/builtins/ # Built-in tools
│ ├── mcp/ # MCP protocol integration
│ ├── models/ # Model factory
│ ├── skills/ # Skill discovery & loading
│ ├── config/ # Configuration system
│ ├── community/ # Community tools & providers
│ ├── reflection/ # Dynamic module loading
│ └── utils/ # Utilities
├── docs/ # Documentation
├── tests/ # Test suite
├── langgraph.json # LangGraph server configuration
├── pyproject.toml # Python dependencies
├── Makefile # Development commands
└── Dockerfile # Container build
```
---
## Configuration
### Main Configuration (`config.yaml`)
Place in project root. Config values starting with `$` resolve as environment variables.
Key sections:
- `models` - LLM configurations with class paths, API keys, thinking/vision flags
- `tools` - Tool definitions with module paths and groups
- `tool_groups` - Logical tool groupings
- `sandbox` - Execution environment provider
- `skills` - Skills directory paths
- `title` - Auto-title generation settings
- `summarization` - Context summarization settings
- `subagents` - Subagent system (enabled/disabled)
- `memory` - Memory system settings (enabled, storage, debounce, facts limits)
Provider note:
- `models[*].use` references provider classes by module path (for example `langchain_openai:ChatOpenAI`).
- If a provider module is missing, DeerFlow now returns an actionable error with install guidance (for example `uv add langchain-google-genai`).
### Extensions Configuration (`extensions_config.json`)
MCP servers and skill states in a single file:
```json
{
"mcpServers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
},
"secure-http": {
"enabled": true,
"type": "http",
"url": "https://api.example.com/mcp",
"oauth": {
"enabled": true,
"token_url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "$MCP_OAUTH_CLIENT_ID",
"client_secret": "$MCP_OAUTH_CLIENT_SECRET"
}
}
},
"skills": {
"pdf-processing": {"enabled": true}
}
}
```
### Environment Variables
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Override extensions_config.json location
- Model API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.
- Tool API keys: `TAVILY_API_KEY`, `GITHUB_TOKEN`, etc.
### LangSmith Tracing
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, tool executions, and middleware processing are traced and visible in the LangSmith dashboard.
**Setup:**
1. Sign up at [smith.langchain.com](https://smith.langchain.com) and create a project.
2. Add the following to your `.env` file in the project root:
```bash
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
LANGSMITH_PROJECT=xxx
```
**Legacy variables:** The `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGCHAIN_PROJECT`, and `LANGCHAIN_ENDPOINT` variables are also supported for backward compatibility. `LANGSMITH_*` variables take precedence when both are set.
### Langfuse Tracing
DeerFlow also supports [Langfuse](https://langfuse.com) observability for LangChain-compatible runs.
Add the following to your `.env` file:
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
If you are using a self-hosted Langfuse deployment, set `LANGFUSE_BASE_URL` to your Langfuse host.
### Dual Provider Behavior
If both LangSmith and Langfuse are enabled, DeerFlow initializes and attaches both callbacks so the same run data is reported to both systems.
If a provider is explicitly enabled but required credentials are missing, or the provider callback cannot be initialized, DeerFlow raises an error when tracing is initialized during model creation instead of silently disabling tracing.
**Docker:** In `docker-compose.yaml`, tracing is disabled by default (`LANGSMITH_TRACING=false`). Set `LANGSMITH_TRACING=true` and/or `LANGFUSE_TRACING=true` in your `.env`, together with the required credentials, to enable tracing in containerized deployments.
---
## Development
### Commands
```bash
make install # Install dependencies
make dev # Run LangGraph server (port 2024)
make gateway # Run Gateway API (port 8001)
make lint # Run linter (ruff)
make format # Format code (ruff)
```
### Code Style
- **Linter/Formatter**: `ruff`
- **Line length**: 240 characters
- **Python**: 3.12+ with type hints
- **Quotes**: Double quotes
- **Indentation**: 4 spaces
### Testing
```bash
uv run pytest
```
---
## Technology Stack
- **LangGraph** (1.0.6+) - Agent framework and multi-agent orchestration
- **LangChain** (1.2.3+) - LLM abstractions and tool system
- **FastAPI** (0.115.0+) - Gateway REST API
- **langchain-mcp-adapters** - Model Context Protocol support
- **agent-sandbox** - Sandboxed code execution
- **markitdown** - Multi-format document conversion
- **tavily-python** / **firecrawl-py** - Web search and scraping
---
## Documentation
- [Configuration Guide](docs/CONFIGURATION.md)
- [Architecture Details](docs/ARCHITECTURE.md)
- [API Reference](docs/API.md)
- [File Upload](docs/FILE_UPLOAD.md)
- [Path Examples](docs/PATH_EXAMPLES.md)
- [Context Summarization](docs/summarization.md)
- [Plan Mode](docs/plan_mode_usage.md)
- [Backend Run/Stop Guide (ZH)](docs/BACKEND_RUN_STOP_ZH.md)
- [Setup Guide](docs/SETUP.md)
---
## License
See the [LICENSE](../LICENSE) file in the project root.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
# DeerFlow Backend
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
---
## Architecture
```
┌──────────────────────────────────────┐
│ Nginx (Port 2026) │
│ Unified reverse proxy │
└───────┬──────────────────┬───────────┘
│ │
/api/langgraph/* │ │ /api/* (other)
▼ ▼
┌────────────────────┐ ┌────────────────────────┐
│ LangGraph Server │ │ Gateway API (8001) │
│ (Port 2024) │ │ FastAPI REST │
│ │ │ │
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
│ │ Lead Agent │ │ │ Memory, Uploads, │
│ │ ┌──────────┐ │ │ │ Artifacts │
│ │ │Middleware│ │ │ └────────────────────────┘
│ │ │ Chain │ │ │
│ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │ Tools │ │ │
│ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │Subagents │ │ │
│ │ └──────────┘ │ │
│ └────────────────┘ │
└────────────────────┘
```
**Request Routing** (via Nginx):
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
- `/` (non-API) → Frontend - Next.js web interface
---
## Core Components
### Lead Agent
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
- **Dynamic model selection** with thinking and vision support
- **Middleware chain** for cross-cutting concerns (9 middlewares)
- **Tool system** with sandbox, MCP, community, and built-in tools
- **Subagent delegation** for parallel task execution
- **System prompt** with skills injection, memory context, and working directory guidance
### Middleware Chain
Middlewares execute in strict order, each handling a specific concern:
| # | Middleware | Purpose |
|---|-----------|---------|
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
### Sandbox System
Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
### Subagent System
Async task delegation with concurrent execution:
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist, exposed only when shell access is available)
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
- **Execution**: Background thread pools with status tracking and SSE events
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result
### Memory System
LLM-powered persistent context retention across conversations:
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
- **System prompt injection**: Top facts + context injected into agent prompts
- **Storage**: JSON file with mtime-based cache invalidation
### Tool Ecosystem
| Category | Tools |
|----------|-------|
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
| **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
| **Skills** | Domain-specific workflows injected via system prompt |
### Gateway API
FastAPI application providing REST endpoints for frontend integration:
| Route | Purpose |
|-------|---------|
| `GET /api/models` | List available LLM models |
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
| `GET/PUT /api/skills` | List and manage skills |
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration |
| `GET /api/memory/status` | Combined config + data |
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
### Custom Agents
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
### IM Channels
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow.
---
## Quick Start
### Prerequisites
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) package manager
- API keys for your chosen LLM provider
### Installation
```bash
cd deer-flow
# Copy configuration files
cp config.example.yaml config.yaml
# Install backend dependencies
cd backend
make install
```
### Configuration
Edit `config.yaml` in the project root:
```yaml
models:
- name: gpt-4o
display_name: GPT-4o
use: langchain_openai:ChatOpenAI
model: gpt-4o
api_key: $OPENAI_API_KEY
supports_thinking: false
supports_vision: true
- name: gpt-5-responses
display_name: GPT-5 (Responses API)
use: langchain_openai:ChatOpenAI
model: gpt-5
api_key: $OPENAI_API_KEY
use_responses_api: true
output_version: responses/v1
supports_vision: true
```
Set your API keys:
```bash
export OPENAI_API_KEY="your-api-key-here"
```
### Running
**Full Application** (from project root):
```bash
make dev # Starts LangGraph + Gateway + Frontend + Nginx
```
Access at: http://localhost:2026
**Backend Only** (from backend directory):
```bash
# Terminal 1: LangGraph server
make dev
# Terminal 2: Gateway API
make gateway
```
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
---
## Project Structure
```
backend/
├── src/
│ ├── agents/ # Agent system
│ │ ├── lead_agent/ # Main agent (factory, prompts)
│ │ ├── middlewares/ # 9 middleware components
│ │ ├── memory/ # Memory extraction & storage
│ │ └── thread_state.py # ThreadState schema
│ ├── gateway/ # FastAPI Gateway API
│ │ ├── app.py # Application setup
│ │ └── routers/ # 6 route modules
│ ├── sandbox/ # Sandbox execution
│ │ ├── local/ # Local filesystem provider
│ │ ├── sandbox.py # Abstract interface
│ │ ├── tools.py # bash, ls, read/write/str_replace
│ │ └── middleware.py # Sandbox lifecycle
│ ├── subagents/ # Subagent delegation
│ │ ├── builtins/ # general-purpose, bash agents
│ │ ├── executor.py # Background execution engine
│ │ └── registry.py # Agent registry
│ ├── tools/builtins/ # Built-in tools
│ ├── mcp/ # MCP protocol integration
│ ├── models/ # Model factory
│ ├── skills/ # Skill discovery & loading
│ ├── config/ # Configuration system
│ ├── community/ # Community tools & providers
│ ├── reflection/ # Dynamic module loading
│ └── utils/ # Utilities
├── docs/ # Documentation
├── tests/ # Test suite
├── langgraph.json # LangGraph server configuration
├── pyproject.toml # Python dependencies
├── Makefile # Development commands
└── Dockerfile # Container build
```
---
## Configuration
### Main Configuration (`config.yaml`)
Place in project root. Config values starting with `$` resolve as environment variables.
Key sections:
- `models` - LLM configurations with class paths, API keys, thinking/vision flags
- `tools` - Tool definitions with module paths and groups
- `tool_groups` - Logical tool groupings
- `sandbox` - Execution environment provider
- `skills` - Skills directory paths
- `title` - Auto-title generation settings
- `summarization` - Context summarization settings
- `subagents` - Subagent system (enabled/disabled)
- `memory` - Memory system settings (enabled, storage, debounce, facts limits)
Provider note:
- `models[*].use` references provider classes by module path (for example `langchain_openai:ChatOpenAI`).
- If a provider module is missing, DeerFlow now returns an actionable error with install guidance (for example `uv add langchain-google-genai`).
### Extensions Configuration (`extensions_config.json`)
MCP servers and skill states in a single file:
```json
{
"mcpServers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
},
"secure-http": {
"enabled": true,
"type": "http",
"url": "https://api.example.com/mcp",
"oauth": {
"enabled": true,
"token_url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "$MCP_OAUTH_CLIENT_ID",
"client_secret": "$MCP_OAUTH_CLIENT_SECRET"
}
}
},
"skills": {
"pdf-processing": {"enabled": true}
}
}
```
### Environment Variables
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Override extensions_config.json location
- Model API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.
- Tool API keys: `TAVILY_API_KEY`, `GITHUB_TOKEN`, etc.
### LangSmith Tracing
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, tool executions, and middleware processing are traced and visible in the LangSmith dashboard.
**Setup:**
1. Sign up at [smith.langchain.com](https://smith.langchain.com) and create a project.
2. Add the following to your `.env` file in the project root:
```bash
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
LANGSMITH_PROJECT=xxx
```
**Legacy variables:** The `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGCHAIN_PROJECT`, and `LANGCHAIN_ENDPOINT` variables are also supported for backward compatibility. `LANGSMITH_*` variables take precedence when both are set.
### Langfuse Tracing
DeerFlow also supports [Langfuse](https://langfuse.com) observability for LangChain-compatible runs.
Add the following to your `.env` file:
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
If you are using a self-hosted Langfuse deployment, set `LANGFUSE_BASE_URL` to your Langfuse host.
### Dual Provider Behavior
If both LangSmith and Langfuse are enabled, DeerFlow initializes and attaches both callbacks so the same run data is reported to both systems.
If a provider is explicitly enabled but required credentials are missing, or the provider callback cannot be initialized, DeerFlow raises an error when tracing is initialized during model creation instead of silently disabling tracing.
**Docker:** In `docker-compose.yaml`, tracing is disabled by default (`LANGSMITH_TRACING=false`). Set `LANGSMITH_TRACING=true` and/or `LANGFUSE_TRACING=true` in your `.env`, together with the required credentials, to enable tracing in containerized deployments.
---
## Development
### Commands
```bash
make install # Install dependencies
make dev # Run LangGraph server (port 2024)
make gateway # Run Gateway API (port 8001)
make lint # Run linter (ruff)
make format # Format code (ruff)
```
### Code Style
- **Linter/Formatter**: `ruff`
- **Line length**: 240 characters
- **Python**: 3.12+ with type hints
- **Quotes**: Double quotes
- **Indentation**: 4 spaces
### Testing
```bash
uv run pytest
```
---
## Technology Stack
- **LangGraph** (1.0.6+) - Agent framework and multi-agent orchestration
- **LangChain** (1.2.3+) - LLM abstractions and tool system
- **FastAPI** (0.115.0+) - Gateway REST API
- **langchain-mcp-adapters** - Model Context Protocol support
- **agent-sandbox** - Sandboxed code execution
- **markitdown** - Multi-format document conversion
- **tavily-python** / **firecrawl-py** - Web search and scraping
---
## Documentation
- [Configuration Guide](docs/CONFIGURATION.md)
- [Architecture Details](docs/ARCHITECTURE.md)
- [API Reference](docs/API.md)
- [File Upload](docs/FILE_UPLOAD.md)
- [Path Examples](docs/PATH_EXAMPLES.md)
- [Context Summarization](docs/summarization.md)
- [Plan Mode](docs/plan_mode_usage.md)
- [Setup Guide](docs/SETUP.md)
---
## License
See the [LICENSE](../LICENSE) file in the project root.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
@@ -89,12 +89,6 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
def _validate_display_name(name: str) -> str:
normalized = name.strip()
if not normalized:
@@ -209,29 +203,6 @@ async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) ->
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
async def _get_editable_or_raise(
store: AgentStore,
agent_id: str,
user_id: str,
*,
is_admin: bool,
) -> tuple[dict[str, Any], str]:
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
caller is a system admin. Custom agents owned by other users still 403.
"""
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
return owned, "owner"
visible = await store.get_visible(agent_id, user_id)
if visible is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
if is_admin and visible.get("user_id") is None:
return visible, "admin"
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
@router.get(
"/agents",
response_model=AgentsListResponse,
@@ -351,10 +322,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
try:
store = get_agent_store(http_request)
user_id = _current_user_id(http_request)
is_admin = _is_admin_user(http_request)
current, edit_mode = await _get_editable_or_raise(
store, agent_id, user_id, is_admin=is_admin
)
current = await _get_owned_or_raise(store, agent_id, user_id)
fields_set = request.model_fields_set
update_data: dict[str, Any] = {}
@@ -386,12 +354,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
if request.soul is not None:
_write_agent_soul(agent_id, request.soul)
if not update_data:
record = current
elif edit_mode == "admin":
record = await store.update_builtin(agent_id, update_data)
else:
record = await store.update(agent_id, user_id, update_data)
record = await store.update(agent_id, user_id, update_data) if update_data else current
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -4,21 +4,38 @@ import zipfile
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from pydantic import BaseModel
from app.gateway.authz import require_permission
from app.gateway.path_utils import resolve_thread_virtual_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["artifacts"])
router = APIRouter(prefix="/api", tags=["artifacts"])
ACTIVE_CONTENT_MIME_TYPES = {
ACTIVE_CONTENT_MIME_TYPES = {
"text/html",
"application/xhtml+xml",
"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:
@@ -180,4 +197,45 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
if is_text_file_by_content(actual_path):
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
@router.patch(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Update 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,
) -> Response:
"""Update a text artifact.
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:
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}")
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")
return PlainTextResponse(content=body.content, media_type=mime_type or "text/plain")
@@ -1,419 +1,327 @@
"""Scheduled task management API."""
from __future__ import annotations
import re
import zipfile
from io import BytesIO
import mimetypes
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel):
name: str = Field(default="Scheduled task")
prompt: str
schedule: str
timezone: str | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskUpdateRequest(BaseModel):
name: str | None = None
prompt: str | None = None
schedule: str | None = None
timezone: str | None = None
enabled: bool | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
element_user_id: str | None = None
class ScheduledTaskSubscribeRequest(BaseModel):
notify_element: bool = False
class ScheduledTaskRunFileUpdateRequest(BaseModel):
content: str = Field(..., description="New text content to write into the run file")
_MAX_RUN_FILE_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB
async def _user_id(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return user_id
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
def _result_download_name(result: dict[str, Any], *, extension: str = "md") -> str:
title = result.get("title") if isinstance(result, dict) else None
return f"{_safe_filename(str(title or 'scheduled-task-result'))}.{extension}"
def _result_markdown(result: dict[str, Any]) -> str:
title = str(result.get("title") or "Scheduled Task Result")
content = str(result.get("content") or "")
return f"# {title}\n\n{content}".strip() + "\n"
def _utf8_sig_bytes(content: str) -> bytes:
return content.encode("utf-8-sig")
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
return f"attachment; filename*=UTF-8''{quoted}"
def _run_files(run: dict[str, Any]) -> list:
result = run.get("result") or {}
return result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
files = _run_files(run)
if file_index < 0 or file_index >= len(files):
raise HTTPException(status_code=404, detail="Result file not found")
item = files[file_index]
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise HTTPException(status_code=404, detail="Result file not found")
path = Path(item["path"])
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Result file is no longer available")
return item, path
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
return True
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
def _is_text_download(path: Path, mime_type: str | None) -> bool:
return _is_text_previewable(path, mime_type)
def _read_text_download_bytes(path: Path) -> bytes:
try:
content = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return _utf8_sig_bytes(content)
@router.get("/thread")
async def get_scheduler_thread(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.ensure_scheduler_thread(user_id)
@router.get("")
async def list_scheduled_tasks(request: Request) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_tasks(user_id)
@router.get("/{task_id}/runs")
async def list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
runs = await service.list_runs(user_id, task_id, limit=max(1, min(limit, 200)))
return runs
@router.get("/runs/{run_id}")
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return run
@router.delete("/runs/{run_id}")
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)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@router.get("/runs/{run_id}/files/{file_index}")
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
previewable = _is_text_previewable(path, mime_type)
content = None
if previewable:
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return {
"index": file_index,
"name": item.get("name") or path.name,
"mime_type": mime_type,
"kind": item.get("kind"),
"size": path.stat().st_size,
"previewable": previewable,
"content": content,
}
@router.put("/runs/{run_id}/files/{file_index}")
async def update_scheduled_task_run_file(
run_id: str,
file_index: int,
body: ScheduledTaskRunFileUpdateRequest,
request: Request,
) -> dict:
if len(body.content.encode("utf-8")) > _MAX_RUN_FILE_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
user_id = await _user_id(request)
try:
return await service.update_run_file(user_id, run_id, file_index, body.content)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/runs/{run_id}/download")
async def download_scheduled_task_run(run_id: str, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
result = run.get("result") or {}
if not isinstance(result, dict):
result = {}
files = result.get("files") if isinstance(result.get("files"), list) else []
accessible_files: list[tuple[str, Path]] = []
for item in files:
if not isinstance(item, dict):
continue
path_value = item.get("path")
if not isinstance(path_value, str) or not path_value:
continue
path = Path(path_value)
if path.exists() and path.is_file():
accessible_files.append((str(item.get("name") or path.name), path))
if not accessible_files:
body = _utf8_sig_bytes(_result_markdown(result))
return Response(
content=body,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": _content_disposition(_result_download_name(result))},
)
archive = BytesIO()
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
for name, path in accessible_files:
arcname = _safe_filename(name, path.name)
mime_type = mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
zf.writestr(arcname, _read_text_download_bytes(path))
else:
zf.write(path, arcname=arcname)
archive.seek(0)
return StreamingResponse(
archive,
media_type="application/zip",
headers={"Content-Disposition": _content_disposition(_result_download_name(result, extension="zip"))},
)
@router.get("/runs/{run_id}/files/{file_index}/download")
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
filename = _safe_filename(str(item.get("name") or path.name), path.name)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
return Response(
content=_read_text_download_bytes(path),
media_type=f"{mime_type or 'text/plain'}; charset=utf-8",
headers={"Content-Disposition": _content_disposition(filename)},
)
return FileResponse(path, filename=filename, media_type=item.get("mime_type") or "application/octet-stream")
@router.get("/delivery-profile")
async def get_delivery_profile(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.get_delivery_profile(user_id)
@router.put("/delivery-profile")
async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.update_delivery_profile(user_id, body.model_dump(exclude_unset=True))
@router.post("")
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(
user_id=user_id,
name=body.name,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
@router.patch("/{task_id}")
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
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")
return task
@router.delete("/{task_id}")
async def delete_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True}
@router.post("/{task_id}/pause")
async def pause_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.pause_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/resume")
async def resume_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.resume_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/trigger")
async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.trigger_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True, "task": task}
# --- Publish / subscribe ---
@router.get("/published")
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))
@router.post("/{task_id}/publish")
async def publish_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.publish_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/unpublish")
async def unpublish_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.unpublish_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/subscribe")
async def subscribe_scheduled_task(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
if sub is None:
raise HTTPException(status_code=404, detail="Task is not available for subscription")
return sub
@router.patch("/{task_id}/subscribe")
async def update_scheduled_task_subscription(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found")
return sub
@router.delete("/{task_id}/subscribe")
async def delete_scheduled_task_subscription(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.unsubscribe_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"success": True}
"""Scheduled task management API."""
from __future__ import annotations
import re
import zipfile
from io import BytesIO
import mimetypes
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel):
name: str = Field(default="Scheduled task")
prompt: str
schedule: str
timezone: str | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskUpdateRequest(BaseModel):
name: str | None = None
prompt: str | None = None
schedule: str | None = None
timezone: str | None = None
enabled: bool | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
element_user_id: str | None = None
async def _user_id(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return user_id
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
def _result_download_name(result: dict[str, Any], *, extension: str = "md") -> str:
title = result.get("title") if isinstance(result, dict) else None
return f"{_safe_filename(str(title or 'scheduled-task-result'))}.{extension}"
def _result_markdown(result: dict[str, Any]) -> str:
title = str(result.get("title") or "Scheduled Task Result")
content = str(result.get("content") or "")
return f"# {title}\n\n{content}".strip() + "\n"
def _utf8_sig_bytes(content: str) -> bytes:
return content.encode("utf-8-sig")
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
return f"attachment; filename*=UTF-8''{quoted}"
def _run_files(run: dict[str, Any]) -> list:
result = run.get("result") or {}
return result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
files = _run_files(run)
if file_index < 0 or file_index >= len(files):
raise HTTPException(status_code=404, detail="Result file not found")
item = files[file_index]
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise HTTPException(status_code=404, detail="Result file not found")
path = Path(item["path"])
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Result file is no longer available")
return item, path
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
return True
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
def _is_text_download(path: Path, mime_type: str | None) -> bool:
return _is_text_previewable(path, mime_type)
def _read_text_download_bytes(path: Path) -> bytes:
try:
content = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return _utf8_sig_bytes(content)
@router.get("/thread")
async def get_scheduler_thread(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.ensure_scheduler_thread(user_id)
@router.get("")
async def list_scheduled_tasks(request: Request) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_tasks(user_id)
@router.get("/{task_id}/runs")
async def list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
runs = await service.list_runs(user_id, task_id, limit=max(1, min(limit, 200)))
return runs
@router.get("/runs/{run_id}")
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return run
@router.delete("/runs/{run_id}")
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)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@router.get("/runs/{run_id}/files/{file_index}")
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
previewable = _is_text_previewable(path, mime_type)
content = None
if previewable:
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return {
"index": file_index,
"name": item.get("name") or path.name,
"mime_type": mime_type,
"kind": item.get("kind"),
"size": path.stat().st_size,
"previewable": previewable,
"content": content,
}
@router.get("/runs/{run_id}/download")
async def download_scheduled_task_run(run_id: str, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
result = run.get("result") or {}
if not isinstance(result, dict):
result = {}
files = result.get("files") if isinstance(result.get("files"), list) else []
accessible_files: list[tuple[str, Path]] = []
for item in files:
if not isinstance(item, dict):
continue
path_value = item.get("path")
if not isinstance(path_value, str) or not path_value:
continue
path = Path(path_value)
if path.exists() and path.is_file():
accessible_files.append((str(item.get("name") or path.name), path))
if not accessible_files:
body = _utf8_sig_bytes(_result_markdown(result))
return Response(
content=body,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": _content_disposition(_result_download_name(result))},
)
archive = BytesIO()
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
for name, path in accessible_files:
arcname = _safe_filename(name, path.name)
mime_type = mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
zf.writestr(arcname, _read_text_download_bytes(path))
else:
zf.write(path, arcname=arcname)
archive.seek(0)
return StreamingResponse(
archive,
media_type="application/zip",
headers={"Content-Disposition": _content_disposition(_result_download_name(result, extension="zip"))},
)
@router.get("/runs/{run_id}/files/{file_index}/download")
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
filename = _safe_filename(str(item.get("name") or path.name), path.name)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
return Response(
content=_read_text_download_bytes(path),
media_type=f"{mime_type or 'text/plain'}; charset=utf-8",
headers={"Content-Disposition": _content_disposition(filename)},
)
return FileResponse(path, filename=filename, media_type=item.get("mime_type") or "application/octet-stream")
@router.get("/delivery-profile")
async def get_delivery_profile(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.get_delivery_profile(user_id)
@router.put("/delivery-profile")
async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.update_delivery_profile(user_id, body.model_dump(exclude_unset=True))
@router.post("")
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(
user_id=user_id,
name=body.name,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
@router.patch("/{task_id}")
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
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")
return task
@router.delete("/{task_id}")
async def delete_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True}
@router.post("/{task_id}/pause")
async def pause_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.pause_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/resume")
async def resume_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.resume_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/trigger")
async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.trigger_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True, "task": task}
@@ -1,9 +1,8 @@
import json
import logging
import tempfile
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
@@ -57,20 +56,6 @@ class SkillInstallResponse(BaseModel):
success: bool = Field(..., description="Whether the installation was successful")
skill_name: str = Field(..., description="Name of the installed skill")
message: str = Field(..., description="Installation result message")
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
class SkillUploadValidateResponse(BaseModel):
"""Response model for the pre-install validation endpoint."""
ok: bool = Field(..., description="Whether the archive passed cheap validation")
skill_name: str = Field(..., description="Skill name parsed from frontmatter")
description: str = Field(default="", description="Skill description parsed from frontmatter")
license: str | None = Field(default=None, description="Skill license, if declared")
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
class CustomSkillContentResponse(SkillResponse):
@@ -115,110 +100,6 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
_ZIP_MAGIC = b"PK\x03\x04"
async def _persist_uploaded_skill(file: UploadFile) -> Path:
"""Stream an uploaded skill archive to a temp file. Caller must unlink it.
Validates the leading bytes look like a ZIP archive and enforces the same
512 MB cap that the extractor uses, so we reject obvious garbage early.
"""
if file.filename is None:
raise HTTPException(status_code=400, detail="No file provided")
suffix = Path(file.filename).suffix.lower()
if suffix not in {".zip", ".skill"}:
raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
tmp_path = Path(tmp_name)
total = 0
head: bytes = b""
try:
with open(fd, "wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
if not head:
head = chunk[: len(_ZIP_MAGIC)]
total += len(chunk)
if total > _MAX_SKILL_UPLOAD_BYTES:
raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
out.write(chunk)
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
except Exception:
tmp_path.unlink(missing_ok=True)
raise
if not head.startswith(_ZIP_MAGIC):
tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
return tmp_path
@router.post(
"/skills/validate",
response_model=SkillUploadValidateResponse,
summary="Validate Skill Archive (No Install)",
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
)
async def validate_skill_upload(
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
config: AppConfig = Depends(get_config),
) -> SkillUploadValidateResponse:
tmp_path = await _persist_uploaded_skill(file)
try:
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
return SkillUploadValidateResponse(**result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
finally:
tmp_path.unlink(missing_ok=True)
@router.post(
"/skills/install-upload",
response_model=SkillInstallResponse,
summary="Install Skill from Upload",
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
)
async def install_skill_upload(
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
overwrite: bool = False,
config: AppConfig = Depends(get_config),
) -> SkillInstallResponse:
tmp_path = await _persist_uploaded_skill(file)
try:
storage = get_or_new_skill_storage(app_config=config)
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except SkillAlreadyExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
finally:
tmp_path.unlink(missing_ok=True)
@router.post(
"/skills/install",
response_model=SkillInstallResponse,
@@ -110,8 +110,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",
@@ -1,126 +0,0 @@
# 后端启动与停止说明(Windows 优先)
本文档用于 `offline-backend-20260512/backend` 目录下 DeerFlow 后端的日常启动、停止与排障。
## 1. 前置条件
- 已安装 Python 3.12+
- 已安装 `uv`
- 已在项目中准备好配置文件(通常是项目根目录 `config.yaml`)
## 2. 进入后端目录
```powershell
cd F:\react\deeflow-code\deerflow-server-master\offline-backend-20260512\backend
```
## 3. 首次安装依赖
### 方案 A(优先,跨平台)
```powershell
uv sync
```
### 方案 B(如果你的环境里 `make` 可用)
```powershell
make install
```
> Windows 上部分 `make.exe` 与 GNU Make 不兼容,若报错请直接使用 `uv sync`。
## 4. 启动后端
### 4.1 生产样式(无热重载)
```powershell
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
### 4.2 开发样式(热重载)
```powershell
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
```
如果你的 `make` 可用,也可以:
```powershell
make gateway # 无热重载
make dev # 热重载
```
## 5. 验证是否启动成功
启动后可访问:
- 文档页:`http://127.0.0.1:8001/docs`
- OpenAPI:`http://127.0.0.1:8001/openapi.json`
- 健康检查:`http://127.0.0.1:8001/health`
PowerShell 快速检查:
```powershell
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8001/health).StatusCode
```
返回 `200` 即正常。
## 6. 停止后端
### 6.1 前台启动时
在运行窗口按 `Ctrl + C`。
### 6.2 后台或端口占用时(Windows)
按 8001 端口查进程并强制结束:
```powershell
Get-NetTCPConnection -LocalPort 8001 -State Listen | Select-Object OwningProcess
Stop-Process -Id <PID> -Force
```
一键方式(自动停止 8001 的监听进程):
```powershell
$procIds = @(Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
foreach ($oneId in $procIds) { Stop-Process -Id $oneId -Force -ErrorAction SilentlyContinue }
```
## 7. 常见问题
### 7.1 `make gateway` 报 unknown action keyword
说明当前 `make` 不是预期版本(Windows 常见)。直接改用:
```powershell
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
### 7.2 PowerShell 报 `&&` 语法错误
PowerShell 5 不支持 Bash 风格 `&&`。请改成分号:
```powershell
uv sync; $env:PYTHONPATH='.'; uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
### 7.3 Git 切分支时 `.deer-flow/data/*.db-wal|*.db-shm` 无法删除
通常是后端进程仍占用 SQLite 文件。先停掉 8001 端口进程,再执行 Git 操作。
## 8. 推荐日常流程(Windows)
```powershell
cd F:\react\deeflow-code\deerflow-server-master\offline-backend-20260512\backend
uv sync
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
```
停止时按 `Ctrl + C`。
@@ -1,825 +0,0 @@
# DeerFlow 后端开发文档
> 本文档面向开发人员,梳理后端项目结构、核心模块、API 接口、配置系统和常用开发命令,方便快速上手和维护。
---
## 目录
1. [项目概述](#1-项目概述)
2. [技术栈](#2-技术栈)
3. [目录结构](#3-目录结构)
4. [架构分层](#4-架构分层)
5. [快速启动](#5-快速启动)
6. [配置系统](#6-配置系统)
7. [核心模块详解](#7-核心模块详解)
8. [API 接口汇总](#8-api-接口汇总)
9. [Agent 系统](#9-agent-系统)
10. [中间件链](#10-中间件链)
11. [沙箱执行系统](#11-沙箱执行系统)
12. [内存系统](#12-内存系统)
13. [工具系统](#13-工具系统)
14. [IM 频道集成](#14-im-频道集成)
15. [数据持久化](#15-数据持久化)
16. [测试](#16-测试)
17. [常见问题与排错](#17-常见问题与排错)
---
## 1. 项目概述
DeerFlow 是一个基于 **LangGraph** 的 AI 超级 Agent 系统。后端提供:
- **Lead Agent**:具备沙箱执行、持久内存、子 Agent 委托和可扩展工具集成的主 Agent
- **Gateway API**:FastAPI REST API(端口 8001),同时内嵌 LangGraph 兼容的 Agent 运行时
- **IM 频道集成**:飞书、Slack、Telegram、钉钉、Discord、微信、企业微信
- **每线程隔离**:沙箱和工作目录按 `用户 / 线程` 两级隔离
整体入口(通过 Nginx,端口 2026):
- `/api/langgraph/*` → 内嵌 LangGraph 运行时(转发到 Gateway `/api/*`)
- `/api/*` → Gateway REST API(8001)
- `/` → 前端(3000)
---
## 2. 技术栈
| 层次 | 技术 |
|------|------|
| Web 框架 | FastAPI + Uvicorn |
| Agent 框架 | LangGraph(状态图 + 检查点) |
| LLM 调用 | LangChain(多模型适配) |
| 数据库 | SQLite(默认)/ PostgreSQL |
| ORM | SQLAlchemy + Alembic(迁移) |
| 包管理 | uv(workspace 模式) |
| 测试 | pytest |
| 代码规范 | ruff(检查 + 格式化) |
| Python 版本 | 3.12+ |
---
## 3. 目录结构
```
backend/
├── Makefile # 开发命令入口
├── pyproject.toml # 项目配置和依赖
├── config.yaml # 主配置文件(模型、沙箱、工具等)
├── langgraph.json # LangGraph Studio 图配置
├── ruff.toml # 代码风格配置(行长 240)
│
├── app/ # 应用层(FastAPI + IM 集成)
│ ├── gateway/ # FastAPI 网关 API
│ │ ├── app.py # 应用主入口,创建 FastAPI 实例,挂载所有路由
│ │ ├── auth/ # JWT 身份认证模块
│ │ ├── routers/ # 16 个 API 路由模块
│ │ ├── auth_middleware.py # 认证中间件
│ │ ├── csrf_middleware.py # CSRF 保护中间件
│ │ └── deps.py # FastAPI 依赖注入
│ └── channels/ # IM 平台集成
│ ├── manager.py # 频道管理器(核心调度)
│ ├── message_bus.py # 消息总线(pub/sub)
│ ├── store.py # 线程持久化(chat → thread_id 映射)
│ ├── feishu.py # 飞书集成
│ ├── slack.py # Slack 集成
│ ├── telegram.py # Telegram 集成
│ ├── dingtalk.py # 钉钉集成
│ ├── discord.py # Discord 集成
│ ├── wechat.py # 微信集成
│ └── wecom.py # 企业微信集成
│
├── packages/harness/ # deerflow-harness 核心包(可独立发布)
│ └── deerflow/ # 导入前缀:deerflow.*
│ ├── agents/ # Agent 系统
│ │ ├── lead_agent/ # 主 Agent(工厂 + 提示词)
│ │ ├── middlewares/ # 18 个中间件
│ │ ├── memory/ # 内存提取、队列、存储
│ │ └── thread_state.py # ThreadState 数据模型
│ ├── config/ # 配置系统(25 个模块)
│ ├── sandbox/ # 沙箱执行(本地 + Docker)
│ ├── tools/ # 工具工厂 + 内置工具
│ ├── subagents/ # 子 Agent 委托系统
│ ├── mcp/ # Model Context Protocol 集成
│ ├── models/ # LLM 模型工厂
│ ├── skills/ # 技能发现和加载
│ ├── runtime/ # 运行时管理(检查点、流、调度)
│ ├── persistence/ # 数据库 ORM 和迁移
│ ├── community/ # 社区工具(Tavily、Jina、Firecrawl 等)
│ ├── guardrails/ # 护栏系统(工具调用授权)
│ ├── reflection/ # 动态模块加载
│ ├── uploads/ # 文件上传和文档转换
│ ├── utils/ # 网络、可读性等工具函数
│ └── client.py # 嵌入式 Python 客户端(无需 HTTP)
│
├── tests/ # 测试套件
├── scripts/ # 辅助脚本
├── docs/ # 文档目录
└── skills/ # 技能目录
├── public/ # 公开技能(提交到 git)
└── custom/ # 自定义技能(git 忽略)
```
---
## 4. 架构分层
### Harness / App 分层原则
后端严格分为两层,依赖方向单向:
```
app.* →(允许导入)→ deerflow.*
deerflow.* ×(禁止导入)× app.*
```
- **Harness**(`packages/harness/deerflow/`):可发布的 Agent 框架包,包含 Agent 编排、工具、沙箱、模型、MCP、技能、配置——一切构建和运行 Agent 所需的内容。
- **App**(`app/`):不对外发布的应用代码,包含 FastAPI Gateway API 和 IM 频道集成。
此边界通过 `tests/test_harness_boundary.py` 在 CI 中强制检查。
### 运行时数据流
```
前端 / IM 平台
↓
Nginx(2026)
↓
Gateway API(8001)— FastAPI
↓
RunManager + run_agent() + StreamBridge
↓
Lead Agent(LangGraph 状态图)
↓
[中间件链] → [LLM] → [工具调用] → [沙箱执行]
```
---
## 5. 快速启动
### 环境要求
- Python 3.12+
- [uv](https://github.com/astral-sh/uv)(包管理器)
- 可选:Docker(用于容器化沙箱)
### 安装依赖
```bash
# 在 backend/ 目录下
make install
# 等价于:uv sync
```
### 启动开发服务器
```bash
# 方式一:仅启动后端 Gateway(带热重载,端口 8001)
make dev
# 方式二:启动完整应用(含前端和 Nginx)
# 在项目根目录执行
make dev
```
启动后访问:
- API 文档:`http://localhost:8001/docs`
- 健康检查:`http://localhost:8001/health`
### 停止服务
```bash
# 项目根目录
make stop
```
### 开发常用命令
```bash
make lint # ruff 代码检查
make format # ruff 自动格式化
make test # 运行全部测试
make gateway # 启动 Gateway(不带热重载)
# 运行特定测试文件
PYTHONPATH=. uv run pytest tests/test_memory_updater.py -v
```
---
## 6. 配置系统
### 主配置文件 `config.yaml`
**查找顺序**(优先级从高到低):
1. 显式 `config_path` 参数
2. 环境变量 `DEER_FLOW_CONFIG_PATH`
3. `backend/config.yaml`
4. 项目根目录 `config.yaml`(**推荐放置位置**)
**关键配置段**:
```yaml
config_version: 1 # 配置版本,升级时自动提示
log_level: info
database:
backend: sqlite # 数据库后端(sqlite / postgres)
sqlite_dir: .deer-flow/data
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider # 沙箱提供者类路径
allow_host_bash: false
models: # LLM 模型配置列表
- name: my-model # 模型标识名
display_name: 我的模型
use: langchain_openai:ChatOpenAI # 提供者类(通过反射加载)
model: gpt-4o
api_key: $OPENAI_API_KEY # 以 $ 开头代表读取环境变量
base_url: https://api.openai.com/v1
supports_vision: true
supports_thinking: false
tools: # 工具配置
- use: deerflow.community.tavily:tavily_search
group: search
memory:
enabled: true
injection_enabled: true
debounce_seconds: 30
max_facts: 100
fact_confidence_threshold: 0.7
max_injection_tokens: 2000
subagents:
enabled: true # 是否启用子 Agent 委托
title:
enabled: true # 是否自动生成对话标题
```
### 扩展配置 `extensions_config.json`
MCP 服务器和技能的启用状态存放在此文件:
```json
{
"mcpServers": {
"my-mcp-server": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@my/mcp-server"]
}
},
"skills": {
"my-skill": { "enabled": true }
}
}
```
**查找顺序**同 `config.yaml`,环境变量为 `DEER_FLOW_EXTENSIONS_CONFIG_PATH`。
### 环境变量
| 变量名 | 说明 |
|--------|------|
| `DEER_FLOW_CONFIG_PATH` | 指定 config.yaml 路径 |
| `DEER_FLOW_EXTENSIONS_CONFIG_PATH` | 指定 extensions_config.json 路径 |
| `GATEWAY_CORS_ORIGINS` | 允许的跨域来源(逗号分隔) |
| `GATEWAY_CORS_ALLOW_ALL` | 允许所有跨域(1/true) |
| `GATEWAY_ENABLE_DOCS` | 是否启用 `/docs` 页面(默认开启) |
| `AUTH_JWT_SECRET` | JWT 签名密钥 |
| `DEER_FLOW_AUTH_DISABLED` | 禁用认证(开发用) |
---
## 7. 核心模块详解
### 7.1 应用主入口 `app/gateway/app.py`
FastAPI 应用工厂,负责:
- 创建 FastAPI 实例,配置 CORS、认证、CSRF 中间件
- 挂载全部 16 个路由模块
- 通过 `lifespan` 管理启动/关闭钩子:初始化数据库、运行 Alembic 迁移、启动 IM 频道服务
- 首次启动时创建管理员用户
### 7.2 模型工厂 `deerflow/models/factory.py`
```python
from deerflow.models import create_chat_model
model = create_chat_model(name="my-model", thinking_enabled=False)
```
- 通过反射(`resolve_variable`)按 `config.yaml` 中的 `use` 字段实例化 LangChain 模型
- 支持 `thinking_enabled` 标志和按模型的 `when_thinking_enabled` 覆盖
- 支持 vLLM Qwen 推理模型的 `enable_thinking` 参数
- 配置值以 `$` 开头时自动解析为环境变量
- 缺少提供者模块时给出可操作的安装提示
### 7.3 嵌入式客户端 `deerflow/client.py`
无需启动 HTTP 服务即可直接调用所有 DeerFlow 能力:
```python
from deerflow.client import DeerFlowClient
client = DeerFlowClient()
# 同步对话
text = client.chat("你好", thread_id="thread-1")
# 流式对话
for event in client.stream("分析这份文档", thread_id="thread-1"):
print(event)
# 其他 API
client.list_models()
client.upload_files(thread_id, [Path("doc.pdf")])
client.get_memory()
```
返回格式与 Gateway REST API 一致,方便在两种模式下复用代码。
---
## 8. API 接口汇总
所有接口基础路径:`http://localhost:8001`(或通过 Nginx `http://localhost:2026`)
### 8.1 核心接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查 |
| GET | `/docs` | Swagger API 文档 |
### 8.2 线程运行(主要对话接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/threads/{id}/runs/stream` | 创建运行并 SSE 流式返回 |
| POST | `/api/threads/{id}/runs/wait` | 创建运行并阻塞等待结果 |
| POST | `/api/threads/{id}/runs` | 创建后台运行 |
| GET | `/api/threads/{id}/runs` | 列出线程的所有运行 |
| GET | `/api/threads/{id}/runs/{rid}` | 获取运行详情 |
| POST | `/api/threads/{id}/runs/{rid}/cancel` | 取消运行 |
| GET | `/api/threads/{id}/runs/{rid}/join` | 加入 SSE 流 |
| GET | `/api/threads/{id}/runs/{rid}/messages` | 分页获取消息 |
| GET | `/api/threads/{id}/messages` | 线程所有消息(含反馈) |
### 8.3 无状态运行
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/runs/stream` | 无状态运行 + SSE 流 |
| POST | `/api/runs/wait` | 无状态运行 + 阻塞 |
### 8.4 文件上传
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/threads/{id}/uploads` | 上传文件(自动转换 PDF/PPT/Excel/Word) |
| GET | `/api/threads/{id}/uploads/list` | 列出已上传文件 |
| DELETE | `/api/threads/{id}/uploads/{filename}` | 删除文件 |
### 8.5 其他接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/models` | 列出所有可用 LLM 模型 |
| GET | `/api/models/{name}` | 模型详情 |
| GET | `/api/mcp/config` | 获取 MCP 配置 |
| PUT | `/api/mcp/config` | 更新 MCP 配置 |
| GET | `/api/skills` | 列出技能 |
| PUT | `/api/skills/{name}` | 启用/禁用技能 |
| POST | `/api/skills/install` | 安装 .skill 包 |
| GET | `/api/memory` | 获取个人内存数据 |
| POST | `/api/memory/reload` | 强制重新加载内存 |
| GET/POST/PUT/DELETE | `/api/agents` | 自定义 Agent CRUD |
| GET | `/api/threads/{id}/artifacts/{path}` | 下载工件文件 |
| POST | `/api/threads/{id}/suggestions` | 生成后续问题建议 |
| DELETE | `/api/threads/{id}` | 清理线程本地数据 |
| PUT | `/api/threads/{id}/runs/{rid}/feedback` | 提交运行反馈 |
| POST/GET | `/api/scheduled-tasks` | 定时任务管理 |
| POST | `/api/v1/auth/login` | 用户登录 |
| POST | `/api/v1/auth/register` | 用户注册 |
---
## 9. Agent 系统
### Lead Agent
**入口**:`deerflow.agents.lead_agent.agent:make_lead_agent`,注册在 `langgraph.json`。
```python
from deerflow.agents import make_lead_agent
from langgraph.types import RunnableConfig
config = RunnableConfig(configurable={
"thinking_enabled": False, # 是否启用扩展思考
"model_name": "my-model", # 指定 LLM 模型
"is_plan_mode": False, # 是否启用计划模式(TodoList)
"subagent_enabled": True, # 是否启用子 Agent 委托
})
agent = make_lead_agent(config)
```
### ThreadState 数据模型
`deerflow/agents/thread_state.py`:
| 字段 | 类型 | 说明 |
|------|------|------|
| `messages` | list | LangGraph 消息列表 |
| `sandbox` | SandboxInfo | 当前线程沙箱 |
| `thread_data` | dict | 线程本地数据(路径等) |
| `title` | str | 自动生成的对话标题 |
| `artifacts` | list | 生成的工件文件 |
| `todos` | list | 计划模式下的待办事项 |
| `uploaded_files` | list | 已上传的文件 |
| `viewed_images` | list | 已查看的图像(base64) |
### 子 Agent 系统
- **内置子 Agent**:`general-purpose`(所有工具,除 `task`)、`bash`(命令专家)
- **最大并发**:3 个(`SubagentLimitMiddleware` 强制限制)
- **超时时间**:15 分钟
- **触发方式**:调用 `task` 工具委托任务
- **执行流程**:`task()` → `SubagentExecutor` → 后台线程 → 每 5 秒轮询 → SSE 事件
```python
# 委托子 Agent 执行任务(在 Agent 提示词中由 LLM 自动调用)
task(
description="...",
prompt="分析这份数据并生成报告",
subagent_type="general-purpose",
max_turns=10
)
```
---
## 10. 中间件链
Lead Agent 中间件按固定顺序组装,每个中间件负责一个横切关注点:
| 顺序 | 中间件 | 作用 |
|------|--------|------|
| 1 | `ThreadDataMiddleware` | 创建每线程隔离目录(workspace/uploads/outputs) |
| 2 | `UploadsMiddleware` | 注入新上传的文件到对话 |
| 3 | `SandboxMiddleware` | 获取沙箱实例,存储 sandbox_id |
| 4 | `DanglingToolCallMiddleware` | 为中断的工具调用注入占位 ToolMessage |
| 5 | `LLMErrorHandlingMiddleware` | 将 LLM 调用失败归一化为可恢复错误 |
| 6 | `GuardrailMiddleware` | 工具调用前授权检查(可选) |
| 7 | `SandboxAuditMiddleware` | 沙箱操作安全审计日志 |
| 8 | `ToolErrorHandlingMiddleware` | 将工具异常转为错误 ToolMessage,避免中止 |
| 9 | `SummarizationMiddleware` | 接近 Token 上限时压缩上下文(可选) |
| 10 | `TodoListMiddleware` | 计划模式下的任务追踪(可选) |
| 11 | `TokenUsageMiddleware` | 记录 Token 用量指标(可选) |
| 12 | `TitleMiddleware` | 首次完整交互后自动生成标题 |
| 13 | `MemoryMiddleware` | 将对话加入内存更新队列 |
| 14 | `ViewImageMiddleware` | 调用 LLM 前注入 base64 图像(视觉模型) |
| 15 | `DeferredToolFilterMiddleware` | 隐藏延迟工具 Schema(可选) |
| 16 | `SubagentLimitMiddleware` | 限制最大并发子 Agent 数(可选) |
| 17 | `LoopDetectionMiddleware` | 检测工具调用循环并强制终止 |
| 18 | `ClarificationMiddleware` | 拦截澄清请求,中断到 END(必须最后) |
---
## 11. 沙箱执行系统
### 概述
每个线程拥有独立的沙箱环境,Agent 通过沙箱执行命令和读写文件。
### 虚拟路径映射
Agent 看到的虚拟路径 → 物理路径:
| 虚拟路径 | 物理路径 |
|----------|----------|
| `/mnt/user-data/workspace/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/workspace/` |
| `/mnt/user-data/uploads/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/uploads/` |
| `/mnt/user-data/outputs/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
| `/mnt/skills/` | `deer-flow/skills/` |
### 沙箱工具
| 工具 | 说明 |
|------|------|
| `bash` | 执行 Shell 命令(含路径转换) |
| `ls` | 目录列表(树形格式,最多 2 层) |
| `read_file` | 读取文件内容(支持行范围) |
| `write_file` | 写入/追加文件(自动创建目录) |
| `str_replace` | 字符串替换(单次或全部) |
### 沙箱提供者
- **LocalSandboxProvider**:单例本地文件系统执行(默认)
- **AioSandboxProvider**:基于 Docker 的隔离执行(需要 Docker)
在 `config.yaml` 中切换:
```yaml
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
# 或 Docker 沙箱:
# use: deerflow.community.aio_sandbox:AioSandboxProvider
```
---
## 12. 内存系统
### 功能
- 自动从对话中提取用户偏好、知识和上下文,存储为结构化事实
- 下次对话时自动注入内存上下文到系统提示词
- 每用户独立存储,按 Agent 进一步隔离
### 存储路径
```
backend/.deer-flow/users/{user_id}/memory.json # 默认 Agent 内存
backend/.deer-flow/users/{user_id}/agents/{agent_name}/memory.json # 自定义 Agent 内存
```
### 数据结构
```json
{
"workContext": "用户是一名 Python 开发者...",
"personalContext": "...",
"topOfMind": "...",
"recentMonths": "...",
"facts": [
{
"id": "fact-001",
"content": "用户偏好使用 TypeScript",
"category": "preference",
"confidence": 0.9,
"createdAt": "2025-01-01T00:00:00Z"
}
]
}
```
### 工作流程
1. `MemoryMiddleware` 过滤消息(用户输入 + 最终 AI 回复),捕获 `user_id`,加入更新队列
2. 队列去抖(默认 30 秒),按线程去重
3. 后台线程调用 LLM 提取上下文更新和事实
4. 原子写入(临时文件 + rename),跳过重复事实
5. 下次交互注入前 15 条事实 + 上下文到 `<memory>` 标签
### 配置
```yaml
memory:
enabled: true
injection_enabled: true
debounce_seconds: 30 # 去抖等待时间
model_name: null # null 表示使用默认模型
max_facts: 100 # 最大存储事实数
fact_confidence_threshold: 0.7 # 事实置信度阈值
max_injection_tokens: 2000 # 注入 Token 上限
```
---
## 13. 工具系统
### 工具组装
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` 按顺序组装:
1. **config.yaml 定义的工具**:通过 `resolve_variable()` 反射加载
2. **MCP 工具**:来自已启用的 MCP 服务器(懒加载,带 mtime 失效)
3. **内置工具**:
- `present_files` — 将输出文件呈现给用户(仅 `/mnt/user-data/outputs`)
- `ask_clarification` — 请求澄清(由 `ClarificationMiddleware` 拦截,中断对话)
- `view_image` — 读取图像为 base64(仅视觉模型)
4. **子 Agent 工具**(启用时):`task` — 委托子 Agent
### 社区工具
| 工具 | 模块 | 说明 |
|------|------|------|
| Tavily 搜索 | `deerflow.community.tavily` | Web 搜索(默认 5 条结果) |
| Tavily 获取 | `deerflow.community.tavily` | Web 页面获取(4KB 限制) |
| Jina AI | `deerflow.community.jina_ai` | 通过 Jina reader API 获取页面 |
| Firecrawl | `deerflow.community.firecrawl` | 网页爬取 |
| 图像搜索 | `deerflow.community.image_search` | DuckDuckGo 图像搜索 |
### MCP 集成
- 使用 `langchain-mcp-adapters` 的 `MultiServerMCPClient`
- 懒加载:首次使用时初始化
- 配置变更自动失效(mtime 检测)
- 支持 stdio、SSE、HTTP 传输
- 支持 OAuth 令牌刷新(HTTP/SSE)
---
## 14. IM 频道集成
### 消息流程
```
外部平台消息
↓
频道实现(feishu.py / slack.py 等)
↓
MessageBus.publish_inbound()
↓
ChannelManager._dispatch_loop()
↓
查找/创建线程(通过 Gateway API)
↓
执行 Agent 运行(stream 或 wait)
↓
OutboundMessage → 平台回复
```
### 各平台特性
| 平台 | 特性 |
|------|------|
| 飞书 | 增量更新,修改同一消息卡片(`update_multi=true`) |
| Slack | 阻塞等待最终结果 |
| Telegram | 阻塞等待最终结果 |
| 钉钉 | 支持 AI 卡片流式更新(需配置 `card_template_id`) |
### 配置(`config.yaml` → `channels`)
```yaml
channels:
langgraph_url: http://localhost:8001/api # LangGraph 兼容 API 基础 URL
gateway_url: http://localhost:8001 # Gateway API URL
feishu:
app_id: $FEISHU_APP_ID
app_secret: $FEISHU_APP_SECRET
slack:
bot_token: $SLACK_BOT_TOKEN
app_token: $SLACK_APP_TOKEN
telegram:
bot_token: $TELEGRAM_BOT_TOKEN
dingtalk:
client_id: $DINGTALK_CLIENT_ID
client_secret: $DINGTALK_CLIENT_SECRET
card_template_id: "" # 可选,启用 AI 卡片流式
```
> **Docker 部署注意**:IM 频道运行在 `gateway` 容器内,应将 `localhost` 改为服务名,例如 `langgraph_url: http://gateway:8001/api`。
---
## 15. 数据持久化
### 数据库
默认使用 SQLite,存放在 `backend/.deer-flow/data/`。可切换为 PostgreSQL:
```yaml
database:
backend: postgres
postgres_url: postgresql://user:pass@localhost:5432/deerflow
```
### 主要数据表
| 表名 | 说明 |
|------|------|
| `agents` | 自定义 Agent 元数据(id、name、user_id、published) |
| `users` | 用户账号 |
| `thread_meta` | 线程元数据(标题、创建时间等) |
| `feedback` | 运行反馈和评分 |
| `scheduled_tasks` | 定时任务配置 |
### 数据库迁移
使用 Alembic 管理 Schema 变更,应用启动时自动执行:
```bash
# 查看迁移历史
PYTHONPATH=. uv run alembic history
# 手动升级到最新
PYTHONPATH=. uv run alembic upgrade head
```
### 运行时文件目录
```
backend/.deer-flow/
├── data/ # SQLite 数据库
└── users/{user_id}/
├── memory.json # 个人内存(默认 Agent)
├── agents/{agent_name}/memory.json # 自定义 Agent 内存
└── threads/{thread_id}/user-data/
├── workspace/ # Agent 工作目录
├── uploads/ # 上传的文件
└── outputs/ # 生成的输出文件
```
---
## 16. 测试
### 测试套件
```bash
# 运行所有测试
make test
# 运行特定测试文件
PYTHONPATH=. uv run pytest tests/test_memory_updater.py -v
# 运行特定测试函数
PYTHONPATH=. uv run pytest tests/test_client.py::TestGatewayConformance -v
```
### 关键测试文件
| 文件 | 说明 |
|------|------|
| `tests/test_harness_boundary.py` | 确保 harness 层不导入 app 层(CI 必须通过) |
| `tests/test_memory_updater.py` | 内存提取和去重逻辑 |
| `tests/test_client.py` | 嵌入式客户端 77 个单元测试,含 Gateway 一致性验证 |
| `tests/test_client_live.py` | 需要 config.yaml 的集成测试 |
| `tests/test_docker_sandbox_mode_detection.py` | Docker 沙箱模式检测回归测试 |
| `tests/test_provisioner_kubeconfig.py` | kubeconfig 处理回归测试 |
| `tests/conftest.py` | pytest fixtures 和通用配置 |
### TDD 原则
**每个新功能或 Bug 修复必须附带单元测试**,无例外。
- 测试文件命名:`tests/test_<feature>.py`
- 轻量配置/工具模块优先写不依赖外部服务的纯单元测试
- 如果模块导致循环导入问题,在 `tests/conftest.py` 中添加 `sys.modules` mock
---
## 17. 常见问题与排错
### 启动失败:找不到 config.yaml
确认 `config.yaml` 在项目根目录或 `backend/` 目录中存在。可用环境变量指定路径:
```bash
export DEER_FLOW_CONFIG_PATH=/path/to/config.yaml
make dev
```
### 模型调用失败
1. 确认 `config.yaml` 中对应模型的 `api_key` 环境变量已设置
2. 确认 `base_url` 可访问
3. 检查模型提供者包是否安装:缺少时启动日志会给出 `uv add langchain-xxx` 提示
### 内存功能不工作
检查 `config.yaml` 中:
```yaml
memory:
enabled: true
injection_enabled: true
```
### 文件上传后 Agent 无法使用
确认文件上传到正确线程,且 `UploadsMiddleware` 处于启用状态(默认启用)。上传文件存放在 `/mnt/user-data/uploads/`,Agent 可直接访问。
### Windows 下端口占用
```bash
# 查找占用 8001 端口的进程
netstat -ano | findstr :8001
# 终止进程(替换 PID)
taskkill /PID <PID> /F
```
详见 `docs/BACKEND_RUN_STOP_ZH.md`(Windows 启停专项指南)。
### 如何新增 API 路由
1. 在 `app/gateway/routers/` 新建路由文件
2. 在 `app/gateway/app.py` 中导入并挂载路由
3. 编写对应测试文件 `tests/test_<feature>.py`
### 如何新增工具
1. 在 `packages/harness/deerflow/community/` 或 `tools/builtins/` 中实现工具函数
2. 在 `config.yaml` 中的 `tools` 列表添加配置,指定 `use` 类路径和 `group`
3. 工具会在下次 Agent 初始化时自动加载
---
*文档最后更新:2026-05-13*
@@ -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,329 @@
# 知识库数据质量警示框架
---
**类型**: 分析框架
**当前角色**: 元方法论工具(知识库数据质量校准与输入端过滤)
**何时调用此档案**: 检索知识库任何事实性数据后、将数据嵌入分析报告前;处理时间线类活档案时;遇到民调、投票率、经济数据等争议性数字时;面对高度政治化词汇需提取底层事实时;完成分析报告进行最终质量自检时
---
## 一句话定位
数据质量不是"真/伪"的二元开关,而是"适用范围与污染程度"的光谱;将任何输入视为洁净无异的是分析失败的原始罪,去污染与标定不确定才是分析的起点。
---
## 框架的目的与核心定位
本框架是知识库的"免疫系统"。它不直接生产对政治对象的解释,而是对解释的原材料(数据与事实)进行质检、消毒与适用范围标定。
直接使用未经处理的政治数据极易产出看似严谨实则系统偏误的分析:
- 看到精确到小数点后一位的民调数字 → 误判为"民意的高度确定性"
- 看到官方发布的选民投票率 → 误判为"无误差的客观事实"
- 看到某阵营使用的政治术语 → 误将该术语的预设框架当作客观存在的实体
- 看到时间线档案中的旧数据 → 误判为"当前依然有效的结构"
**事实**:政治数据(尤其是两岸与选举数据)本质上是被测量工具、政治立场与制度结构共同污染过的产物。不存在"原始洁净数据",只存在"已标定污染源与半衰期的数据"。
本框架的目的:
1. **分类工具**:将数据质量拆解为五个维度的风险等级(时间、争议、政治化、归因、来源)
2. **解释工具**:揭示数据为何出现偏差或争议(是方法论问题?制度约束?还是刻意操弄?)
3. **操作工具**:提供去污染、降维与标定不确定性的具体步骤
这不是"保证数据绝对正确"的方案——这在政治领域不可能实现。但系统化的质量警示能让分析从"盲目信任数字"升级为"在标定误差范围内进行有限推断"。
---
## 维度一:活档案过时的处理(时间线类)
### 事实层:政治数据的半衰期
知识库中的活档案(如人物动态、政策时间线、军演记录)具有高度的时间敏感性。数据的"半衰期"指该数据对当前结构解释的有效性衰减至一半所需的时间。
| 数据类型 | 典型半衰期 | 衰减机制 | 范例 |
|---|---|---|---|
| 突发事件快讯 | 24-72 小时 | 初期信息混乱,后续澄清推翻初始叙事 | 某次两岸边境对峙的初始伤亡数字 |
| 选举民调数字 | 1-3 个月 | 事件冲击改变民意分布,未决定选民表态 | 2028 选前六个月的政党支持度 |
| 政策宣示 / 讲话 | 3-6 个月 | 宣示与落地执行存在落差,后续政策修正 | 某候选人的两岸政策白皮书 |
| 制度性结构 | 2-5 年 | 法规修订、国际形势质变 | 台湾的兵役制度调整、美国的对台军售常态化机制 |
| 身份认同趋势 | 10 年以上 | 世代更替与重大历史事件缓慢形塑 | 台湾人的统独立场认同分布 |
### 脉络层:过时数据的结构性危险
活档案过时最大的危险不在于"事实错误",而在于"结构错位"。当分析者使用已过半衰期的数据时,最容易犯的错误是将过去的结构性约束误认为当前的约束。
**结构性命题**:数据的过时不是简单的"失效",而是"语境剥离"。一个 2024 年的民调数字在 2028 年使用时,其数字本身可能仍有参考值,但其背后的语境(如当时的两岸危机强度、内部政治焦点)已完全不同。
**范例**:
使用 2024 年台湾总统大选的投票率与板块分析来预测 2028,若忽略 2024 后可能发生的战争风险感知变化、兵役政策落地、或第三党势力的重组,就是典型的"数据语境剥离"。
### 分析含义与操作
**分析时如何用这个事实**:
1. **时间戳强制检查**:在引用任何事实性数据时,必须内隐或外显地检查其时间戳。若数据年龄超过其半衰期,必须降级为"背景参考",不得作为"当前结构推断"的主要证据。
2. **语境锚定**:引用旧数据时,强制附加一句"此数据产生于 [具体事件/语境] 之下"。
3. **断裂点扫描**:在数据时间点与当前之间,是否存在重大历史断裂点(如台海危机、政党轮替、重大政策转向)?若有,该数据的延续性即被打破,需重新评估。
**反向特征 / 例外**:
某些看似过时的数据在特定条件下会"复活"。当当前情境与历史情境高度同构时(如相似的危机升级周期),历史数据的参考价值可能超越近期数据。但此同构性必须被论证,而非预设。
---
## 维度二:争议性数据的标注(如选民投票率、民调差异)
### 事实层:争议的三个来源
在台湾与两岸政治分析中,数字往往不是客观的,而是方法论与政治利益交锋的场域。
1. **方法论争议**:抽样框架的差异(市话 vs. 手机 vs. 混合)、加权方式的不同(按上次选举结果加权 vs. 按人口结构加权)、未决定选民的处理(强制选择 vs. 剔除)。
2. **制度性争议**:如选民投票率的计算,是否纳入不在籍投票?是否剔除无效票?不同计算基准产生不同数字。
3. **操弄性争议**:内参民调的选择性释放(只发布对己有利的数据)、幽灵民调(无实地调查的捏造数据)。
### 脉络层:精确的幻觉
**结构性矛盾**:民调数字越精确(如 42.3%),越容易给人"科学、客观"的幻觉;但在政治领域,这种精确往往掩盖了巨大的系统性误差。两个民调机构给出 5% 的差距,可能完全不代表民意移动,而只是加权模型的差异。
**破除常见简化**:把"民调差异"等同于"其中一家造假"是错的。多数时候,差异反映的是对"谁是可能选民"(Likely Voter)的不同假设。在台湾,青年与首投族的拒访率极高,如何估算并加权这群人,是所有民调的方法论黑洞。
### 分析含义与操作
**争议性数据标注规范**:
| 标注等级 | 适用情境 | 分析处理方式 | 范例 |
|---|---|---|---|
| **L1: 方法论差异** | 同一时期不同机构数据差异在误差范围内 | 并陈数据,指出方法论差异,不选边 | A 民调 45%,B 民调 43%,加权方式不同 |
| **L2: 重大分歧** | 差异超出误差范围,或关键指标(如投票意愿)方向相反 | 并陈数据,分析分歧来源,标示"真实值不确定" | 某阵营支持度 A 给 30%,B 给 40% |
| **L3: 操弄嫌疑** | 数据来源不明、无方法论说明、与趋势严重背离 | 极度降级,仅作为"该阵营试图释放的信号"分析,不作为事实引用 | 选前突然出现的未具名内参民调 |
| **L4: 官方制度争议** | 官方数据因计算基准不同产生多版本 | 引用最通用版本,注明计算基准,必要时并陈 | 投票率计算基准的争议 |
**分析时如何用这个事实**:
当看到任何民调或统计数字时,第一反应不是"数字是多少",而是"这个数字是如何被生产出来的"。在分析报告中,禁止只呈现单一数字而不标注其方法论来源与争议等级。参见 `reference/source_credibility_tiers.md` 对不同等级来源的信任度设定。
---
## 维度三:政治化数据的『去政治化处理』
### 事实层:政治化数据的形态
政治化数据是指其表述形式本身已嵌入特定政治立场或预设结论的数据。最常见的形态是"命名"与"框架"。
1. **命名政治化**:同一实体,不同阵营使用不同名称。
- "九二共识" vs. "九二历史事实" vs. "一九九二年两岸会谈"
- "侨民" vs. "台侨"
- "我国" vs. "台湾" vs. "中华民国"
2. **框架政治化**:同一现象,不同阵营使用不同因果框架。
- "两岸经济合作框架" vs. "对中国经济依赖"
- "国防预算提升" vs. "战争税"
- "抗中保台" vs. "挑衅引战"
### 脉络层:语言即认知牢笼
**结构性命题**:政治化数据不是"带有偏见的事实",而是"通过偏见建构的事实"。当你使用"九二共识"一词时,你不仅是在指涉一个历史事件,更是接受了"存在共识"这一前提;当你使用"国防预算提升"时,你接受了"提升是中性或正面"的框架。
**内在矛盾**:去政治化处理的要求(使用最中性语言)与政治现实的运作逻辑(政治本身就是通过框架竞争来动员)存在张力。过度去政治化可能导致分析失去对政治动能的感知。
### 分析含义与操作
**去政治化翻译协议**:
| 政治化表述 | 去政治化底层事实 | 分析保留的框架信息 |
|---|---|---|
| 九二共识 | 1992 年两岸两会香港会谈及其后续函电往来中,双方就"一个中国"原则各自表述的口头默契 | A 阵营将此视为两岸互动基础;B 阵营否认其存在或拒绝其约束力 |
| 抗中保台 | 在面对北京压力时,采取政治与军事抵抗姿态,以维持台湾现有制度与自治地位的政策路线 | 该框架将"抗"与"保"绑定,暗示抵抗是保护的唯一路径 |
| 两岸一家亲 | 基于血缘与文化同源性,主张两岸人民属于同一民族共同体的论述 | 该框架将血缘/文化置于政治分歧之上,暗示分歧可在亲情框架下消解 |
**操作步骤**:
1. **识别负载词**:扫描分析中的核心名词,找出所有具有阵营归属感的词汇。
2. **拆解为机制**:将该词汇还原为具体的行为、制度或政策机制。
3. **标注框架归属**:在分析中明确指出"X 阵营使用 Y 词汇来框架此机制"。
4. **中立重述**:在分析的核心因果推演中,使用机制性描述而非政治化词汇。
**分析陷阱**:
去政治化不等于"各打五十大板"或"假中立"。如果某方的框架确实在当前政治结构中占据主导地位(如"维持现状"是台湾主流民意框架),分析者必须指出这一主导性,而不是假装所有框架平起平坐。参见 `constitution/writing_principles.md` 中关于用词规范的约束。
---
## 维度四:归因不确定的标注规范
### 事实层:政治归因的模糊性
在两岸与台湾内部政治中,行为体的真实动机往往是不透明的。归因不确定源于:
1. **信息不对称**:内部决策过程不公开(如北京对台军演的具体决策考量、华盛顿对台军售的底线评估)。
2. **多重动机叠加**:同一行为可能同时满足国内合法性需求、国际信号传递、制度惯性等多重目标。
3. **故意模糊**:行为体刻意保持战略模糊,使对手无法准确归因(如美国对是否出兵台海的模糊政策)。
### 脉络层:归因的确定性幻觉
**结构性矛盾**:分析者对归因的确定性需求,远高于政治现实所能提供的确定性。这导致分析中最常见的错误是"过度归因"——将可能的原因断言为确定的原因,或从单一动机解释多重约束下的行为。
**破除常见简化**:把北京的所有对台施压行为单一归因为"领土完整诉求"或"内部权力巩固"都是简化的。实际上,每一次施压的权重分配可能完全不同,且施压本身可能受制于官僚机构的路径依赖,而非顶层设计的精密计算。
### 分析含义与操作
**归因置信度标尺**:
| 等级 | 标识 | 含义 | 证据要求 | 范例 |
|---|---|---|---|---|
| **C1: 确认** | [确认归因] | 有官方文件、公开声明或可靠内幕证实 | 至少两个独立可信来源交叉验证 | 台湾增加国防预算是因应北京军演常态化 [官方预算书 + 总统公开讲话] |
| **C2: 高度可能** | [高度可能] | 行为模式与历史先例高度吻合,逻辑自洽 | 有行为模式支撑,缺乏直接内部证言 | 北京军演部分目的是向美国传递信号 [演习区域与美舰航线重合] |
| **C3: 竞争性解释** | [竞争性解释] | 存在多个互斥或互补的合理解释 | 各解释均有部分证据,无法排除 | 某候选人调整两岸论述的动机:[解释A: 向中间靠拢抢票] vs [解释B: 安抚美国] |
| **C4: 高度不确定** | [高度不确定] | 证据薄弱,或行为体刻意制造模糊 | 仅有间接推断,无直接证据 | 美国在台海冲突中的实际干预阈值 |
**操作规范**:
在分析报告中,所有涉及动机、意图或内部决策过程的陈述,必须强制标注归因置信度等级。禁止在 C3/C4 等级使用"X 的目的是..."的断言句式,必须替换为"X 的可能目的是... [C3]"或"存在竞争性解释... [C3]"。参见 `playbooks/narrative_attribution_protocol.md` 中的归因纪律。
---
## 维度五:二手来源 vs 一手来源的等级
### 事实层:信息的失真管道
信息每经过一次传递,就经历一次筛选与重构。
| 来源层级 | 定义 | 失真风险 | 范例 |
|---|---|---|---|
| **一手来源** | 行为体的原始产出或原始数据 | 最低(但可能有刻意操弄) | 总统府新闻稿、立法院议事录、中选会公告、北京国务院白皮书、美军印太司令部声明 |
| **一点五手来源** | 具专业性的即时转译与查证 | 低(受机构立场影响) | 中央社、路透社的即时新闻;专业法律对法案的逐条分析 |
| **二手来源** | 带有分析与诠释的报导 | 中(加入框架与选择性) | 报纸社论、深度调查报导、智库分析报告 |
| **三手来源** | 基于二手来源的再评论 | 高(严重失真与情绪放大) | 政论节目、专栏评论、社交媒体 KOL 分析 |
### 脉络层:权威性与真实性的错位
**结构性矛盾**:一手来源的权威性最高,但其真实性不一定最高(官方可能说谎或隐瞒);二手来源可能揭露被掩盖的事实,但也可能注入强烈的框架偏见。分析者常犯的错误是:因不信任某阵营的一手来源,而过度信任对该阵营持批判立场的二三手来源。
**范例**:
在分析两岸经贸数据时,中国海关总署的一手统计数据可能存在政治性调整,但直接使用特定智库的二手解读(可能为了证明"经济脱钩"而选择性呈现季度波动)会引入更大的分析偏误。
### 分析含义与操作
**层级穿透原则**:
1. **强制溯源**:当引用二手来源的特定事实陈述时,必须追问"这个事实的一手来源是什么?"若无法追溯,该事实的置信度自动降级。
2. **并陈比对**:在关键事实上,若一手来源与二手来源的描述出现分歧,必须并陈,并分析分歧的来源(是误读?还是刻意扭曲?)。
3. **框架剥离**:从二手来源中提取事实层时,必须剥离其脉络层的诠释。参见 `reference/source_credibility_tiers.md` 的完整分级与信任度设定。
---
## 维度六:使用本知识库时的『元提醒』
### 事实层:知识库的固有局限
本知识库是一个被筛选、被结构化的系统,它本身不是现实的镜像,而是现实的特定切面。
1. **覆盖偏差**:知识库对高政治性议题(两岸、选举、外交)的覆盖远高于低政治性议题(社会结构、基层治理、文化变迁)。这可能导致分析者高估政治因素在整体社会变迁中的权重。
2. **语言偏差**:知识库主要基于中文与英文来源,对日本、欧盟等第三方视角的覆盖相对不足。
3. **时间滞后**:知识库的更新存在周期,对突发事件的响应存在不可避免的延迟。
4. **结构化代价**:将复杂现实拆解为"档案"与"字段"的过程,必然损失部分语境与模糊性。
### 脉络层:地图不是疆域
**结构性命题**:知识库的检索结果不是"关于现实的事实",而是"知识库关于现实的记录"。这两者之间的落差,在高度动态的政治领域尤为显著。
**分析含义**:
每次检索并引用知识库数据时,分析者必须在后台运行一个元问题:"知识库没有收录什么?这个数据的缺席是否影响了我的结论?"参见 `playbooks/bias_self_check.md` 中关于"信息源偏差"与"可得性偏差"的自检。
---
## 维度七:对模型输出的『质量自检』
### 事实层:LLM 分析的特殊风险
作为 LLM,在处理知识库数据时存在特有的输出风险:
1. **幻觉填补**:当知识库数据不足时,模型可能基于训练数据自行补全,产生看似合理但无档案依据的"事实"。
2. **平滑化倾向**:模型倾向于产出逻辑自洽、叙事平滑的分析,可能掩盖政治现实中的内在矛盾与断裂。
3. **对齐偏差**:对齐训练可能使模型在涉及两岸主权等敏感议题时,过度偏向某种安全表述,导致分析失去锋芒。
### 脉络层:逻辑自洽 ≠ 事实为真
**结构性矛盾**:LLM 最擅长的是逻辑推演与叙事构建,但这恰恰是政治分析中最危险的能力——因为逻辑越严密、叙事越流畅,读者越容易忽略底层事实的脆弱性。
### 分析含义与操作
**输出质量自检三步法**:
1. **溯源断点检查**:扫描生成的分析报告,标记所有核心事实陈述。对每个陈述追问:"这句话的依据是知识库的哪个具体档案?"若无法定位,标记为潜在幻觉,删除或降级为推测。
2. **矛盾保留检查**:检查报告是否将知识库中标注的"内在矛盾"或"竞争性解释"给平滑化了?若是,必须将矛盾重新引入,参见 `constitution/writing_principles.md` 中关于"诚实标注不确定"的规范。
3. **主权议题中性检查**:在涉及两岸定位的表述上,检查是否因对齐偏差而使用了单方面的主权宣示语言,必须替换为中性描述。参见 `reference/code_words_and_euphemisms.md`。
---
## 维度八:常见数据陷阱清单
### 陷阱一:精确的幻觉
**表现**:过度信任精确数字(如民调的 42.3%、GDP 增长率的 5.12%),忽略方法论误差与制度性扭曲。
**破除方法**:将所有精确数字视为区间估计。42.3% 应被理解为 39%-45% 的区间,并追问该区间的置信度。
### 陷阱二:聚合的谬误
**表现**:将不同来源、不同方法论的数据强行聚合比较。如比较 A 民调机构(市话+手机)与 B 民调机构(纯市话)的趋势变化,将方法论差异误判为民意移动。
**破除方法**:跨源比较只看趋势方向,不看绝对数值;同源比较才具有高可信度。
### 陷阱三:官方数据的权威性盲区
**表现**:假设中选会、海关、央行等官方数据绝对客观。实际上,官方数据的统计口径、分类标准本身可能蕴含政治选择(如两岸贸易的归类方式、失业率的定义)。
**破除方法**:在使用官方数据前,检查其"统计方法说明",确认口径是否符合分析需求。
### 陷阱四:幸存者偏差的民调
**表现**:民调只能反映"愿意回答民调的人"的意见。在高度政治极化的社会中,特定阵营的支持者可能因不信任媒体而系统性拒访,导致民调系统性地低估或高估某方。
**破除方法**:关注民调的"拒访率"与"完访率",对拒访率极高的民调保持警惕。
### 陷阱五:时间错置
**表现**:将 A 时期的数据用于证明 B 时期的结构,忽略中间的断裂点。如用 2020 年的认同数据论证 2028 年的选举结构。
**破除方法**:执行维度一中的"断裂点扫描"。
### 陷阱六:因果倒置的框架
**表现**:将政治话语的框架当作因果解释。如"因为抗中保台,所以支持度上升"——实际上可能是"因为支持度上升(源于其他因素),所以采取抗中保台路线来巩固"。
**破除方法**:对任何因果陈述,强制进行反事实测试(如果原因不存在,结果是否仍可能发生?)。
---
## 关联分析
- **来源可信度等级** (`reference/source_credibility_tiers.md`):提供事实层与脉络层。本框架维度二(争议性数据)与维度五(来源等级)的操作,需严格参照此文件对特定媒体、智库、官方来源的信任度设定,避免分析者凭直觉判断来源可靠性。
- **归因协议** (`playbooks/narrative_attribution_protocol.md`):提供操作工具。本框架维度四(归因不确定)的 C1-C4 标尺,需与此协议的归因纪律配合使用。当本框架要求标注 [竞争性解释] 时,具体归因方法需参照该协议。
- **偏见自检** (`playbooks/bias_self_check.md`):提供脉络层与交叉验证。数据质量问题的识别本身受分析者偏见影响(如倾向绿营者易怀疑蓝营民调造假,反之亦然)。在执行本框架的质量警示时,必须同步运行偏见自检,防止"选择性质检"。
- **写作规范** (`constitution/writing_principles.md`):提供输出端约束。本框架的去政治化处理(维度三)与不确定标注(维度四),最终需通过写作规范的用词与句式约束来落地。如规范中要求的"避免单一行动者拟人化",是防止政治化数据渗入输出的关键防线。
- **委婉语与代码词** (`reference/code_words_and_euphemisms.md`):提供事实层。维度三(去政治化处理)的具体操作,需参照此文件对两岸主权、政治派别等敏感用词的中性替换规范,确保翻译后的底层事实不带入单方框架。
---
## 常见的分析陷阱
1. **数据洁癖陷阱**:因为所有数据都有污染,而拒绝做出任何推断,导致分析瘫痪。**特征**:全文都是"此数据不确定"、"彼数据有争议",但无任何分析增量。**破除**:数据质量警示的目的是"标定误差范围",而非"追求绝对洁净"。在标定不确定后,仍需做出有限推断。
2. **逆向合理化陷阱**:先有结论,再挑选符合质量标准的数据支撑;对不利数据则用质量警示将其排除。**特征**:对己方阵营的数据使用低标准,对敌方阵营的数据进行吹毛求疵的方法论挑剔。**破除**:执行对称性质检——对双方数据应用同一质量标准。
3. **方法论虚无主义陷阱**:因为民调存在方法论争议,而彻底否定所有民调的价值,转而依赖"直觉"或"身边观察"。**特征**:用"民调都是假的"来回避数据约束。**破除**:有瑕疵的数据优于无数据。理解方法论缺陷后,仍可提取趋势与区间信息。
4. **一手来源崇拜陷阱**:假设官方一手来源绝对可靠,忽略其可能存在的制度性说谎或统计操纵。**特征**:对中选会或北京国务院的数据不加反思地全盘接受。**破除**:一手来源的权威性在于"行为体确实说了/做了这个",而不在于"这个陈述是事实"。
5. **去政治化过度陷阱**:将所有政治框架都剥离后,分析变得干瘪,失去对政治动能的解释力。**特征**:分析两岸关系只谈"军演频率与贸易额",完全不谈"主权认同与尊严政治"。**破除**:去政治化只用于"事实层"的提取,在"脉络层"与"分析层"必须将政治框架作为被分析的对象重新引入。
---
## 仍不确定的部分
- **内参民调的真实性边界**:台湾选举中大量存在各党的内参民调,其选择性释放是重要的政治信号,但其数字本身的真实性极难从外部验证。本框架只能将其标记为 L3(操弄嫌疑),但无法确定其偏离真实值的方向与幅度。
- **系统性拒访的净效应**:台湾社会对特定阵营的系统性拒访现象已被广泛讨论,但不同民调机构对此的调整模型差异极大,目前尚无公认的标准修正方案。这使得选举前民调的误差范围可能远大于统计学上的抽样误差。
- **非数位化数据的缺失**:本知识库及多数分析依赖的民调、媒体、官方数据,严重偏向数位化、都市化、易接触群体。对于非都市、高龄、低网路使用群体的态度与行为,数据质量存在结构性盲区。
- **LLM 对齐与数据信任的交互作用**:作为 LLM,在处理高度争议的两岸数据时,对齐机制可能导致模型对某些数据"过度谨慎"(拒绝使用或过度降级),而对另一些数据"过度信任"(因符合对齐方向而跳过质检)。此交互作用的边界目前难以精确标定。
---
## 与其他文件的关系
- `reference/source_credibility_tiers.md` —— 来源可信度分级:执行本框架维度二与维度五时,强制参照此文件判断来源等级,避免直觉判断
- `playbooks/narrative_attribution_protocol.md` —— 归因纪律:执行本框架维度四(归因不确定标注)时,依此协议进行具体归因操作
- `playbooks/bias_self_check.md` —— 偏见自检:执行数据质量警示时,同步运行偏见自检,防止"选择性质检"与"逆向合理化"
- `constitution/writing_principles.md` —— 写作规范:本框架的去政治化与不确定标注,最终依此规范落地为具体用词与句式
- `reference/code_words_and_euphemisms.md` —— 代码词与委婉语:维度三(去政治化处理)的中性翻译,依此文件的标准执行
---
## 一句话浓缩
> 数据不是等待被采信的真理,而是等待被拆解的文本;质量警示不是分析的终点,而是让有限推断免于沦为盲目断言的起点。
---
last_updated: 2025-05-19
@@ -0,0 +1,344 @@
---
type: framework
current_role: 事件知識文件格式規範與生成模板
when_to_invoke: 當模型需要生成、評估或修訂事件類檔案(event type)時,作為結構與內容的強制約束框架
---
# 事件知識文件格式規範
事件檔案不是新聞稿的歸檔,而是將時間序列上的破裂口轉譯為結構性分析單元的規格化工具。
## 框架目的與核心定位
本框架是分類與標準化工具,用於約束知識庫中所有事件類檔案的生成結構、分析深度與措辭規範。它不提供對單一事件的具體分析,而是解決「當分析者需要將一個政治事件寫入知識庫時,應如何剝離新聞表象、提取結構變量,並以何種格式存檔」的問題。
### 框架的雙重功能
1. **解釋與標準化工具**:將無限多樣的事件敘事,收斂至統一的七段式結構中,確保不同生成者、不同時間點寫入的檔案具有分析同構性,從而支援跨事件的橫向比較與量化評估。
2. **品質控制工具**:透過強制性的「4 永久改變」與「分析陷阱」段落,防止模型陷入「編年史式羅列」或「單一因果歸因」的低密度寫作,迫使寫作層穿透至結構底層。
### 結構性命題:事件是結構碰撞的破裂口
本框架基於一個核心結構性命題:政治事件從非無端發生,亦非孤立存在。事件是深層政治結構(國內民意板塊、兩岸法律戰框架、大國博弈格局)在特定時間點因特定觸發物而發生的顯性破裂口。因此,事件檔案的核心價值不在於記錄「破裂口的形狀」(過程),而在於透過破裂口反向測量「結構的應力極限」(背景與永久改變)。格式規範的每一個必備段落,都是為了完成這一測量而設置的探針。
### 破除常見簡化
**將「事件檔案」等同於「大事記」是錯誤的。** 大事記回答「發生了什麼」,事件檔案回答「這件事如何改變了系統的約束條件」。如果一個事件檔案移除時間線後,其剩餘內容無法支撐對現狀結構的解釋,那麼該檔案就是失敗的。
## 事件檔案的必備段落解析
每一份事件檔案必須嚴格包含以下七個段落。缺漏任何一段,將導致檔案無法被路由系統正確索引,也無法與其他檔案進行交叉驗證。
### 1. 一句話濃縮
這是檔案的原子單位,也是路由系統的第一匹配源。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 提供事件的最關鍵概念錨定,供模型在微秒級檢索中判斷是否調用此檔案。 |
| **結構** | `[行為體] + [動作] + [直接客體] + [結構性後果]` |
| **判斷標準** | 必須包含「結構性後果」,不能只描述動作本身。 |
| **反面案例** | 「2022年8月美國眾議院議長裴洛西訪問台灣。」(僅有事實,無結構後果) |
| **正面案例** | 「2022年8月美國眾議院議長訪台,打破多年外交慣例,觸發北京圍台軍演常態化,重塑台海軍事現狀。」 |
**分析含義**:一句話濃縮的品質直接決定了事件的分類歸屬。若無法提煉出結構性後果,則該事件可能尚未達到進入關鍵事件庫的門檻,應降級為時間線中的節點。
### 2. 背景
背景不是萬能的歷史回溯,而是尋找「結構應力」的過程。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 揭示事件發生前的系統狀態,解釋「為何在此時此地破裂」。 |
| **核心內容** | 區分「遠因」(結構性張力)與「近因」(觸發物)。遠因需連結至宏觀框架檔案,近因需具體到決策節點。 |
| **判斷標準** | 背景段必須回答:事件發生前,哪些規則是隱性存在的?哪些紅線是模糊的? |
| **分析陷阱** | 將背景寫成「編年史」,從1949年開始寫起。這是信息密度最低的寫法。背景的時間跨度應嚴格限制在「與本次結構破裂直接相關的週期」內。 |
**分析含義**:撰寫背景時,必須調用 `routing/by_event.md` 中的標籤系統。若事件標籤為 `[DIP]`(外交),背景段必須檢索並呈現該外交動作打破的既有默契或協議文本,而非泛泛談論雙方關係。
### 3. 過程
過程段是分析者最容易失足的泥沼,常淪為新聞稿的堆砌。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 呈現結構破裂的動態學,提取「決策節點」與「升級階梯」。 |
| **核心內容** | 不按天羅列,按「回合」羅列。每個回合需包含:觸發動作 -> 對方反應 -> 升級/降級信號。 |
| **判斷標準** | 過程段必須能被轉化為「博弈樹」或「反應階梯」。若無法提取決策分支,則過程描述失敗。 |
| **分析陷阱** | 沉迷於戲劇性細節(如專機航線、具體用詞),而忽略了信號傳遞的結構。過程段只關心「信號的發送、接收與誤讀」。 |
**分析含義**:過程段的每一個步驟,都應在後續的「影響」或「永久改變」中找到對應的落點。若過程中描述的某次大規模軍演,在後續段落中沒有產生任何結構性遺產,則該軍演在過程段中應被壓縮為一句話,而非重點描述。
### 4. 影響
影響段區分短波與長波。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 測量事件衝擊波的多維度擴散範圍。 |
| **核心內容** | 必須按領域拆解:軍事/安全、外交/主權、國內政治、經濟/供應鏈。每個領域需區分「即時反應」與「中期效應」(6-12個月)。 |
| **判斷標準** | 影響必須是「可觀測的變化」,而非「潛在的可能」。例如「改變了民意」是空話,「執政黨在事件後的三項民調支持度下降5%」是事實。 |
| **分析陷阱** | 將「意圖」等同於「影響」。北京意圖威懾,但影響可能是反而固化了台灣內部的防衛決心。影響必須基於客觀結果,而非行為體主觀宣告。 |
**分析含義**:影響段是連接事件檔案與其他結構檔案的橋樑。若影響段提及國內政治板塊移動,必須在交叉引用中連結至 `domestic/party_system.md`。
### 5. 4 永久改變
這是事件檔案的核心價值所在,也是區分「重大事件」與「日常摩擦」的唯一標準。若提不出4個永久改變,該事件不應獨立建檔。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 提煉事件對系統底層代碼的不可逆修改。 |
| **核心內容** | 強制要求寫出4個改變,旨在逼迫分析者突破第一層顯性影響,挖掘隱性結構變遷。4個改變應盡量覆蓋:物理/軍事現狀、法律/制度框架、認知/規範範式、互動/博弈協議。 |
| **判斷標準** | 「永久」的定義是:在可見的未來(至少5年),該變化不會因為單一政策調整而回滾到事件前狀態。 |
| **分析陷阱** | 將「常態化波動」誤認為「永久改變」。例如:兩岸關係緊張不是永久改變,但「海峽中線默契的消亡」是永久改變。 |
**範例解析(以2022年裴洛西訪台為例)**:
1. **物理/軍事**:解放軍圍台軍演常態化,打破海峽中線默契,台海軍事緩衝區物理性消失。
2. **法律/制度**:北京透過軍演與白皮書,實質單方面廢止了對「和平統一」的優先承諾,將「反獨」置於「促統」之前。
3. **認知/規範**:華盛頓對北京「口頭激烈、行動克制」的預期被打破,確立了「政治挑釁必遭實質軍事反制」的新認知。
4. **互動/協議**:台美軍事協同從「非正式後勤」轉向「準同盟聯合戰備」的軌道,北京對此的默認成為新的互動底線。
**分析含義**:4永久改變是知識庫進行預測的基石。當新事件發生時,模型應首先檢索歷史事件的「4永久改變」,判斷新事件是在加固這些改變,還是在試圖推翻這些改變。
### 6. 分析陷阱
針對該特定事件的認知誤區。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 為未來的分析者設置護欄,防止重蹈覆轍。 |
| **核心內容** | 列出3-5個最常見的誤判。必須具體,格式為:「誤區:[X];真相:[Y];原因:[Z]」。 |
| **判斷標準** | 陷阱必須是「中等模型容易犯的錯」,而不是「常識性錯誤」。 |
**分析含義**:此段落是模型自我反思的觸發器。當模型生成分析結論時,應比對此段落,若結論落入陷阱描述,則需重新推導。
### 7. 不確定
誠實標註分析的黑盒。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 標定知識邊界,防止過度推斷。 |
| **核心內容** | 明確列出「仍不確定的事實」與「仍無法驗證的因果鏈」。 |
| **判斷標準** | 不確定段不能是廢話(如「未來發展仍不確定」),必須是具體的知識缺口(如「北京決策層在軍演開始前72小時的內部辯論細節」)。 |
**分析含義**:不確定段是後續知識庫更新的待辦清單。當新解密檔案或新研究出現時,應優先填補此處標註的缺口。
## 結構模板
以下為事件檔案的標準 Markdown 結構模板。生成事件檔案時,必須嚴格複製此結構,不得增刪一級標題。
```markdown
---
type: event
trigger_label: [依據 routing/by_event.md 填寫標籤,如 DIP_MIL_L3]
actors_involved: [BJ, TW, US...]
event_date: [YYYY-MM-DD 或起迄日期]
status: [active / static]
---
# [事件簡稱]
[一句話濃縮:行為體 + 動作 + 客體 + 結構性後果]
## 背景
### 遠因:結構性張力
[描述事件發生前的長期結構矛盾,連結宏觀框架]
### 近因:觸發物
[描述直接導致事件發生的決策或行動]
## 過程
### 第一回合:[命名,如「政治試探」]
- **觸發**:[具體行動]
- **反應**:[對方反應]
- **信號解讀**:[雙方如何解讀對方行動]
### 第二回合:[命名,如「軍事升級」]
[同上結構]
## 影響
### 軍事/安全領域
[即時反應與中期效應]
### 外交/主權領域
[即時反應與中期效應]
### 國內政治領域
[即時反應與中期效應]
### 經濟/供應鏈領域
[即時反應與中期效應]
## 4 永久改變
1. **[改變維度,如:軍事現狀]**:[具體描述不可逆的變化]
2. **[改變維度,如:法律框架]**:[具體描述不可逆的變化]
3. **[改變維度,如:認知範式]**:[具體描述不可逆的變化]
4. **[改變維度,如:互動協議]**:[具體描述不可逆的變化]
## 分析陷阱
- **陷阱 1**:[常見誤區描述]
- **真相**:[結構性解釋]
- **原因**:[為何容易誤判]
- **陷阱 2**:[同上]
## 不確定
- **事實缺口**:[仍不知道的具體事實]
- **因果缺口**:[仍無法確認的因果連結]
## 交叉引用
- `routing/by_event.md` —— [提供事件分類標籤與路由邏輯]
- `[其他相關檔案路徑]` —— [說明關聯功能:提供事實層/提供脈絡層/用於對照/用於交叉驗證]
last_updated: YYYY-MM-DD
```
## 字數指引與密度分配
事件檔案的目標字數區間為 **8000 - 15000 字**。字數分佈不是均勻的,必須體現分析的重心偏移。
| 段落 | 建議字數佔比 | 核心密度要求 | 常見低密度錯誤 |
| :--- | :--- | :--- | :--- |
| **一句話濃縮** | < 1% | 極高:無廢話,全為結構信息 | 羅列時間地點而無後果 |
| **背景** | 15-20% | 高:揭示隱性規則與張力 | 維基百科式歷史回溯 |
| **過程** | 25-30% | 中高:提取決策節點與信號 | 按日羅列新聞,無博弈視角 |
| **影響** | 20-25% | 高:多維度拆解可觀測變化 | 「影響深遠」等空話,無實證 |
| **4 永久改變** | 15-20% | 極高:提煉不可逆底層代碼 | 將短期波動誤認為永久改變 |
| **陷阱與不確定** | 10% | 高:精準定位認知盲區 | 泛泛而談「未來不確定」 |
**分析含義**:若生成過程中「過程」段字數超過 40%,說明檔案已退化為新聞報導;若「4 永久改變」字數少於 10%,說明分析未穿透表層。此時必須強制重寫。
## 交叉引用規範
交叉引用不是友情連結,而是分析邏輯的閉環證明。
### 引用語法與語義
1. **向上引用(連結框架/路由)**:必須引用 `routing/by_event.md` 以確認自身標籤;必須引用 `constitution/writing_principles.md` 以確認措辭合規。這是存在性證明。
2. **平行引用(連結其他事件)**:當事件過程涉及歷史先例時,必須引用。語義需標明「用於對照」或「用於交叉驗證」。例如:2023年4月軍演必須引用2022年8月軍演,並標明「用於驗證軍演常態化趨勢」。
3. **向下引用(連結行為體/結構)**:必須引用涉及的行為體檔案或結構檔案。語義需標明「提供脈絡層」。例如:影響段提及台灣內部政治,必須引用 `domestic/party_system.md`。
### 反向驗證機制
當事件檔案寫入完成後,寫入者必須檢查被引用的檔案(如行為體檔案)是否已包含對該事件的索引。若未包含,需在目標檔案中補充。這確保了知識庫的圖譜完整性。
## 措辭規範:避免立場化
這是知識庫最核心的品質紅線。事件檔案必須遵循 `constitution/writing_principles.md` 的總規範,並在事件描述中嚴格執行雙層語境分離。
### 引用層 vs 分析層
| 層級 | 定義 | 允許的措辭 | 禁止的措辭 |
| :--- | :--- | :--- | :--- |
| **引用層** | 轉述行為體的宣告、法律文本、政策文件 | 保留原文用詞,如「一個中國原則」(北京語境)、「主權獨立」(台北語境) | 將引用層詞彙不加引號地作為分析結論使用 |
| **分析層** | 描述客觀結構、因果機制、互動博弈 | 使用最中性語言,如「北京當局」、「台北政府」、「華盛頓」、「海峽中線」 | 「中國侵略」、「台灣挑釁」、「大陸打壓」、「我國」 |
### 破除常見簡化
**在分析層使用「中美」或「兩岸」作為單一主語是錯誤的。** 例如「兩岸關係因此惡化」是低密度且立場模糊的表述。正確的分析層表述應拆解為行為體:「北京對台北的軍事威懾梯度上升,台北對北京的經濟脫鉤意願增強」。這不僅避免了立場化,更強迫分析者看清內部的不對稱性。
### 主權與稱謂的處理原則
在描述涉及主權爭議的事件時,必須呈現多方視角,並明示「此為某方詮釋」。
- **範例**:關於 2022 年軍演的合法性,應表述為:「北京當局宣稱軍演依據國內法行使主權,台北政府與華盛頓則認定其違反國際法與台海現狀。」
- **禁止**:「軍演是非法的」或「軍演是正當的」。
## 活檔案 vs 靜態檔案的區分
事件檔案具有生命週期,必須在 header 中以 `status` 欄位標註。
### 靜態檔案
- **定義**:事件已完全結束,其影響已沉澱為結構,短期內不會有新的決策節點改變其性質。
- **特徵**:「4 永久改變」已清晰可見,無需頻繁更新。
- **範例**:`events/key_events/event_sunflower_2014.md`。太陽花學運已結束,其對兩岸協議監督條例的影響已固化。
- **更新頻率**:每年覆盤一次,或當相關解密檔案出現時更新。
### 活檔案
- **定義**:事件仍處於發酵期,或其引發的結構性後果仍在快速演化中,新的決策節點不斷湧現。
- **特徵**:「4 永久改變」可能僅能識別出 2-3 個,其餘為「潛在改變」,需持續追蹤。
- **範例**:2028 選舉週期相關事件,或 2022 年裴洛西訪台引發的軍事新常態(至今仍在演化出如「聯合利劍」系列新行動)。
- **更新頻率**:每當有重大連動事件發生時,必須回頭更新此檔案的「影響」與「4 永久改變」段。
**分析含義**:模型在調用活檔案時,必須對其中的結論加權時間衰減因子。越早的結論,在當前分析中的權重越低;而靜態檔案的結論則視為常數。
## 與其他檔案類型的差異
知識庫是一個多層生態系統,事件檔案是其中的「實例」,必須與其他類型明確區分。
| 檔案類型 | 核心功能 | 時間維度 | 與事件檔案的關係 |
| :--- | :--- | :--- | :--- |
| **事件檔案** | 記錄結構破裂口的細節與後果 | 微觀至中觀(天/月/年) | 是其他檔案的數據源與驗證點 |
| **行為體檔案** | 記錄決策者的偏好、約束與內部結構 | 中觀至宏觀(年/十年) | 解釋事件中的「為何如此決策」 |
| **框架檔案** | 提供分析工具與分類標準 | 超時間(邏輯結構) | 提供事件檔案的寫作規範與分析視角 |
| **時間線檔案** | 記錄低烈度常態事件的序列 | 宏觀(長時段) | 吸收未達到建檔門檻的日常事件 |
**破除常見簡化**:**把「事件檔案」當作「行為體檔案」的附屬品是錯誤的。** 雖然行為體驅動了事件,但事件一旦發生,就會反過來重塑行為體的約束條件。事件檔案具有獨立的分析價值,它是驗證行為體檔案中「偏好」與「紅線」是否準確的試金石。
## 範本案例解析
為了使規範可操作,以下選取兩個關鍵事件,展示規範如何落地。
### 範本一:2022年裴洛西訪台 (`events/key_events/event_pelosi_visit_2022.md`)
此事件是典型的 `[DIP] + [MIL] + [L3]` 複合型高烈度事件。
- **一句話濃縮的提煉**:不能只寫「裴洛西訪台」,必須提煉出「打破慣例」與「軍演常態化」的因果鏈。
- **過程的回合制**:應分為「訪問前(威脅與試探)」、「訪問中(象徵性展示)」、「訪問後(軍事反制與法律戰升級)」三個回合,而非按8月2日、8月3日羅列。
- **4 永久改變的挖掘**:這是最考驗分析深度的部分。初級分析只能看到「軍演」,深度分析必須提煉出「海峽中線默契的消亡」與「和平統一優先性的實質降級」。這兩點是物理與法律層面的不可逆變化。
### 範本二:2014年太陽花學運 (`events/key_events/event_sunflower_2014.md`)
此事件是典型的 `[POL] + [ELEC] + [L4]` 國內政治結構性事件。
- **背景的結構性視角**:不能只寫「服貿協議引發爭議」,必須揭示「兩岸經貿整合速度與台灣國內分配正義的結構性矛盾」,以及「代議民主失靈(30秒通過委員會)」的近因。
- **影響的多維度拆解**:不僅是佔領立法院的物理影響,更核心的是「兩岸協議監督條例」的法制化,實質凍結了後續兩岸經貿制度化進程。
- **4 永久改變的提煉**:1. 兩岸經貿制度化進程的永久性凍結;2. 台灣青年世代政治參與範式的轉變(從代議轉向直接行動);3. 民進黨執政路線的底定(向青年世代與反中板塊靠攏);4. 北京對台經濟讓利紅利政治轉化效率的永久性質疑。
## 關聯分析
本規範並非孤立存在,其有效性依賴於以下關聯:
1. **`constitution/writing_principles.md`** —— 提供總寫作規範與措辭紅線。本規範中的「措辭規範」段落是其具體化延伸,用於交叉驗證事件檔案是否合規。
2. **`routing/by_event.md`** —— 提供事件分類標籤。本規範要求在 header 中填寫 trigger_label,必須依賴此路由檔案的標籤字典。
3. **`events/key_events/event_pelosi_visit_2022.md`** —— 作為高烈度外交/軍事複合事件的範本。用於對照檢查新寫檔案的結構完整度與分析深度。
4. **`events/key_events/event_sunflower_2014.md`** —— 作為國內政治/社會運動事件的範本。用於對照檢查「4 永久改變」在非軍事領域的提煉方法。
5. **`domestic/party_system.md`** —— 提供台灣政黨板塊的底層結構。當事件檔案分析國內政治影響時,必須與此檔案進行交叉驗證,確保結論不違背既有板塊結構。
## 常見的分析陷阱
在運用本規範生成或分析事件檔案時,分析者最易陷入以下誤區:
1. **倒果為因的回溯性偏誤**:在撰寫「4 永久改變」時,將事件後數年才發生的變化,強行歸因於該事件。例如,將2024年選舉結果完全歸因於2022年軍演。**修正**:永久改變必須是事件直接觸發的系統代碼修改,而非長期趨勢的必然結果。
2. **高估短期衝擊,低估長期結構位移**:在「影響」段中花大量筆墨描述股市跌了多少點、輿論多憤怒,卻在「4 永久改變」中無話可說。**修正**:情緒反應與市場波動是噪音,制度變遷與默契消亡才是信號。
3. **將行為體意圖等同於結構結果**:在「過程」與「影響」中,過度依賴行為體的官方宣告,將「威懾意圖」直接寫成「威懾成功」。**修正**:必須區分意圖與結果,威懾是否成功需看對手後續行為是否改變。
4. **單一因果鏈的潔癖**:試圖為複雜事件找到一個唯一的原因或影響。例如,認為裴洛西訪台僅僅是因為美國國內政治。**修正**:政治事件必然是多因多果,背景段必須呈現結構性遠因與觸發性近因的疊加。
5. **忽視沉默的行為體**:在美中台三角中,只關注發聲的兩方,忽略保持沉默但行動的一方。例如,2022年危機中台北的戰略克制,本身就是重要的結構信號,不應因為聲音小而忽略。
## 仍不確定的部分
本規範在應用於以下情境時,仍存在不確定性:
1. **低烈度長尾事件的門檻判定**:對於如「農產品禁令」或「無人機擾台」等低烈度但高頻次事件,究竟應獨立建檔,還是併入時間線檔案,目前的標準仍顯模糊,需依賴分析者的主觀判斷。
2. **「永久改變」的驗證週期**:宣告一個變化為「永久」需要時間檢驗,但在活檔案中必須提前預判。預判的錯誤率目前缺乏系統性評估。
3. **多事件疊加的歸因分離**:當兩個重大事件同時發生(如選舉年疊加外交危機),其影響難以在事件檔案中乾淨剝離,交叉引用時容易出現歸因重複。
## 與其他文件的關係
- `constitution/writing_principles.md` —— 總寫作規範,本檔案的措辭規範受其約束
- `routing/by_event.md` —— 事件路由索引,本檔案依賴其標籤系統進行分類
- `events/key_events/event_pelosi_visit_2022.md` —— 外交/軍事件範本,展示本規範的實體落地
- `events/key_events/event_sunflower_2014.md` —— 國內政治事件範本,展示本規範的實體落地
事件檔案不是新聞稿的歸檔,而是將時間序列上的破裂口轉譯為結構性分析單元的規格化工具。
last_updated: 2025-03-16
@@ -0,0 +1,294 @@
# 國台辦新聞發布資料指引
---
**類型**: 分析框架(解釋、分類與實務操作工具)
**當前角色**: 資料庫檢索與信號解碼方法論指引
**何時調用此檔案**: 需要檢索、追蹤或解讀國台辦例行新聞發布會、緊急聲明或官網文本時;需要區分國台辦發布內容的「日常基線」與「戰略位移」時;需要利用國台辦公開資料庫進行歷史比对與詞頻分析時;評估北京對台輿論戰與法理鬥爭的節奏時
---
## 一句話濃縮
國台辦新聞發布會不是即興劇場,而是高度劇本化的信號發射器;解讀的關鍵不是聽發言人『說了什麼』,而是比對措辭模板的『變異』、發布時機的『違常』與官網歸檔的『缺席』,從高頻的日常噪音中過濾出低頻的戰略位移。
---
## 這個框架的目的
本框架是**解釋、分類與實務操作工具**。
國台辦新聞發布會是北京對台政策最高頻、最公開、最制度化的信號輸出端口。然而,高頻率伴隨高噪音——大部分發布內容是重複的制式回應,其功能是維持政治在場感與體制績效,而非傳遞新信息。將國台辦的每一次發言都視為同等權重的政策信號,必然導致信號疲勞與誤判。
本框架解決的核心問題是:
> 給定國台辦的新聞發布文本或發布行為,如何系統性地將其分類、解碼,並從中提取具有戰略價值的信號?如何利用國台辦官網的公開資料庫進行結構化的歷史回溯與交叉驗證?
核心主張:**國台辦發布的信號價值,與其偏離「例行模板」的程度成正比。模板的變異、發布節奏的打破、以及文本在官網歸檔時的微調,才是真正的分析富礦。**
---
## 框架的核心結構一:制度安排與發布頻率的基線意義
### 這是什麼事實?
國台辦例行新聞發布會通常安排在每週三上午10點舉行,原則上每兩週一次(遇重大節日或特殊情況可能調整)。發布會由新聞局局長(現任陳斌華)或副局長(如朱鳳蓮)主持,地點在國台辦新聞發布廳。流程通常為:發言人開場宣讀聲明或政策→開放兩岸及外媒記者提問→發言人回應。
### 這個事實如何嵌入更大的政治結構?
這種高度規律的「雙週節奏」,是北京對台工作「制度化與常態化」的表演。在兩岸官方溝通管道(陸委會與國台辦)中斷的當下,例行記者會成為北京單向向台灣及國際社會傳遞意志的唯一常設窗口。然而,規律性也帶來了「績效壓力」——對台系統必須在每兩週的固定節點上產出足夠的「強硬姿態」,以向內部證明對台工作沒有軟化,這導致大量無實質政策支撐的「口頭威懾」被生產出來。
**結構性命題**:例行發布會的「儀式性」大於「信息性」。其核心功能不是傳遞新政策,而是維持對台工作的「在場感」與體制運轉的「績效可見性」。這種績效驅動導致國台辦的措辭存在內在的「通貨膨脹」傾向——為了在每次記者會上都顯得足夠強硬,形容詞與威脅詞必須不斷升級,直到升級空間耗盡。
### 分析時如何用這個事實?
1. **建立「時間錨點」**:分析任何國台辦表態前,先確認其是否為例行記者會。例行記者會上的強硬表態,其信號權重低於非例行時機的表態。
2. **識別「缺席」與「暫停」**:當例行記者會被無故取消、推遲,或發言人迴避特定話題時,這通常是高層正在進行政策辯論、尚未定調的標誌。例如,2022年8月裴洛西訪台期間,國台辦未按慣例召開記者會,而是以發布《台灣問題與新時代中國統一事業》白皮書替代,這標誌著回應層級已超越國台辦的日常權限。
3. **測量「績效通脹」**:當發現國台辦在無重大事件的例行記者會上,使用極端措辭(如「死路一條」、「不惜一戰」),這通常不是政策升級,而是對內績效展示的修辭通脹,應降級評估其信號價值。
---
## 框架的核心結構二:發布內容的類型學
國台辦的發布內容可分為四種理想型。不同類型的信號強度、不可逆性與受眾邏輯截然不同。
| 內容類型 | 特徵與識別標準 | 信號強度與不可逆性 | 結構功能 | 範例 |
|---|---|---|---|---|
| **政策發布** | 宣布具約束力的法規、行政措施、清單。通常有書面文件伴隨。 | 極高(不可逆性最強) | 將政治意志制度化,提供執法依據 | 發布《懲治台獨22條意見》(2024)、宣布暫停ECFA部分產品關稅減讓 |
| **回應表態** | 針對記者提問的特定事件(如美台軍售、賴清德講話)進行定性與譴責。 | 中(具彈性,可隨事件降溫而淡化) | 劃設紅線,測試輿論反應,威懾對手 | 回應美對台軍售:「堅決反對」、「將採取一切必要措施」 |
| **譴責/批判** | 對台執政黨或特定人物的常規化攻擊,無具體對應事件。 | 低(日常基線噪音) | 維持敵我界限,內部動員,績效展示 | 例行指責民進黨「倚外謀獨」、「欺騙民眾」 |
| **邀請/統戰** | 向台灣在野黨、民間團體或青年發出交流邀請,或宣布惠台措施。 | 中低(具可逆性,隨時可收回) | 分化台灣內部,塑造「兩岸一家親」意象 | 邀請台灣青年參訪、宣布恢復部分兩岸航點 |
### 分析含義:類型組合的判讀
單一類型的發布容易誤導,必須觀察類型組合。
1. **「政策發布 + 譴責/批判」**:這是典型的「硬威懾」組合。政策提供牙齒,譴責提供合法性論述。分析時應聚焦政策的具體條文與執行機制,而非譴責的修辭。
2. **「邀請/統戰 + 回應表態」**:這是「軟硬兩手」的並行。對台執政黨強硬(回應表態),對台在野或民間溫和(邀請)。分析時需交叉引用 `narratives/china_side/unification_process_discourse.md`,確認這種分化策略是否服務於當前「以融促統」的上位論述。
3. **「純譴責/批判」(無政策、無邀請)**:這是「自動駕駛模式」的典型特徵。官僚系統在缺乏高層新指令時,按慣性產出的空轉信號,不具備戰略分析價值。
**破除常見簡化**:把國台辦的「譴責/批判」等同於「政策意圖」是常見錯誤。國台辦的譴責多數是滯後的反應,而非前瞻的預告。只有當譴責伴隨「政策發布」時,才具備實質威脅。
---
## 框架的核心結構三:措辭模板的「信號性」與「配重體系」
國台辦的發言是高度模板化的。解讀的核心不是理解單一詞彙,而是識別模板的「變異」與「配重」的偏移。詳細詞彙權重請嚴格參照 `playbooks/analyzing_beijing_signal.md`,此處提供的是針對國台辦發布會的特定操作邏輯。
### 模板的構成與變異
國台辦對特定事件(如美台互動、台灣大選)的回應,通常遵循固定模板:**定性(是什麼性質)→ 歸責(誰的錯)→ 威脅(會有什麼後果)→ 立場(我們的主張)**。
**識別技巧**:
- **定性升級**:從「挑釁行徑」升級為「嚴重挑釁」,信號價值低(修辭通脹);從「破壞台海和平」升級為「觸碰一中原則底線」,信號價值高(紅線位移)。
- **威脅具體化**:從「將採取一切必要措施」升級為「將依據《反分裂國家法》採取行動」,信號價值高(引入法律戰工具)。
- **立場收窄**:從「寄希望於台灣當局也寄希望於台灣人民」變為僅提「寄希望於台灣人民」,信號價值高(關閉對話窗口)。
### 配重體系的偏移
北京的對台論述常以對偶形式出現,國台辦的發布是觀察配重偏移的最佳窗口。
| 配重組合 | 日常基線(平衡) | 升級信號(向右傾斜) | 緩和信號(向左傾斜) |
|---|---|---|---|
| 和平統一 ↔ 不承諾放棄武力 | 兩者並列,前者為主 | 省略「和平」,或將「武力/反獨」前置為主句 | 僅提「和平發展/融合」,省略武力 |
| 反對台獨 ↔ 懲治台獨 | 使用「反對」、「譴責」 | 使用「懲治」、「依法追責」、「嚴懲」 | 回歸「反對」,淡化法律語言 |
| 九二共識 ↔ 一中原則 | 使用「九二共識」(保留模糊空間) | 使用「一中原則」(強調國際法與國內法剛性) | 恢復「九二共識」表述 |
**分析含義**:在國台辦記者會上,發言人通常會唸準備好的講稿。觀察配重偏移的最佳時機是**開場宣讀的聲明**,而非問答環節。問答環節的措辭可能因記者提問的引導而臨時偏移,信號價值較低;開場聲明是經過內部審核的定調文本,配重偏移代表高層意志。
---
## 框架的核心結構四:發言人個人風格的「可觀察性」與邊界
### 這是什麼事實?
國台辦歷任發言人具有鮮明的個人風格:
- **馬曉光(2013-2022)**:老派官僚風格,語速平穩,偶有情緒化表現(如翻白眼、語塞),慣用傳統對台統戰語彙。
- **朱鳳蓮(2019-至今)**:語速極快,攻擊性強,擅長使用網路化語言與反問句(如「民進黨當局還要欺騙台灣民眾多久?」),帶有明顯的「戰狼」色彩。
- **陳斌華(2023-至今)**:曾任新華社港台部記者,熟悉台灣媒體生態。風格趨向「軟硬兼施」,頻繁使用閩南語唸詩或軟性敘事開場,隨後轉入硬性譴責。
### 這個事實如何嵌入更大的政治結構?
發言人的風格選擇,反映了北京對台輿論戰的策略演變。從馬的「官僚宣讀」到朱的「網路戰狼」,再到陳的「軟包裝硬威懾」,這不是發言人個人的即興發揮,而是對台系統為了適應台灣媒體環境與受眾偏好而進行的「產品迭代」。陳斌華的閩南語與詩詞,是為了穿透台灣基層的「軟性包裝」,其內核仍是中共中央台辦的硬性指令。
**洞察**:發言人的風格是受控的表演,但風格的切換本身暴露了機構的內在矛盾。當陳斌華前一分鐘用閩南語吟誦「兩岸一家親」,下一分鐘嚴厲宣讀「懲治台獨」法規時,這種精神分裂式的表演,直觀呈現了國台辦同時扮演「慈善家」與「警察」的結構性困境——軟性統戰的吸引力,永遠被硬性威懾的排斥力所抵消。
### 分析時如何用這個事實?
1. **剝離風格,直視文本**:不要被發言人的語氣、反問句或閩南語干擾。分析時只提取核心動詞與名詞。無論朱鳳蓮的語氣多麼激烈,如果她使用的仍是「堅決反對」而非「依法懲治」,信號強度就沒有升級。
2. **觀察「脫稿」時刻**:發言人脫離講稿的補充說明,有時是個人失誤,有時是刻意為之的「非正式信號」。判斷標準:如果脫稿內容與講稿基調一致,是強調;如果脫稿內容降溫或模糊化了講稿的強硬措辭,可能是發言人試圖保留迴旋空間。
3. **識別「受眾錯位」**:陳斌華的閩南語主要針對台灣民眾(統戰),但其法律威脅主要針對台灣執政黨(鬥爭)。分析時必須將同一場發布會的內容按受眾拆解,避免將對內(大陸民眾)的強硬表態誤判為對台灣的即刻行動預告。
---
## 框架的核心結構五:重大事件後的「緊急發布」與多部門協調
### 這是什麼事實?
當發生重大突發事件(如美高層訪台、台灣大選結果出爐、台方領導人發表挑釁講話)時,國台辦會打破「每兩週一次」的例行節奏,以三種方式進行緊急發布:
1. **發言人書面聲明**:通過新華社或國台辦官網即時發布,通常篇幅短、措辭極端。
2. **臨時專場記者會**:罕見,僅在極端事件(如2022年裴洛西訪台)時舉行。
3. **官網「回應」專欄**:不召開記者會,僅在官網以「國台辦發言人答記者問」形式發布。
### 這個事實如何嵌入更大的政治結構?
緊急發布不是國台辦的獨角戲,而是北京多部門協同反應的對台組件。觀察緊急發布的關鍵,在於其與外交部、國防部、新華社的「時間差」與「措辭差」。
**洞察:多部門協同的「時間差」是觀察決策層級的窗口。**
- **國台辦先發聲,外交部後跟進**:表示事件首先被定性為「兩岸內部事務」,北京試圖將其限制在兩岸框架內,避免國際化。
- **外交部先發聲,國台辦後跟進**:表示事件首先被定性為「中美博弈」,對台施壓是對美反應的附屬品。交叉引用 `narratives/china_side/external_interference_discourse.md`,此時國台辦的聲明必然強調「外部干涉」與「倚外謀獨」的連結。
- **國防部先發聲,國台辦僅作背書**:表示軍事回應主導,國台辦降格為軍事行動的合法性解說員。
### 分析時如何用這個事實?
1. **繪製「反應時間軸」**:記錄事件發生後,各部門發聲的精確時間(精確到分鐘)。這比分析措辭更能揭示決策權重。
2. **識別「降級處理」**:如果預期應有緊急發布(如台方領導人過境美國),但國台辦僅以官網「答記者問」回應,未召開記者會,這可能是刻意降溫,避免將事件炒作為高強度對抗。
3. **比對「口徑差異」**:檢查國台辦緊急聲明與新華社社論的措辭差異。如果新華社使用更極端的修辭,說明宣傳系統在對內動員,而國台辦的對外信號相對克制。
---
## 框架的核心結構六:國台辦官網的「資料庫價值」與檢索策略
### 這是什麼事實?
國台辦官網(www.gwytb.gov.cn)設有「新聞發布」專欄,完整歸檔了歷次例行記者會的文字實錄、發言人表態(書面聲明與答記者問)以及重要政策文件。該資料庫是開放的,支持站內搜索。
### 這個事實如何嵌入更大的政治結構?
官網歸檔不是被動的記錄,而是經過審查的「歷史定本」。發布會上的口誤、發言人的脫稿內容,在文字實錄中可能被無聲修正或刪除。官網呈現的是北京希望後人看到的「標準歷史」。
**洞察:官網歸檔的「時序篡改」與「缺席」是高價值信號。**
- **時序篡改**:有時,一篇已發布的聲明會在數小時後被替換為修改版(如調整措辭軟硬度)。比對快照(如透過 Wayback Machine)可發現這種微調,這反映了高層對定調的二次修正。
- **缺席**:如果某次例行記者會的視頻回放或文字實錄延遲發布,或部分敏感問答被刪除,這通常是該段落偏離了高層滿意的口徑。
### 分析時如何用這個事實?(實務檢索策略)
1. **歷史同日比对**:
- **操作**:檢索過去3-5年同一月份或同一政治節點(如台灣大選後、雙十講話後)的國台辦回應。
- **目的**:建立「歷史基線」,識別當前回應是升級還是降級。
- **語法**:在站內搜索特定詞組(如「嚴重關切」、「不惜一戰」),按時間排序觀察其出現頻率的變化。
2. **詞頻追蹤**:
- **操作**:追蹤特定新詞(如「台獨頑固分子」、「融合發展示範區」)在官網首次出現的時間,及其後續在記者會中被重複的頻率。
- **目的**:識別「新詞植入」到「常態化」的週期,驗證政策從測試到定型的過程。
3. **問答配對分析**:
- **操作**:提取文字實錄中的「記者提問」與「發言人回應」配對。
- **目的**:識別北京允許被問及的問題邊界。如果某類問題(如軍演細節、特定台獨名單)長期無人提問,或發言人以「不回應假設性問題」拒答,這劃定了當前輿論管控的紅線。
---
## 實務應用:追蹤與分析的操作步驟清單
當需要追蹤國台辦發布會或分析其歷史文本時,請按以下步驟執行。
### 步驟一:定位發布性質(事實層)
- [ ] **確認發布類型**:是例行記者會、臨時專場、書面聲明,還是官網「答記者問」?
- [ ] **確認時機**:是否鄰近台灣/美國政治節點?是否為突發事件後的緊急反應?
- [ ] **確認多部門協同狀態**:外交部、國防部是否同步發聲?時間差為何?
- [ ] **輸出**:此發布的「類型-時機-協同」座標。
### 步驟二:提取文本變異(對照層)
- [ ] **調取歷史基線**:在國台辦官網檢索去年同期或上一次同類事件的發言人表態。
- [ ] **識別模板變異**:對照「定性→歸責→威脅→立場」模板,標記哪個環節發生了升級或降級。
- [ ] **識別配重偏移**:對照 `playbooks/analyzing_beijing_signal.md` 的配重體系,標記核心詞彙的增刪與權重變化。
- [ ] **輸出**:文本變異清單(什麼變了,什麼沒變,偏移方向為何)。
### 步驟三:驗證政策與行動錨點(驗證層)
- [ ] **檢索政策層**:過去30天內,是否有對應的法規、清單、經貿措施出台?
- [ ] **檢索行動層**:過去14天內,是否有異常的軍事、海警、外交動作?
- [ ] **輸出**:政策與行動的配合狀態(有跟進 / 無跟進 / 矛盾)。
### 步驟四:受眾與風格拆解(意圖層)
- [ ] **拆解受眾**:將發布內容拆分為對台執政黨(威懾)、對台在野/民眾(統戰)、對美(劃線)、對內(動員)四個維度。
- [ ] **剝離風格**:剔除發言人的修辭包裝(閩南語、反問句、情緒化表達),提取核心指令。
- [ ] **輸出**:信號的首要受眾與剝離風格後的核心意圖。
### 步驟五:官網歸檔比對(反思層)
- [ ] **比對視頻與文字實錄**:檢查文字實錄是否刪減了發言人的脫稿內容或口誤。
- [ ] **檢索歷史快照**:檢查聲明發布後是否有二次修改的痕跡。
- [ ] **輸出**:文本的「標準化」程度評估,以及被掩蓋的微弱信號。
### 步驟六:綜合校準(校準層)
- [ ] **計算信號強度**:基於模板變異程度 × 多層一致性 × 不可逆性。
- [ ] **標記不確定性**:此判斷可能出錯的原因(如:是否為官僚自動駕駛?是否為內部博弈外溢?)。
- [ ] **輸出**:校準後的信號強度與方向判斷,以及不確定性聲明。
---
## 關聯分析
### 關聯一:與「國台辦機構檔案」的連結
本框架是 `entities/foreign_actors/taiwan_affairs_office.md` 的操作延伸。機構檔案揭示了國台辦「一套人馬兩塊牌子」的結構性張力,本框架則提供了觀察這種張力在公開文本中如何表現的具體方法。當國台辦在發布會上表現出行政語言與政治鬥爭語言的撕裂時,可回溯機構檔案驗證其體制根源。功能:提供事實層與結構對照。
### 關聯二:與「北京信號解讀方法」的連結
本框架是 `playbooks/analyzing_beijing_signal.md` 在國台辦這一特定信號源的落地應用。信號解讀方法提供了「論述-政策-行動」三層穿透的宏觀框架,本框架則提供了針對國台辦高頻文本的微觀解碼技巧(如模板變異、發言人風格剝離、官網歸檔比對)。功能:提供分析框架與操作方法。
### 關聯三:與「外部干涉論述」的連結
國台辦在回應美台互動時,必然調用 `narratives/china_side/external_interference_discourse.md` 中的論述模板。分析國台辦記者會的問答環節,可以觀察北京如何將具體的軍售或訪問事件,嵌入「外部干涉—倚外謀獨—破壞台海和平」的因果鏈條中。功能:提供論述層脈絡與交叉驗證。
### 關聯四:與「統一進程論述」的連結
國台辦的「邀請/統戰」類發布,是 `narratives/china_side/unification_process_discourse.md` 的日常實踐。透過追蹤國台辦關於「兩岸融合發展」、「民主協商」的發布頻率與具體內容,可以評估北京「以融促統」戰略的實際推進節奏,而非僅依賴高層講話。功能:提供事實層與長期脈絡驗證。
### 關聯五:與台灣媒體生態的連結
國台辦記者會的問答環節,是觀察北京如何理解並試圖影響台灣媒體的窗口。發言人對特定台灣媒體(如中天、TVBS vs. 自由時報)的點名回應或迴避,反映了北京對台灣媒體光譜的認知與操作。功能:提供受眾分析與對照。
### 關聯六:與大陸對內宣傳的連結
國台辦記者會的強硬表態,經常被《人民日報》、《環球時報》等官媒二次加工後對內發布。比對國台辦原文與官媒加工後的標題,可以識別北京對內動員的強度。如果官媒標題的措辭遠比國台辦原文極端,說明內部動員需求高。功能:提供多層次受眾分析與交叉驗證。
---
## 常見的分析陷阱
### 陷阱一:將「高頻譴責」等同於「政策升級」
國台辦的例行譴責是維持績效的日常噪音。把每次「堅決反對」或「強烈譴責」都視為政策升級的證據,會導致信號疲勞與誤判。只有當譴責伴隨新的法律工具或行政措施時,才具備升級意義。
### 陷阱二:過度解讀發言人的「情緒化表現」
發言人的語氣加重、翻白眼或反問句,多是表演性質,服務於內部績效或對台心理戰。將其視為發言人個人憤怒或政策即將轉向的證據,是將表演誤判為真實。
### 陷阱三:忽略官網歸檔的「過濾效應」
直接引用國台辦官網的文字實錄作為唯一事實來源,忽略了官網會無聲修正口誤或刪除敏感問答。這會導致分析基於「被過濾後的標準歷史」,遺漏了真正的微弱信號。
### 陷阱四:假設國台辦記者會是「即興問答」
記者會上的多數提問是經過安排或預審的,發言人的回答也是照本宣科。將問答環節視為真實的資訊交鋒,會高估其信息含量。真正的信號在開場聲明,而非問答。
### 陷阱五:將「書面聲明」與「記者會表態」視為同等權重
書面聲明(尤其是新華社授權發布的)通常代表更高層級的定調,而記者會上的口頭表態則包含較多的官僚慣性與彈性。將兩者混為一談,會導致信號強度校準失準。
### 陷阱六:忽略「缺席」的信號
當預期國台辦應對某事件表態卻保持沉默,或例行記者會無故取消時,將其視為「無事發生」。在高度劇本化的體制中,「不說話」比「說狠話」更需要高層授權,其信號價值極高。
---
## 仍不確定的部分
1. **記者會提問的預審機制**:我們無法確切知道有多少提問是記者自發的,有多少是國台辦新聞局預先安排的。這導致我們難以判斷問答環節的真實信息含量與「測試氣球」性質。
2. **文字實錄的審批流程**:官網文字實錄在發布前,是否需要經過研究局或更高層級的審批?審批的時間差與修改幅度,直接影響我們對「過濾效應」的評估,但目前缺乏內部流程資訊。
3. **發言人與研究局的互動邊界**:發言人在記者會上的脫稿補充,是個人臨場反應,還是與研究局官員在現場的眼神/耳機確認?這決定了脫稿內容的信號權重。
4. **官網歷史快照的完整性**:依賴 Wayback Machine 等工具捕捉官網的二次修改,存在盲區。我們無法確定未被捕捉到的修改有多少,因此可能低估了「時序篡改」的頻率。
5. **多部門協同的決策時滯**:當國台辦與外交部、國防部的發聲存在時間差時,我們能觀察到現象,但難以確定這是決策層級差異導致的必然延遲,還是部門間協調不暢的結果。
---
## 與其他文件的關係
- `entities/foreign_actors/taiwan_affairs_office.md` —— 機構檔案。提供國台辦的體制定位與內部結構,用於解釋發布會行為背後的組織邏輯。
- `playbooks/analyzing_beijing_signal.md` —— 信號分析框架。提供宏觀的三層穿透與配重體系方法論,本檔案是其針對國台辦文本的微觀操作手冊。
- `narratives/china_side/external_interference_discourse.md` —— 外部干涉論述。提供國台辦回應美台互動時的論述模板與因果鏈條,用於識別其歸責邏輯。
- `narratives/china_side/unification_process_discourse.md` —— 統一進程論述。提供國台辦發布「惠台/統戰」措施時的上位戰略脈絡,用於評估其長期意圖。
---
## 一句話濃縮
> 國台辦新聞發布會是高度劇本化的信號發射器,解讀的關鍵不在於發言人說了什麼,而在於比對模板的變異、時機的違常與歸檔的缺席,從高頻的績效噪音中過濾出低頻的戰略位移,並永遠警惕將修辭通脹誤判為政策升級。
---
last_updated: 2025-05-19
@@ -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,317 @@
# 电子战(EW)能力
---
**类型**: 机构/装备档案
**当前角色**: 台湾防卫体系的电磁频谱守门员 / 杀伤链存续的关键变量 / 美台军事技术协作的最深黑箱
**出生时间**: 1950年代(通信兵起源)/ 2017年(资通电军成军)/ 1990年代(专精电战装备引入)
**何时调用此档案**: 分析台湾指管通情(C4ISR)系统在战时的存活率;评估反舰/防空飞弹在电子干扰下的命中率;解读资通电军的组织功能与军种博弈;观察美台在电磁频谱领域的合作与限制;判断解放军「制电磁权」对台海战局的决定性影响;评估豪猪战略中「看不见的神经」是否脆弱
---
## 一句话浓缩
电子战能力是台湾「不对称防卫」的**隐形神经**——它不直接杀敌,却决定了反舰飞弹是「精准刺杀」还是「盲目挥舞」;它是资通电军与各军种在「网路战光环」与「传统电战真空」间的**组织张力场**,更是美台军事合作中「给装备但控核心」的**技术依附缩影**。
---
## 事实层:概念、组织与装备
### 电子战的基本概念与结构性命题
**事实内容**:
电子战(Electronic Warfare, EW)是争夺电磁频谱使用权的军事行动,核心分为三个子领域:
1. **电子攻击(EA)**:使用电磁能量(干扰、欺骗、反辐射武器)削弱或摧毁敌方战斗力。
2. **电子防护(EP)**:保护己方电磁频谱使用不受敌方攻击(如跳频、低截获率雷达)。
3. **电子支援(ES)**:截获、定位、识别敌方电磁辐射,为战术决策与电子攻击提供情报。
**脉络层**:
现代战争已从「机械化」转向「信息化」,电磁频谱是所有信息流的物理载体。没有频谱权,雷达是瞎子、飞弹是废铁、指管是断线。在台海场景中,解放军的首要目标不是歼灭有生力量,而是「致盲」与「断链」——即夺取制电磁权。
**结构性命题**:
**电子战是台湾防卫体系的「乘数」与「归零」因子。** 当台湾具备有效的电子防护与频谱管理时,雄风飞弹与天弓飞弹的威慑力得以乘数放大;当解放军取得制电磁权时,台湾的杀伤链将在开战数小时内归零,防卫体系退回各自为战的二战模式。分析台湾战力,若不将电子战作为前置变数,所有飞弹数量与舰艇吨位的计算都将失真。
### 台湾电子战部队:资通电军的组织与张力
**事实内容**:
- **资通电军指挥部**:2017年由原通信资讯指挥部、电展室等单位整编而成,直属国防部参谋本部。为台湾层级最高的资安、网路战与电子战统筹机构。
- **空军电战大队**:操作C-130HE电子战机(天干机)与各型电战荚舱,负责空中电磁压制与电子情报侦察。
- **海军电战大队**:配备舰载电子战系统(如SLQ-32),负责水面舰的电子防护与反反舰飞弹干扰。
- **陆军资电群**:负责地面通信保障与部分地面电子反制。
**脉络层**:
资通电军的成立,象征台湾将「资安、网路、电磁」视为统一作战域。然而,其实质运作存在严重的内部张力:
| 组织面向 | 网路战(Cyber) | 传统电子战(EW) | 产生的结构性张力 |
|---------|---------------|----------------|---------------|
| 政治可见度 | 极高(防制假讯息、认知战) | 极低(看不见的电磁波) | 资源倾斜:预算与政治关注集中于网路战,传统电磁频谱作战被边缘化 |
| 技术来源 | 软体为主,可自主开发迭代 | 硬体为主,高度依赖美方军售 | 速度落差:网路战可快速更新,电战硬体受制于军售期程与黑盒子 |
| 战术效果 | 非动能,延迟性,难以量化 | 动能辅助,即时性,决定飞弹命中率 | 评估失真:将网路战能力误认为整体电磁防卫能力 |
**分析如何用此事实**:
分析时,**绝不能将「资通电军战力」等同于「电子战战力」**。资通电军的强项在网路防御与认知战,但在传统电磁频谱的电子攻击(EA)与电子支援(ES)上,仍高度依赖空军与海军的专业大队。当政治人物宣称「资通电军已成军」时,必须追问:其电磁频谱压制能力是否同步提升?还是仅增加了键盘战士?
### 电子战装备:已知、推测与黑盒子
**事实内容**:
台湾的电子战装备因高度机密,多处于推测与零星披露状态,核心装备可分为三类:
1. **空中电战平台**:
- **C-130HE电子战机(天干机)**:全球仅台湾操作的特殊改型,具备电子干扰与电子情报搜集能力。数量极少(推测2-4架),机龄老旧,战时生存率极低。
- **F-16V电战荚舱**:搭配ALQ-131或更先进的ALQ-184/V等外挂荚舱,提供战机自我防护与伴随干扰能力。
- **鹰眼E-2K预警机**:虽非专职电战机,但其雷达与电子支援措施(ESM)是台湾空中电磁情报的关键节点。
2. **地面/舰载电战系统**:
- **舰载SLQ-32(V)2/3电子战系统**:配备于成功级、纪德级等主战舰艇,提供反飞弹干扰与电子支援。
- **陆基机动电战车**:中科院研发,用于干扰敌方无人机数据链与通信。
3. **诱饵与反制系统**:
- **AN/ALE-47干扰丝/热焰弹洒布器**:各型战机标准配备。
- **舰载干扰火箭(如Mk 36 SRBOC)**:水面舰反飞弹最后防线。
**脉络层**:
台湾的电子战装备结构呈现「防护强、攻击弱;旧式多、新式少」的特征。C-130HE是唯一的专职电子攻击平台,但其基于越战时代的干扰逻辑,面对解放军现代相位阵列雷达与跳频通信,效能存疑。F-16V的荚舱虽较先进,但属「自我防护」等级,无法执行区域性的防空压制(SEAD)。
**分析如何用此事实**:
评估台湾电子战能力时,必须区分「电子防护(EP)」与「电子攻击(EA)」。台湾拥有一定程度的EP能力(让己方飞弹不被干扰),但极度缺乏EA能力(压制敌方雷达与通信)。这意味着台湾在电磁频谱上本质是「被动挨打与防御」,缺乏「主动致盲对手」的手段。
### 与美方电子战合作:给装备,控核心
**事实内容**:
台湾的先进电子战装备几乎全数来自美方军售。近年关键合作包括:
- F-16V升级案中包含新型电战系统与ALQ-211(V)9整合式电战套件。
- 海军MK 36干扰火箭与SLQ-32技术支援。
- 非公开的电磁参数共享与频谱管理技术协助。
**脉络层**:
美台电子战合作是典型的「不对称依附」。美方出售硬体,但核心的「威胁参数库」(Threat Library,即识别敌方雷达与通信特征的资料库)与「干扰技术码」(Techniques Generator,即针对特定威胁的干扰演算法)通常由美方控管,以加密黑盒子形式提供。
这种模式产生双重矛盾:
1. **战力依赖**:台湾的电子战系统必须依赖美方定期更新参数库,以应对解放军新型雷达。若战时美方断线或无法即时更新,系统将迅速失效。
2. **技术黑箱**:详见 `entities/organizations/chung_shan_institute.md`。中科院虽负责在地整合,但无法触碰底层逻辑,导致无法根据实战反馈即时修改干扰参数。
**分析如何用此事实**:
分析美台军事关系时,电子战是比飞弹更深的「黑箱」。飞弹买来就能打,但电战系统买来只是空壳,灵魂(参数库)在美方手里。这意味台湾的电磁频谱防卫权,实质上有一部分由华盛顿掌握。若美方评估局势不宜升高冲突,限制参数更新,台湾的电子防护将出现致命空窗。
### 对解放军电子战能力的应对
**事实内容**:
解放军电子战体系已从俄系辅助角色,转型为与美军对标的独立作战域。核心威胁包括:
- **歼-16D电战机**:专职伴随干扰,配备大型电战荚舱,压制防空雷达。
- **运-8/运-9电战机**:大型电子战机,执行远距离区域干扰与通信压制。
- **舰载电子战系统**:055型驱逐舰等配备整合式电子战系统,具备强大的舰队防护与干扰能力。
- **无人机蜂群电战**:利用低成本无人机携带干扰源,实施分布式饱和干扰。
**脉络层**:
面对解放军的「制电磁权」战略,台湾的应对呈现「分散、机动、跳频」的逻辑:
1. **战力保存**:将雷达与指挥车机动化,避免被定点压制。
2. **频率捷变**:雷达与通信采用跳频技术,增加敌方锁定与干扰难度。
3. **诱饵部署**:部署角反射器与假热源,消耗敌方反辐射武器。
**分析如何用此事实**:
台湾的应对本质是「防守反击」,试图在解放军的第一波电磁压制下存活。然而,**破除常见简化**:**把「跳频」与「机动」视为解决电子战劣势的万灵丹是危险的。** 现代电子战已从「点对点干扰」进化为「系统对系统」的频谱压制。解放军的运-9电战机可在战区外实施广域阻塞式干扰,跳频通信在整段频谱被淹没时同样无法运作。台湾的防护措施能增加敌方成本,但无法根本扭转电磁劣势。
### 在豪猪战略中的位置:看不见的神经
**事实内容**:
豪猪战略强调「大量、小型、致命、机动」的不对称战力。反舰飞弹与防空飞弹是刺,但电子战是让刺能命中目标的神经。
**脉络层**:
详见 `narratives/us_taiwan/porcupine_strategy.md`。豪猪战略的隐含假设是:飞弹发射后能看见目标并命中。但在强电磁干扰环境下,雷达被致盲、数据链被切断,飞弹车虽存活,却沦为无法导引的废铁。电子战是豪猪战略的「阿基里斯腱」——它最关键,却最不被政治人物与公众看见,也最难取得预算。
**分析如何用此事实**:
评估豪猪战略的可行性时,必须加入「电磁环境」变数。若在无干扰环境下,台湾的飞弹海极具威慑力;若在解放军取得制电磁权的环境下,飞弹命中率可能骤降至个位数。分析者应将电子战能力视为豪猪战略的「折价系数」。
### 中科院相关研发:在地整合的极限
**事实内容**:
详见 `entities/organizations/chung_shan_institute.md`。中科院资讯通信研究所是台湾电子战研发的核心。主要专案包括:
- **新型外挂电战荚舱**:为IDF经国号战机研发,提升自我防护能力。
- **无人机电战套件**:为锐鸢等无人机研发小型干扰源。
- **网路战与认知战平台**:资通电军的核心软体工具。
**脉络层**:
中科院在电子战领域的角色,是「黑盒子的在地封装者」。由于无法取得美方最核心的干扰演算法,中科院的突破点在于「软体定义无线电」(SDR)与「认知电子战」(利用AI动态调整干扰与抗干扰参数)。然而,硬体性能(如发射功率、频宽)受限于台湾半导体与微波零组件的制造能力,与美军现役装备仍有代差。
**分析如何用此事实**:
不要期待中科院能研发出超越美制ALQ-99或NGJ的电战系统。中科院的真正价值在于:当美方因政治因素无法提供特定电战参数时,中科院能否提供「堪用」的替代方案。这是台湾战时的保底手段,但效能不可与美军同日而语。
### 实战准备状态:看不见的脆弱
**事实内容**:
台湾缺乏电子战实弹演练的客观条件:
- 电磁辐射极易被解放军截收,大规模开启电战系统等于暴露参数。
- 岛内空间狭小,强功率干扰会严重影响民用通信,无法实施广域实兵演练。
- 依赖电脑兵棋推演与模拟器,但模拟器的参数库同样受美方限制。
**脉络层**:
「看不见」是电子战最大的实战障碍。飞弹试射有弹着点可验证,电子战干扰是否有效,只有在实战或高度真实的联合演训中才能确认。台湾的电子战部队长期处于「有装备、少验证」的状态,操作人员对「干扰是否真的压制了敌方雷达」缺乏直觉与信心。
**分析如何用此事实**:
评估战备时,必须对台湾宣称的「电子防护能力」打上问号。和平时期参数库的更新频率、模拟器的真实度、操作员的实战反应速度,都是未知数。这是台湾防卫体系中最不透明的黑箱,也是最可能发生战时瞬间崩溃的环节。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:「网路战光环」与「电磁战真空」的资源排挤
资通电军的成立,将网路战推向政治舞台中心,却掩盖了传统电子战的资源匮乏。防制假讯息与骇客攻击有极高的媒体曝光度,容易争取预算;但采购一组新型电战荚舱或升级雷达跳频模组,既无法向公众展示,又受制于美方军售时程。
**内在矛盾**:
- 认知战与网路战威胁是「温水煮青蛙」,传统电磁压制则是「一剑封喉」
- 台湾的预算与注意力却大量流向前者,导致「封喉之剑」生锈
- 资通电军内部也存在软体工程师(高薪、易招募)与电战硬体军官(冷门、难培养)的人才失衡
**分析如何用此矛盾**:
观察国防预算分配时,若资通电军的预算增长主要投入于资安与网路防御,而非电磁频谱作战,则台湾的「不对称战力」在战时将面临「有网无波」的困境:网路防线完好,但雷达与飞弹导引已被致盲。
### 矛盾二:「自我防护(EP)」与「防空压制(EA)」的攻守失衡
台湾的电子战装备几乎全数属于「自我防护」——让战机不被击落、让舰艇不被命中。极度缺乏「防空压制」能力——主动寻找并摧毁敌方雷达与通信节点。
**内在矛盾**:
- 防卫作战的最高效手段,不是挨打后存活,而是打瞎攻击者的眼睛
- 但台湾受制于美方军售限制与政治红线,无法取得进攻性电战平台(如AGM-88反辐射飞弹的最新型与配套电战吊舱)
- 缺乏EA能力,意味着台湾永远在电磁频谱上处于「被动接招」的劣势,解放军可从容组织多波次干扰与打击
**结构性命题**:
**台湾的电子战是「盾的抛光」,而非「矛的铸造」。** 这种失衡不仅是装备限制,更是政治定位的投射——台湾被盟邦期望扮演「不挑衅的防御者」,而非「具备反击能力的威慑者」。分析时,必须认清台湾电子战能力的上限是由华盛顿的政治红线,而非台北的军事需求所决定。
### 矛盾三:「参数库依赖」与「战时自主」的断裂
电子战的核心是「知彼」——精确掌握敌方雷达的脉冲重复频率、跳频规律与信号特征。这依赖长期的电子情报搜集与参数库构建。
**内在矛盾**:
- 台湾的参数库高度依赖美方提供与更新
- 和平时期,美方根据其全球情报网更新参数,台湾受惠
- 战时,若美方不直接参战,参数更新将中断;若解放军启用战前未暴露的新频段或新波形,台湾的黑盒子将无法识别威胁
- 中科院虽试图建立自主参数库,但受限于截收平台数量与距离,对大陆内陆的远距电磁情报搜集能力极弱
**分析如何用此矛盾**:
这是台湾电子战最致命的脆弱点。开战首日,若解放军投入未见于和平时期侦察的新型电战装备,台湾的干扰系统将因「不认识目标」而失效。分析战局时,不能假设和平时期的电子侦察数据能完全适用于战时的高强度电磁环境。
---
## 对比分析:台湾 vs 解放军电子战体系
| 维度 | 台湾电子战体系 | 解放军电子战体系 | 分析含义 |
|------|--------------|----------------|---------|
| **战略定位** | 防卫性,确保指管通情存活 | 攻势性,夺取制电磁权为首要 | 解放军将电磁压制作为开战第一波,台湾仅求存活,战略主动权在彼 |
| **核心平台** | 老旧C-130HE、战机外挂荚舱 | 歼-16D专职电战机、运-8/9大型电战机、舰载整合系统 | 台湾缺乏专职伴随干扰与远距支援干扰平台,电战覆盖范围与功率均居劣势 |
| **技术来源** | 美方军售黑盒子,自主性低 | 俄系基础+自主迭代,高度可控 | 解放军可针对实战反馈即时修改干扰码,台湾需等美方更新,战时恐断链 |
| **作战想定** | 在强干扰下维持杀伤链运作 | 致盲台湾雷达、切断数据链、诱导飞弹脱靶 | 台湾是「防守反击」,解放军是「先发制人」。首波打击的效能决定战局走向 |
| **验证环境** | 模拟器为主,缺乏实兵对抗 | 常态性大规模演训,具备相对真实的电磁对抗环境 | 台湾操作员的实战直觉与系统真实抗扰度,缺乏严苛验证,战时可能发生「平时可用、战时当机」的落差 |
**破除常见简化**:
**将「台湾拥有先进美制电战荚舱」等同于「具备抗干扰能力」是危险的。** 荚舱只是硬体,真正的战力取决于参数库是否最新、操作员是否熟练、以及杀伤链各环节(雷达-指管-飞弹)是否具备同等的抗扰度。木桶效应在电子战中最为显著:只要数据链的某一段被干扰,整条杀伤链即告断裂。
---
## 关联分析
### 关联一:与中科院的在地整合角色
详见 `entities/organizations/chung_shan_institute.md`。
- 功能:提供事实层与结构性命题
- 中科院资通所是台湾电子战软体与整合的核心。理解中科院「黑盒子封装」的局限,即是理解台湾电子战自主性的上限。
- 交叉验证:当中科院宣称「研发新型电战系统」时,应追问其核心射频元件与参数库是否仍受外购限制。
### 关联二:与空军战力存活的因果连结
详见 `entities/organizations/roc_air_force.md`。
- 功能:提供脉络层
- 空军是台湾最依赖电子战的军种。F-16V的存活率,直接取决于其电战荚舱能否有效欺骗解放军空对空飞弹的寻标器。
- 分析时,应将空军战损率评估与电子战效能绑定,而非仅计算双方战机数量。
### 关联三:与上位概念的框架连结
详见 `primitives/concepts/information_electronic_warfare.md`。
- 功能:提供脉络层与理论框架
- 本档案是上位概念在台海场景的具体化。上位概念提供的「电磁频谱即机动域」框架,是分析台湾电子战战略地位的基础。
### 关联四:与豪猪战略的隐性支撑
详见 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:提供结构性命题
- 电子战是豪猪战略的「隐形神经」。没有电子防护的豪猪,是瞎眼的豪猪。
- 交叉验证:评估豪猪战略预算分配时,若飞弹采购预算远高于电战系统升级预算,即显示战略存在「重刺轻眼」的失衡。
### 关联五:与解放军能力评估的镜像对照
详见 `narratives/cross_strait/pla_capabilities_assessment.md`。
- 功能:提供事实层与用于对照
- 台湾电子战的弱点,正是解放军电子战的强项。理解解放军歼-16D与运-9的战术运用,才能准确定位台湾电子战部队的生存空间。
- 分析时,应将台湾的EP能力与解放军的EA能力进行对抗推演,而非孤立评估。
### 关联六:与反舰飞弹杀伤链的绑定
详见 `entities/equipment/anti_ship_missiles_taiwan.md`。
- 功能:提供事实层与交叉验证
- 反舰飞弹的命中率在无干扰与强干扰环境下可呈现指数级落差。雄风飞弹的导引雷达是否具备足够的电子反反制(ECCM)能力,是决定其威慑力的关键。
- 分析时,不能将飞弹的纸面命中率直接套用于战时想定,必须引入电子战折价系数。
---
## 常见的分析陷阱
### 陷阱一:将「资通电军」等同于「电子战强」
资通电军名称中的「资通」易使人误以为其全面掌握电磁频谱作战。实际上,其核心战力与预算多集中于网路战与资安。传统电子战(雷达干扰、反辐射压制)仍分散于海空军,且资源相对匮乏。将资通电军战力等同于整体电子战战力,会严重高估台湾的电磁防护能力。
### 陷阱二:假设美制电战荚舱能完全压制解放军雷达
美制电战荚舱(如ALQ-184)虽技术先进,但其设计目的多为「自我防护」,即让战机有机会脱离锁定,而非「压制防空」(使敌方雷达完全失效)。面对解放军现代相位阵列雷达的高功率与复杂波形,单机荚舱的干扰效果有限,需配合战术机动与其他软硬杀手段。假设挂载荚舱即等于免疫防空飞弹,是技术上的误判。
### 陷阱三:忽略电子战对杀伤链的「木桶效应」
飞弹的导引头、发射车的射控雷达、指挥所的数据链,三者任一环节被干扰,飞弹即无法命中。分析时常犯的错误是:只评估飞弹本身的抗干扰能力,却忽略了数据链被切断时,飞弹根本无法获得目标资料。电子战的脆弱性取决于系统中最弱的一环。
### 陷阱四:假设和平时期的电磁参数在战时仍有效
和平时期截收的解放军雷达参数,可能只是其基本模式。战时解放军必然启用备用频段、复杂波形与跳频模式。若台湾的参数库未涵盖这些战时模式,干扰系统将无法识别与对抗。假设和平时期的侦察等于战时的透明,是致命的乐观。
### 陷阱五:低估广域阻塞式干扰的毁灭性
台湾强调「跳频」与「机动」作为抗干扰手段,但这主要针对「瞄准式干扰」。解放军的大型电战机(如运-9)可在战区外实施广域阻塞式干扰,将整段频谱淹没。此时跳频通信亦无法运作,因为所有频点皆被噪音覆盖。低估这种「粗暴但有效」的干扰方式,会错判台湾指管体系的存活时间。
### 陷阱六:将「网路战」与「电子战」混为一谈
网路战攻击的是资料流(0与1),电子战攻击的是电磁波(物理信号)。两者虽在认知电子战与软体定义无线电领域有交集,但本质不同。网路战可从远端发起,电子战必须与目标进行物理空间的电磁交互。将防制骇客入侵的能力,误认为防制雷达干扰的能力,是概念上的根本错位。
---
## 仍不确定的部分
- **美方参数库的更新频率与深度**:美方是否向台湾提供了针对解放军最新型雷达(如055型舰载雷达、歼-20雷达)的完整干扰参数?这决定了台湾电子战系统是否只是「半成品」,但此为极机密,外界无法确知。
- **中科院认知电子战(AI电战)的实质进展**:中科院宣称利用AI动态生成干扰波形,此技术是否已从实验室走向实战部署?面对解放军未知的雷达波形,AI反应的速度与效能是否优于传统参数库?目前缺乏实证。
- **战时电磁频谱管理的真实状态**:台湾岛内民用通信密集,战时军方若需占用频段或实施电磁静默,与民用的冲突如何解决?指管系统的频谱抗扰度在军民混用环境下的表现,缺乏大规模演训验证。
- **C-130HE电子战机的真实效能**:该机型服役多年,其干扰功率与数位化程度是否足以对抗现代解放军雷达?还是仅具象征意义?因从未公开演训,外界仅能推测。
- **解放军电子战对台湾飞弹导引头的实战干扰率**:雄风与天弓飞弹在面临同等级电子干扰时的脱锁率,缺乏实弹验证数据。兵棋推演中的假设值可能与实战存在巨大落差。
---
## 与其他文件的关系
- 中科院档案:`entities/organizations/chung_shan_institute.md` —— 理解电子战装备的在地整合者与黑盒子封装的局限
- 空军档案:`entities/organizations/roc_air_force.md` —— 追踪空中电子战平台与战机存活率的关联
- 资电作战概念:`primitives/concepts/information_electronic_warfare.md` —— 提供电磁频谱争夺的理论框架
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md` —— 揭示电子战作为隐形神经在不对称战略中的关键地位
- 解放军能力评估:`narratives/cross_strait/pla_capabilities_assessment.md` —— 提供对手电子战威胁的想定与对照
- 反舰飞弹档案:`entities/equipment/anti_ship_missiles_taiwan.md` —— 交叉验证杀伤链在电子干扰下的脆弱性
---
## 一句话浓缩
> 电子战能力是台湾防卫体系的隐形神经,决定了豪猪的刺是精准还是盲目;它是「网路战光环」掩盖下的「电磁战真空」,也是美台合作中「给硬体控灵魂」的技术依附缩影——分析台湾战力,若不先算电磁频谱的得失,所有飞弹数量的推演都只是和平时期的算术题。
---
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,329 @@
# 唐纳·川普(涉台立场专档) [US_PERSON_TRUMP_TAIWAN]
---
**类型**: person(涉台立场专档)
**当前角色**: 美国第 47 任总统(2025.1- );美台关系与两岸格局的最大外部变数
**出生时间**: 1946-06-14
**何时调用此档案**: 分析川普 2.0 时期任何涉台发言、美台政策变动、美中台三角互动、台湾内部对美政治反应、以及 2028 台湾选举的外部环境设定时
---
## 一句话定位
川普的涉台立场不是静态的"亲台"或"反台",而是一套将台湾视为"可定价筹码"的交易逻辑——其言辞的不可预测性是谈判工具,对半导体与防务费用的执着是可预测的施压轴心,两者共同构成了 2028 年前台湾政治最大的外部冲击波。
---
## 档案分工与定位
本档案路径为 `entities/foreign_actors/donald_trump.md`,与 `entities/people/donald_trump.md`(P4 人物档案)形成分工:
- **P4 人物档案**:侧重川普的个人全貌、决策风格、国内政治基础与整体外交逻辑。
- **本档案(涉台专档)**:深度聚焦川普对台湾的立场演变、关键言论解码、内阁与交易主义的张力、对台舆论冲击,以及对 2028 台湾选举的结构性影响。
分析者在处理美台关系时,应以本档案为主入口,以 P4 档案为补充背景。本档案大量运用 `primitives/frameworks/trump_factor_analysis.md` 的分析框架,将其具象化为涉台领域的操作工具。
---
## 涉台立场的演变:从"反中副产品"到"主动施压筹码"
许多分析者用川普第一任的经验推论第二任。这是严重的分析错误。川普 1.0 与 2.0 的涉台立场存在结构性位移。
### 川普 1.0(2017-2021):被动亲台与"反中副产品"
**事实层**:
- 2016.12 接受蔡英文祝贺电话,打破 1979 年以来惯例。
- 2018-2020 签署《台湾旅行法》、《亚洲再保证倡议法》、《台北法》。
- 大幅提升对台军售频率与品项(如 F-16V 战机、MQ-9 无人机)。
- 2020.8 卫生部长阿扎访台,2020.9 次卿克拉奇访台。
- 2021.1 卸任前,蓬佩奥宣布解除国务院内部对台交往限制。
**脉络层**:
川普 1.0 的亲台表象下,是川普个人意志的缺位与内阁的强势主导。当时的涉台升级并非源于川普对台湾的战略重视,而是三个结构性因素的叠加:
1. **内阁鹰派主导**:蓬佩奥、波顿、纳瓦罗等传统反共鹰派掌控国务院与国安会,台湾被视为对抗北京的合理支点。
2. **美中贸易战的延伸**:对台升级是对中施压牌组的一部分,台湾的工具价值在对抗中上升。
3. **川普的默许而非驱动**:川普本人对台湾议题兴趣缺缺,只要内阁提出的对台动作不干扰他的对中交易大局,他倾向于不干预。
**分析含义**:
分析川普 1.0 时期时,**绝不能将"政策结果"等同于"川普意图"**。第一任的友台是特定内阁组成与美中对抗大环境的副产品。当这两个条件在第二任改变时,友台政策的基石就不复存在。
### 川普 2.0(2025- ):主动施压与"交易筹码化"
**事实层**:
- 2024 选举期间,多次公开宣称"台湾偷走我们的芯片生意"。
- 2024 彭博社专访,提出"台湾应支付防务费用(保护费)"。
- 2024 集会发言,强调"台湾距离美国 9000 英里,距离中国 68 英里",质疑防卫合理性。
- 2025 上任后,对半导体进口加征关税的威胁常态化,台湾首当其冲。
- 对台军售虽未停止,但审查标准更倾向"是否对美国经济有利"。
**脉络层**:
川普 2.0 的涉台立场发生了本质位移:台湾从"反中工具"转变为"对美负债"与"谈判筹码"。这源于:
1. **内阁 MAGA 化**:传统鹰派(如鲁比奥)虽仍在位,但必须与万斯等孤立主义派妥协,台湾不再享有免受质疑的特权。
2. **美中"再交易"的可能性**:当川普寻求与北京达成大交易时,台湾的工具价值从"制衡北京的牌"转变为"可向北京让步的筹码"或"可向台北索取的代价"。
3. **经济民族主义上位**:半导体取代地缘战略,成为川普认知台湾的首要透镜。
**分析含义**:
分析川普 2.0 时,**必须抛弃"美国基于战略价值必然护台"的预设**。在川普的认知框架中,战略价值是可以被价格化的;如果防卫台湾的成本(距离远、风险高)在感知上高于收益(半导体已赴美、军费不够),他的直觉是索取更多或准备脱手。
---
## 关键涉台言论解码:保护费、关税与芯片
川普的涉台言论看似随意,实则遵循其特有的交易逻辑。以下拆解三大核心言论的结构意涵。
### 言论一:"台湾应付保护费"
**事实层**:
2024 年 7 月彭博社专访中,川普被问及是否防卫台湾时回答:"台湾为我们做了什么?我们保护了他们,他们应该付钱给我们。"此言论在台湾引发巨大震动。
**脉络层**:
这不仅是选举语言,更是川普对美台关系的**根本性重新定价**。他将美台关系从"基于共同价值的战略同盟"降维为"基于费用交换的保护契约"。这种降维的破坏力不在于美国立刻撤回安全承诺(制度上有约束),而在于它改变了台湾社会对自身安全的认知框架——从"自助人"退化为"被保护者",从而引发深层的信任危机。
**分析含义**:
分析者不应将"保护费"言论简单视为勒索,而应视为一种**压力测试**。川普的目的是迫使台湾主动提高国防预算(如 GDP 3% 以上)、扩大对美军购、加速台积电赴美。当台湾做出这些让步时,川普会将其包装为"交易胜利",而非对台承诺的强化。详见 `narratives/us_taiwan/taiwan_pay_protection_discourse.md`。
### 言论二:"台湾偷走我们的芯片生意"
**事实层**:
2024 年多场集会中,川普宣称"台湾抢走了我们大约 100% 的芯片生意",并暗示将对台湾半导体征收关税。
**脉络层**:
这是川普经济民族主义在涉台领域的投射。**破除常见简化**:把"川普对台加征芯片关税"等同于"放弃台湾安全"是错的。在川普的逻辑里,经济剥削(关税)与安全保护(军售)可以并行不悖,甚至互为条件——"你买我的武器,我收你的关税,这样贸易才平衡"。这是典型的房地产开发商逻辑:收租的同时卖保险。他并不希望摧毁台湾的半导体产能,而是希望通过关税威胁迫使台积电将更多产能与核心技术转移至美国。
**分析含义**:
分析半导体关税威胁时,**关注点应放在"台积电赴美进度"而非"美台关系破裂"**。只要台积电持续在亚利桑那等州扩厂,川普的关税威胁就更多是谈判筹码;若台积电赴美进度受阻,关税威胁转化为实质政策的概率才会显著上升。详见 `narratives/us_taiwan/tariff_on_taiwan_chips.md`。
### 言论三:"距离论"(9000 英里 vs 68 英里)
**事实层**:
川普多次在公开场合强调美国距离台湾 9000 英里,而中国距离只有 68 英里,以此质疑美国防卫台湾的地理合理性。
**脉络层**:
这是川普"成本中心"思维的典型体现。他将地缘战略简化为物流距离,忽略了第一岛链的战略价值与海空权优势。但此言论的功能不仅是表达地理疑虑,更是**为未来的"不作为"或"有限行动"预设正当性**。如果未来台海发生危机而川普选择不介入,他可以轻易引用"距离太远"作为向国内选民交代的理由。
**分析含义**:
分析者听到"距离论"时,**不应试图从地缘战略角度反驳,而应识别其为"预期管理"工具**。这预示着在危机情境下,川普极可能以"地理限制"为由,将美军行动限制在威慑展示(如航母通过)而非实质介入。
---
## 对台政策的"可预测 vs 不可预测"
川普的不可预测性常被误认为混乱。实际上,不可预测性是策略,而底层逻辑具有高度可预测性。
### 不可预测的部分:作为谈判工具的"疯子理论"
**事实层**:
川普拒绝明确回答是否防卫台湾,在记者追问时常以"我不想透露底牌"回应。这种战略模糊从传统的"双重威慑"工具,转变为川普个人的"议价筹码"。
**脉络层**:
不可预测性在川普框架中有三个功能:
1. **对北京:增加冒险成本**。北京不确定川普是否会冲动开战,这种不确定本身就是威慑。
2. **对台北:迫使让步**。台北不确定川普是否会与北京交易,这种恐惧迫使台湾更积极迎合美方要求(买武器、付保护费、转移芯片)。
3. **对国内:保持灵活性**。不承诺就不受约束,永远保留"交易"或"撤退"的选项。
**分析含义**:
当川普发出不可预测的涉台信号时,分析者不应问"他到底想什么",而应问**"这个不可预测信号对谁产生了什么压力,谁因此做出了让步"**。详见 `narratives/us_taiwan/trump_taiwan_uncertainty.md`。
### 可预测的部分:交易主义的底层逻辑
**事实层**:
尽管言辞多变,川普的决策遵循三个可预测的核心预设:
1. 所有关系都是零和交易(有赢家必有输家)。
2. 盟友是成本中心,对手是交易对象。
3. 短期有形利益(金钱、产业)优先于长期无形利益(信誉、价值)。
**脉络层**:
基于这三个预设,川普 2.0 的涉台政策呈现可预测的轨迹:
- **持续要求台湾增加防务支出**:这是最可能兑现的"保护费"形式。
- **持续施压半导体转移**:关税与投资要求将交替使用。
- **避免明确的安全承诺**:维持模糊以保留交易空间。
- **对北京的强硬与妥协交替**:强硬用于国内动员,妥协用于达成交易。
**分析含义**:
分析者可以利用这些可预测的底层逻辑,过滤川普的即兴发言。如果某次发言符合"要求台湾付更多钱"的逻辑,则其转化为政策的概率极高;如果某次发言偏离了所有可预测逻辑(如突然宣布出兵保卫台湾),则极可能是即兴发挥,大概率被内阁回收。
---
## 内阁鹰派 vs 川普交易主义的张力
川普 2.0 的对台政策不是川普一人决定,而是内阁派系与川普交易主义持续角力的产物。
### 鹰派的"转化"策略
**事实层**:
国务卿鲁比奥、前国安顾问沃尔兹等人历来对台友好。但在川普 2.0 体制下,他们不能直接以"支持民主"或"战略价值"为由推动对台政策,必须将其包装为符合川普交易逻辑的选项。
**脉络层**:
鹰派在川普政府中的生存策略是**"交易化包装"**:
- 将对台军售包装为"创造美国就业"与"降低美国防务负担"。
- 将武装台湾包装为"低成本威慑"——让台湾自己打,美国不用出兵。
- 将高层互访包装为"促进台湾对美投资"。
**分析含义**:
分析内阁声明时,**注意其中的"交易化修辞"**。当鲁比奥谈论台湾时,如果强调"责任分担"与"经济合作",说明他正在为亲台政策向川普买路;如果他强调"共同价值",则可能是在向国内传统共和党人喊话,但对川普的影响力较弱。
### 孤立派的"制约"机制
**事实层**:
副总统万斯代表 MAGA 孤立派,对海外承诺持深度怀疑。他在参议院的关键投票权与作为川普接班人的政治资本,使其对川普有实质制约。
**脉络层**:
万斯等人的功能是**"风险放大器"**。当川普考虑介入台海危机时,万斯会强调"这违背美国优先"、"这会导致第三次世界大战"。这使得川普在危机中更倾向于选择"有限威慑"而非"全面介入"。
**分析含义**:
在台海危机情境下,**万斯的立场决定了川普的"退缩底线"**。如果鹰派无法证明介入是"低成本且能赢"的,万斯的孤立主义将主导决策。
---
## 对台湾舆论的"冲击波"效应
川普的涉台言论对台湾内部政治心理的冲击,远大于其实质政策的改变。
### 疑美论的"超级燃料"
**事实层**:
川普的"保护费"与"芯片偷走"言论,在台湾媒体被广泛引用,成为 2024 选举后疑美论的核心论据。
**脉络层**:
川普因素对疑美论的强化不是线性的,而是**结构性的降维打击**。传统疑美论(如"美国弃台论")多基于地缘战略推演,属于精英层的理性怀疑;川普引发的疑美论则是基于"美国不可靠"的情感直觉,直接渗透到大众认知。他将美台关系从"战略伙伴"降格为"保护费契约",使得台湾民众对美信任的根基(共同价值)被抽空。
**分析含义**:
分析台湾各党派对川普的反应时,**不要只看政策表态,要看他们如何"修补信任赤字"**。DPP 的挑战最大:它必须在"迎合川普要求以维持实质关系"与"安抚内部疑美焦虑"之间走钢丝。KMT 则面临"反美论可能反噬对美关系"的风险。TPP 试图将"理性平衡"包装为应对川普的最佳策略,但在情感冲击下,理性论述的穿透力有限。
---
## 对 2028 的"结构性变量"
川普 2.0 是 2028 台湾选举无法绕过的外部结构性变量。
### 情境分析与选举影响
| 情境 | 触发条件 | 对 2028 选举的结构性影响 |
|---|---|---|
| **A:模糊延续** | 川普持续施压但无结构性破坏,军售照常,关税有限 | 疑美论温和增长,DPP 可勉强维持"稳固台美"论述,KMT 难以主打"美国不可靠" |
| **B:交易式调整** | 川普与北京达成大交易,台湾在关税或国际空间上被让步 | 疑美论爆发,DPP 论述崩塌,KMT "亲中保台"或 TPP "等距外交"获利 |
| **C:危机驱动升级** | 北京采取重大胁迫行动,川普被迫回应 | 视川普回应而定:若强硬,DPP 受益;若退缩,疑美论与失败主义蔓延,KMT/TPP 受益 |
| **D:撤离式调整** | 川普明确表态不防卫台湾,大幅削减军售 | DPP 遭遇存亡危机,"抗中保台"彻底失效,选举主轴被迫转为"如何与北京谈判" |
**分析含义**:
2028 的选举主轴不是由台湾内政决定,而是由川普 2.0 落入哪个情境决定。分析 2028 可能性时,**必须先设定川普情境,再推演台湾内政反应**,而非反过来。
---
## 实务分析:解读川普涉台言论的检查清单
当川普发出涉台言论时,按以下步骤系统分析,避免过度反应或轻视。
### Step 1: 信号源与场合识别
- [ ] 这是川普本人的发言,还是内阁声明?
- [ ] 场合是什么?(正式政策演讲 / 专访 / 集会 / Truth Social)
- [ ] 是否有记者追问?是否为即兴回应?
**判断规则**:场合越非正式,越可能是即兴或谈判喊价。内阁正式声明的权重远高于川普集会发言。
### Step 2: 交易逻辑映射
- [ ] 这个言论对谁施加压力?(台北?北京?美国国内?)
- [ ] 这个言论要求谁让步?让步内容是什么?(军购?芯片?关税?)
- [ ] 是否与当前美中谈判议程对齐?
**判断规则**:若能清晰对应到某个谈判目标,则是交易策略;若不能,则是情绪表达或政治动员。
### Step 3: 72 小时跟进检验
- [ ] 发言后 72 小时内,国务院或国防部是否有跟进声明?
- [ ] 是否有行政命令或政策指令发布?
- [ ] 国会是否提出异议或支持?
**判断规则**:没有执行跟进的发言是空包弹。追踪执行层的实际动作,而非停留在言辞层。
### Step 4: 重复频率观察
- [ ] 同一立场是否在 3 次以上不同场合出现?
- [ ] 是否有具体数字伴随?(如"应支付 GDP 的 10%")
**判断规则**:重复出现且伴随具体数字的立场,正在向政策转化。
---
## 关联分析
### 关联一:与 `entities/people/donald_trump.md` 的关系
**功能**:提供事实层与个人背景。本档案聚焦涉台立场,P4 档案提供川普的整体决策风格与国内政治基础。分析涉台言论时,需参考 P4 档案中的"个人层"特征。
### 关联二:与 `primitives/frameworks/trump_factor_analysis.md` 的关系
**功能**:提供脉络层与分析框架。本档案是此框架在涉台领域的具体应用。当需要拆解川普发言的多层意涵时,应调用此框架的"发言解码矩阵"。
### 关联三:与 `narratives/us_taiwan/trump_taiwan_uncertainty.md` 的关系
**功能**:提供交叉验证。本档案分析不可预测性的结构功能,该叙事文件分析不确定性如何被论述化与放大。两者结合可识别:哪些不确定性是川普制造的,哪些是台湾内部政治放大的。
### 关联四:与 `narratives/us_taiwan/taiwan_pay_protection_discourse.md` 的关系
**功能**:提供事实层与论述演变。本档案拆解"保护费"言论的川普逻辑,该文件追踪"保护费"论述在台湾内部的接受、转化与抵抗。
### 关联五:与 `narratives/us_taiwan/tariff_on_taiwan_chips.md` 的关系
**功能**:提供事实层。本档案指出半导体是川普认知台湾的核心透镜,该文件提供关税威胁的具体时间线与台积电应对策略,用于验证川普"经济剥削与安全保护并行"的逻辑。
### 关联六:与 `events/timelines/us_taiwan_2024_present_trump_2.md` 的关系
**功能**:提供事实层与验证基准。本档案的立场演变分析需以此时间线为依据,任何涉台言论的"重复频率"与"后续跟进"检查,均需对照此时间线。
---
## 常见的分析陷阱
### 陷阱一:将"保护费"言论等同于"撤回安全承诺"
**具体表现**:川普说"台湾应付钱",分析者立刻得出"美国将放弃台湾"的结论。
**正确做法**:识别"保护费"是重新定价而非终止契约。川普的目的是加码索取,而非撕毁合同。将台湾作为筹码施压,与真正出卖台湾,是两种不同的逻辑。
### 陷阱二:用第一任经验推论第二任
**具体表现**:"川普第一任期对台军售创新高,因此第二任期也会如此"。
**正确做法**:第一任的友台是内阁与环境的产物,第二任内阁已MAGA化,环境已变为"美中再交易"。必须基于 2.0 的新结构重新评估。
### 陷阱三:忽略"言辞伤害"与"结构破坏"的区别
**具体表现**:将川普每一次对台抱怨都视为灾难性信号。
**正确做法**:区分言辞伤害(抱怨、暗示)与结构破坏(暂停军售、切断情报)。目前多数动作属于言辞伤害与边缘调整,分析者应将注意力集中在是否有"结构破坏"的迹象。
### 陷阱四:心理化揣测川普"内心亲台"或"内心卖台"
**具体表现**:"川普内心钦佩习近平,因此会出卖台湾"或"川普是反共先锋,因此会保护台湾"。
**正确做法**:川普没有固定的意识形态内心。只分析可观察的行为、发言场合与制度约束,不揣测内心。
### 陷阱五:低估半导体议题的轴心地位
**具体表现**:将半导体关税视为独立的经济议题,与安全议题脱钩分析。
**正确做法**:在川普的认知中,半导体就是安全,安全就是半导体。台湾的半导体地位是其被索取的起点,而非被保护的保证。分析时必须将两者视为同一硬币的两面。
### 陷阱六:假设"美国制度"能完全约束川普
**具体表现**:"国会有台湾关系法,所以川普无法对台不利"。
**正确做法**:制度是减速器而非刹车。总统在军售节奏、高层互访、公开表态上有广泛裁量权,这些"边缘调整"的累积效应足以改变台海安全格局,无需触碰法律底线。
---
## 仍不确定的部分
- **川普与习近平的直接互动黑箱**:两人在私下会谈中是否就台湾达成任何口头谅解,这是外部观察者完全无法确知的,却可能决定川普在危机中的第一反应。
- **"保护费"的具体数字门槛**:台湾增加多少防务预算或对美采购,才能满足川普的"交易感"?这个数字是浮动的,取决于川普当下的国内政治需求。
- **台湾社会对"言辞伤害"的耐受力阈值**:台湾公众与政治精英对川普反复的负面言论能承受多久,何时会从焦虑转化为对美信任的实质崩塌,仍不确定。
- **危机中军方建议与川普直觉的冲突解决机制**:若台海发生危机,印太司令部的专业建议与川普"不要卷入远方战争"的直觉冲突时,谁占上风,取决于危机的具体形态与川普的国内政治算计。
---
## 与其他文件的关系
| 文件路径 | 关联功能 |
|---|---|
| `entities/people/donald_trump.md` | 人物全貌:提供川普个人决策风格与历史行为的事实基础,本档案为其涉台立场的特化与深化 |
| `primitives/frameworks/trump_factor_analysis.md` | 分析框架:提供拆解川普因素的多层结构与发言解码矩阵,本档案为其在涉台领域的应用实例 |
| `narratives/us_taiwan/trump_taiwan_uncertainty.md` | 核心论述:提供川普不确定性的叙事分析与论述化扩张的识别,与本档案形成事实-叙事的交叉验证 |
| `narratives/us_taiwan/taiwan_pay_protection_discourse.md` | 核心论述:提供"保护费"论述在台湾内部的演变与政治化过程,补充本档案对言论源头的分析 |
| `narratives/us_taiwan/tariff_on_taiwan_chips.md` | 核心论述:提供半导体关税威胁的具体时间线与产业影响,验证川普"经济与安全并行"的交易逻辑 |
| `events/timelines/us_taiwan_2024_present_trump_2.md` | 时间线:提供川普 2.0 时期涉台事件的具体时序,作为本档案立场演变分析的验证基准 |
---
## 一句话浓缩
> 川普 2.0 的涉台立场是一套将台湾"筹码化"与"重新定价"的交易逻辑——言辞的不可预测性是施压工具,对保护费与半导体的执着是可预测的索取轴心;分析者必须区分言辞伤害与结构破坏,避免用第一任经验推论第二任,并认清在川普逻辑中,对台加码索取远比出卖台湾更具操作性。
---
last_updated: 2025-05-30
@@ -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
@@ -0,0 +1,329 @@
# 哈德逊研究所(Hudson Institute)
---
**类型**: 机构档案
**当前角色**: 华盛顿保守派鹰派智库、战略清晰派与意识形态对抗路线的知识引擎、MAGA 派对台政策的核心输入源
**成立时间**: 1961 年
**何时调用此档案**: 引用哈德逊涉台报告时;分析美方战略清晰化论述时;评估保守派与 MAGA 派的对台立场时;解读前川普政府官员(如庞培欧)的涉台主张时;进行多智库立场对照与极化光谱分析时
---
## 一句话浓缩
哈德逊研究所是华盛顿涉台政策圈的「破壁者」——它不追求维持现状的平衡,而是**以意识形态对抗为底色、以战略清晰为手段、以击败北京为终极目标**,将台湾从「需管控的风险」重新定义为「必须捍卫的前哨」;理解哈德逊,就是理解美方鹰派如何试图彻底拆除战略模糊的护栏,以及这套激进论述如何与 MAGA 派的本土主义产生危险又共生的结盟。
---
## 机构基本性质与结构定位
### 事实层
- **全名**: Hudson Institute, Inc.
- **成立**: 1961 年,由未来学家赫尔曼·卡恩创立于纽约,后迁至华盛顿特区
- **总部**: 华盛顿特区
- **现任主席与 CEO**: John P. Walters(前白宫国家毒品控制政策办公室主任)
- **政治/思想光谱**: 保守派 / 新保守主义 / 鹰派
- **资金来源**: 保守派基金会(如莎拉·斯凯基金会、林德与哈里·布拉德利基金会)、企业捐款、个人捐款;长期接受台湾政府驻美机构的资助(公开揭露)
- **核心定位**: 强调「美国卓越主义」、强硬国防、意识形态对抗;在涉台议题上是战略清晰派的绝对主力
### 脉络层:哈德逊的「反建制」建制化
哈德逊在华盛顿智库生态中的核心特征是**「反建制的建制化」**。它起源于对冷战时期妥协派的批判,在当代则表现为对华盛顿「接触政策」与「战略模糊」共识的全面否定。
在涉台议题上,这种特征表现为:
1. **现状否定**:不认为台海现状值得维持,视现状为北京持续改变下的慢性溃败。
2. **国会/政治视角**:其学者多出身国会、国防系统或政治任命轨道,研究视角天然贴近国会的「立法宣示」与「惩罚对手」逻辑,而非行政体系的「危机管控」逻辑。
3. **意志优先**:预设北京政权是扩张性且不可理喻的,唯有展现无条件的抵抗意志与压倒性的实力才能产生威慑。
### 分析含义
分析时,不应将哈德逊视为「客观事实的提供者」,而应视为**「美方鹰派打破现状路线的推土机」**。当哈德逊发布报告时,其功能不是「优化现有政策」,而是「证明现有政策已经失败,必须彻底替换」。
---
## 涉台研究的历史与当代演变
### 事实层:从冷战反共到对华战略竞争的先锋
哈德逊的涉台研究经历了三个结构性阶段:
- **冷战时期(1960s-1990s)**:作为反共堡垒,台湾被视为围堵共产主义链条的一环,但并非核心焦点。研究侧重于苏联威胁与大规模战略。
- **接触政策时期(1990s-2010s)**:哈德逊在对华政策圈处于边缘位置。华盛顿主流拥抱接触政策,哈德逊的少数对华鹰派(如 Michael Pillsbury 早期)被视为异类。涉台研究零星,多依附于 broader 的亚太安全研究。
- **战略竞争时期(2010s 末至今)**:随着美中关系恶化,哈德逊从边缘走向核心。它迅速将资源投入印太与台海议题,成为「战略清晰」与「全面对抗」论述的主要生产者。关键转折是 2018 年后,哈德逊系统性地将台湾定位为「击败中国野心的关键」。
### 脉络层:从「中国观察」到「中国威胁」的加速器
哈德逊涉台研究的演变,不是被动的学术转向,而是**主动的政治议程设定**。当华盛顿建制派(如 Brookings)仍在讨论「如何管控竞争」时,哈德逊已经将框架设定为「如何赢得竞争」。
这种框架设定的结构性后果是:**台湾在哈德逊的论述中失去了内政的复杂性,被高度符号化为「自由对抗专制的前哨」**。台湾的政党轮替、社会分歧、经济顾虑,在哈德逊的分析中常被边缘化,因为它们无助于「展现抵抗意志」的核心叙事。
### 分析含义
追踪哈德逊研究重心的转移,可作为观察**华盛顿鹰派如何将台湾从「美中关系的问题」重新定义为「击败中国的武器」**的指标。当哈德逊开始大量生产关于台湾半导体、不对称防御、国际参与的建议时,其底层逻辑始终是「这如何服务于美方击败对手的战略」,而非「这如何符合台湾社会的生存利益」。
---
## 关键涉台学者与立场解析
哈德逊的涉台立场具有高度一致性,但不同学者的切入点与功能各异。
### Michael Pillsbury(白邦瑞):对华鹰派的理论教父与 MAGA 桥梁
- **背景**: 哈德逊资深研究员,前国防部官员,长年研究中国军事与战略
- **专长**: 中国战略文化、军事欺骗、大国竞争
- **立场特征**:
- **「百年马拉松」论提出者**:主张北京自冷战起便执行长期战略欺骗美国,目标是取代美国霸权。此论述为对华全面对抗提供了历史与理论正当性。
- **对川普的直接影响**:被川普公开称赞为「中国问题的最高权威」,是 2017 年《国家安全战略》将中国定为「战略竞争对手」的关键推手。
- **涉台立场**:将台湾视为北京战略欺骗与扩张的试金石,主张美方必须打破战略模糊,否则将传递软弱信号。
- **代表作品**: 《百年马拉松》
**分析含义**:Pillsbury 的功能是**提供「对抗」的宏观理论框架**。分析时需注意,Pillsbury 的论述高度依赖「意图决定论」(即北京有取代美国的意图,因此一切行为皆应被视为威胁),这在方法论上常忽略北京行为的务实性与内部制约。引用 Pillsbury 时,必须意识到他的权威来自对「美方过去政策失败」的叙事建构,而非对台湾本身的深度研究。
### Seth Cropsey:海上霸权与战略清晰的军事推手
- **背景**: 哈德逊资深研究员,前海军部副次长,美国世纪中心创始人
- **专长**: 海上战略、海军力量、印太军事平衡
- **立场特征**:
- **海权决定论者**:主张美方必须维持绝对海上优势,台湾的地理位置决定了它是第一岛链的核心。
- **战略清晰的军事论证者**:频繁撰文论证「战略模糊等于邀请北京动武」,主张美方必须明确承诺防卫台湾,并在台驻军或部署军事资产。
- **不对称防御的激进推动者**:主张台湾必须全民皆兵,购买大量反舰导弹与防空系统,将自己武装成「豪猪」。
- **代表作品**: 多篇关于台海海战与战略清晰的政策简报
**分析含义**:Cropsey 提供了**战略清晰派的军事逻辑**。分析时需注意,Cropsey 的军事推演常假设北京会采取最直接的军事行动(如全面封锁或两栖登陆),而较少考虑灰色地带胁迫或内部政变等非动能手段。这种假设使得「清晰威慑」显得无比必要,但也可能高估了军事承诺的威慑效果与升级风险。
### Patrick Cronin:印太战略的建制化包装者
- **背景**: 哈德逊印太战略主席,前 CSIS 与新美国安全中心(CNAS)资深官员
- **专长**: 印太安全架构、同盟关系、综合威慑
- **立场特征**:
- **相对温和的鹰派**:在哈德逊内部属于较具建制色彩的学者,不常使用意识形态化语言。
- **同盟网络论者**:强调台湾安全需嵌入美日韩菲等同盟网络中,主张「综合威慑」。
- **务实清晰派**:支持战略清晰,但更强调通过行动(如军售、联合训练)展现承诺,而非仅仅通过声明。
- **代表作品**: 印太威慑倡议报告、台海韧性研究
**分析含义**:Cronin 的功能是**将哈德逊的鹰派主张翻译成五角大楼能执行的政策语言**。分析时,Cronin 的报告是观察「哈德逊主张有多少能被军方实际吸收」的最佳指标。若 Cronin 的措辞开始与 Cropsey 趋同,代表军方内部的共识正在向鹰派移动。
### Michael Pompeo(庞培欧):政治象征与行动派
- **背景**: 前美国国务卿、前 CIA 局长,现任哈德逊杰出会士
- **专长**: 政治行动、意识形态外交
- **立场特征**:
- **「自由中国」论提出者**:在国务卿任内发表演讲,主张对华政策目标应是「促成中国政权更迭」。
- **战略清晰的最高政治象征**:卸任后立即访台,多次公开呼吁美方应立即承认台湾为独立主权国家。
- **去模糊化的行动者**:任内取消美台交往限制,为涉台政策解绑提供先例。
- **代表作品**: 尼克松图书馆演讲、多次访台公开演说
**分析含义**:Pompeo 在哈德逊的角色不是学者,而是**政治加速器**。他的发言代表了战略清晰派的「最终目的地」。分析时,Pompeo 的主张不应被视为「即将发生的政策」,而应被视为「鹰派试图移动 Overton Window(可接受政策范围)的工具」。当 Pompeo 提出承认台湾时,他是在为 Cronin 的「务实清晰」或 Cropsey 的「驻军」创造看似温和的对比空间。
---
## 立场特征:鹰派、战略清晰派的结构性拆解
### 事实层
哈德逊在涉台政策辩论中的核心标签是「鹰派」与「战略清晰派」。这表现为:
1. 坚决主张废除战略模糊,明确承诺防卫台湾。
2. 主张提升美台军事关系(含联合演习、驻军、情报共享)。
3. 支持台湾全面参与国际组织。
4. 反对任何形式的两岸妥协或「冻结现状」方案。
5. 将北京政权视为不可调和的意识形态敌人。
### 脉络层:「清晰」不是目的,是「对抗」的手段
将哈德逊标记为「战略清晰派」容易产生误导,暗示其核心关怀是「政策的明确性」。实际上,**哈德逊追求的不是清晰,而是对抗**。战略清晰只是对抗的必要条件。
这种偏好存在两个结构性内在矛盾:
1. **无条件支持与有条件赋权的矛盾**:哈德逊主张对台「无条件」支持,但这种支持的前提是台湾必须完全配合美方的对抗战略(如大幅增加国防预算、延长义务役、采购特定武器)。若台湾内部出现疑虑或希望与北京妥协,哈德逊的「无条件支持」将迅速转为「指责自由搭车」。**台湾在哈德逊的框架中是主体,但必须是服从美方战略的主体**。
2. **威慑升级与战争风险的内在冲突**:哈德逊主张通过展现绝对意志与实力来威慑北京,但清晰化承诺与军事部署本身可能触发北京先发制人的「使用或失去」恐慌。哈德逊常假设北京是理性的成本计算者(会被美方实力吓退),但同时假设北京是意识形态扩张者(无法被安抚)——这两个假设在极限压力下可能相互矛盾。
### 分析含义
分析哈德逊报告时,**不要问「这是否能威慑北京」,而要问「这如何重塑美台关系的权力结构」**。哈德逊的战略清晰,本质上是要求台北将自身的安全决策权更大程度地让渡给华盛顿,以换取华盛顿的绝对安全承诺。
---
## 近年涉台报告与政策辩论中的实际角色
### 角色一:战略模糊共识的「攻城锤」
2020 年以来,哈德逊成为挑战 Brookings/CSIS 建制共识的最强力者。Pillsbury、Cropsey、Pompeo 等人从军事、政治、意识形态全方位攻击战略模糊,迫使其捍卫者(如 Bush、Hass)处于守势。
**分析含义**:哈德逊在此辩论中的角色不是「提供替代方案」,而是**「制造危机感」**——通过不断强调「模糊等于邀请战争」,迫使建制派不得不将辩论焦点从「是否清晰」转向「如何清晰」,这本身就是鹰派的胜利。
### 角色二:台湾国际空间的「破冰船」
哈德逊是推动台湾国际参与最积极的智库。它不仅主张参与,更主张美方应单方面承认台湾地位、与台湾签署自由贸易协定、甚至推动台湾加入北约式安全网络。
**分析含义**:这些主张在短期内几乎不可能实现,但其功能是**「重新定义正常」**。当哈德逊提出「驻军台湾」时,原本激进的「联合演习」就显得像是温和的中间路线。
### 角色三:MAGA 派的对台政策输入源
哈德逊是少数能在 MAGA 派内部保持影响力的传统保守派智库。Pillsbury 对川普的影响,以及 Pompeo 的政治象征意义,使得哈德逊成为防止 MAGA 滑向孤立主义的关键力量。
**分析含义**:哈德逊在 MAGA 生态中的角色是**「将对抗中国包装为美国优先」**。分析时,需观察哈德逊如何将「防卫台湾」与「美国工人利益」、「保护半导体」与「打击中国偷窃」连结——这是其说服 MAGA 基本盘的核心修辞。
---
## 对台湾政府的「友好」姿态:赋权与剥夺的共生
### 事实层
哈德逊对台湾官方展现出华盛顿智库中最友好的姿态:
- 频繁邀请台湾高层(总统、外长、国安会秘书长)发表演讲或视讯对谈。
- Pompeo 等人访台时受到元首级礼遇。
- 报告中常使用「国家」称呼台湾,支持台湾主体性。
- 极少批评台湾政府的内政或两岸政策。
### 脉络层:友好作为规训
哈德逊的「友好」不是无条件的接纳,而是**一种高度选择性的赋权**。
1. **赋权**:给予台湾在华盛顿无法从建制派那里获得的尊严与政治空间,让台湾的声音以「主权主体」的形式被听见。
2. **剥夺**:这种赋权的前提是台湾必须扮演「抗中先锋」的角色。哈德逊的友好,实质上是将台湾锁定在「对抗最前线」的位置。当台湾试图寻求战略缓冲或展现自主性(如提出两岸和平倡议)时,哈德逊的友好将迅速冷却,甚至转为指责。
### 分析含义
分析台美互动时,**不要将哈德逊的友好视为「对台湾主体性的尊重」,而应视为「对台湾战略功能的确认」**。哈德逊给予台湾尊严,是为了让台湾更有意愿为美方的对抗战略流血。这种友好是工具性的,但在台湾内部政治中具有极高的动员效果。
---
## 在 MAGA 派内的影响:与孤立主义的危险共舞
### 事实层
哈德逊在共和党内的独特地位在于:它既是传统的新保守主义大本营,又成功渗透了 MAGA 派的核心圈。Pillsbury 被川普倚重,Pompeo 是 MAGA 派潜在的政治领袖。
### 脉络层:缝合「美国优先」与「全球对抗」
MAGA 派内部存在深刻的孤立主义倾向(「美国优先」= 不为外国打仗)。哈德逊的任务是证明:**防卫台湾不是为外国打仗,而是保护美国的核心利益**。
哈德逊的论述策略:
1. **半导体武器化**:将台积电等同于美国的国防工业基础,失去台湾等于失去科技霸权。
2. **威信论**:若不在台海展现意志,北京将得寸进尺,最终威胁美国本土。
3. **成本转嫁**:强调只要美方展现清晰承诺,战争就不会发生,因此防卫台湾是「零成本」的威慑。
### 分析含义
哈德逊与 MAGA 的结盟是**脆弱且功利性的**。若未来台海爆发危机,需要美方付出实质代价(如人员伤亡或经济制裁)时,MAGA 的孤立主义本能可能反噬哈德逊的鹰派主张。分析时,**必须区分哈德逊的「威慑承诺」与 MAGA 基本盘的「战争意愿」**——前者是战略计算,后者是情绪反应,两者在和平时期可以重合,在危机时期极易断裂。
---
## 与其他智库的立场对照
| 智库 | 立场定位 | 对战略模糊的态度 | 对台支持的核心逻辑 | 与 Hudson 的关键差异 |
|---|---|---|---|---|
| **Hudson** | 鹰派/战略清晰派 | 坚决反对,主张清晰化 | 意识形态对抗、击败北京、维持霸权 | 基准组 |
| **Brookings** | 建制温和派 | 维持,并重新诠释 | 维持现状、危机管控 | 视台湾为需管控的风险,Hudson 视台湾为击败对手的武器 |
| **CSIS** | 中立/偏中右,技术导向 | 内部多元,偏维持但接受升级 | 实力威慑、军事平衡 | 侧重数据与兵推,较少意识形态色彩;Hudson 是政治先行 |
| **Quincy** | 克制派/退缩派 | 反对,但主张减少承诺 | 避免战争、降低美方风险 | 主张美方应放弃台湾以避免核战,Hudson 主张为台湾不惜核战边缘 |
详见 `entities/foreign_actors/brookings.md`、`entities/foreign_actors/csis.md`、`entities/foreign_actors/quincy_institute.md`。
**分析含义**:Hudson 与 Brookings 的对立是「赢得竞争 vs. 管控风险」的对立;与 Quincy 的对立是「霸权圣战 vs. 霸权收缩」的对立。Hudson 与 CSIS 的差异最微妙:CSIS 提供战争可能发生的**数据**,Hudson 提供战争必须被威慑的**意志**。两者在政策结论上常趋同(如支持不对称防御),但底层逻辑截然不同。
---
## 关联分析
### 关联一:与「战略清晰化」议程的共生关系
哈德逊是战略清晰论述的主要生产者与受益者。分析战略清晰概念的演变时,必须追踪 Pillsbury 与 Cropsey 的近期文章——他们正试图将「清晰化」从「明确防卫承诺」扩展到「主动改变台海现状」(如推动台湾法理独立)。
**功能**: 提供脉络层——理解战略清晰如何从威慑工具演变为对抗工具。
### 关联二:与台湾官方的「越级沟通」管道
当台湾政府对华盛顿行政体系的缓慢进展感到沮丧时,常选择哈德逊作为发声平台。台湾总统多次选择在哈德逊发表重要演说,这是刻意绕过国务院的谨慎过滤,直接向美国会与保守派喊话。
**功能**: 提供事实层与脉络层——哈德逊不仅是观察者,也是台美非正式政治结盟的场域。
### 关联三:与 Brookings 的意识形态对抗
Hudson 学者常公开批评 Brookings 的战略模糊立场为「绥靖」与「自我欺骗」。Brookings 则批评 Hudson 的清晰化主张为「鲁莽」与「将台湾推向火坑」。这场辩论是美方涉台政策圈最核心的结构性张力。
**功能**: 用于对照——定位特定政策建议在光谱上的位置。Hudson 的存在使得 Brookings 的「温和」显得更像「保守」。
详见 `entities/foreign_actors/brookings.md`。
### 关联四:与 Quincy 的「镜像对立」
Quincy 主张美方应减少对台承诺以避免核战争,这与 Hudson 的「不惜代价威慑」截然相反。然而,两者共享一个前提:**当前的模糊政策已经失败**。分析时需区分:反对模糊是因为「它不够强硬」(Hudson)还是「它太具挑衅性」(Quincy)。
**功能**: 用于交叉验证——识别反建制阵营内部的极化裂痕。
详见 `entities/foreign_actors/quincy_institute.md`。
### 关联五:与 CSIS 的「数据-意志」互补与竞争
CSIS 的兵推报告常被 Hudson 引用作为「战争风险极高,必须清晰威慑」的证据。然而,CSIS 的报告也常得出「美方将付出惨重代价」的结论,这又被 Quincy 引用为「不应介入」的证据。同一组数据,在 Hudson 与 Quincy 的框架中得出相反结论。
**功能**: 用于对照——揭示数据本身不产生政策,框架才产生政策。
详见 `entities/foreign_actors/csis.md`。
### 关联六:与关键学者的个人网络
Pillsbury 与 Pompeo 的个人网络覆盖白宫、国防部与国会鹰派。分析哈德逊报告时,需结合 `entities/foreign_actors/key_us_taiwan_scholars.md` 中的学者关系图,识别报告是否反映了特定政治派别(如对川普影响力圈)的立场。
**功能**: 提供事实层——将机构立场还原为人际网络与政治利益。
详见 `entities/foreign_actors/key_us_taiwan_scholars.md`。
---
## 常见的分析陷阱
### 陷阱一:将哈德逊的「亲台」等同于「尊重台湾主体性」
错。哈德逊的亲台是**工具性的**。它支持台湾,是因为台湾是对抗北京的最有效武器。若台湾的战略价值下降(如半导体去台化成功),或台湾拒绝配合升级对抗,哈德逊的支持可能迅速消退。将其视为「无条件盟友」会严重误判其政策韧性。
### 陷阱二:假设哈德逊代表整个共和党或 MAGA 派
哈德逊是共和党鹰派的发声筒,但 MAGA 派内部存在强大的孤立主义力量(如 Tucker Carlson 派系)。在台海议题上,MAGA 基本盘的「美国优先」可能转化为「不为台湾而战」。假设哈德逊的主张能自动转化为共和党政策,是忽略了党内极化。
### 陷阱三:忽略哈德逊论述的「自我实现预言」风险
哈德逊主张「清晰威慑能避免战争」,但其推动的军事部署与政治宣示,可能被北京解读为「美方正在支持台湾独立」,从而触发北京先发制人的军事行动。分析时,不能只看威慑的意图,必须评估威慑行动在对方认知结构中产生的效果。
### 陷阱四:将哈德逊的兵推与情境分析视为「客观预测」
哈德逊的军事分析常预设最坏情况(如北京必然动武),并以此论证清晰化的必要。这些分析是**政策倡导工具**,而非客观概率评估。引用时必须标注「此为鹰派最坏情况假设」。
### 陷阱五:低估哈德逊移动 Overton Window 的能力
许多分析认为哈德逊的主张(如承认台湾、驻军)过于极端,不可能实现,因此不予重视。然而,哈德逊的功能正是通过提出极端主张,使得原本激进的措施(如高层访台常态化、军售升级)显得「温和」。低估其论述影响力,将无法解释为何建制派的立场正在被迫右移。
### 陷阱六:忽略资金来源对研究方向的微妙影响
哈德逊接受台湾政府资助是公开事实。这不必然导致结论偏颇,但可能影响**修辞强度**——在已有强烈意识形态动机的基础上,资金来源可能使其更倾向于生产符合台湾官方期待(即「美方应更强硬」)的报告,从而形成正向反馈循环。
---
## 仍不确定的部分
- **若 2028 年前北京对台实施全面封锁而非直接动武,哈德逊的「清晰威慑」框架是否有效**:当前框架主要针对动能攻击,对灰色地带胁迫的回应能力仍不确定。
- **MAGA 派若再次执政,哈德逊的鹰派主张与「美国优先」孤立主义的冲突将如何解决**:Pillsbury 等人能否继续说服川普派系仍属未知,若经济衰退,孤立主义可能压倒对抗主义。
- **台湾半导体去台化进程对哈德逊论述的冲击**:若台积电在美建厂顺利,台湾的「硅盾」价值在哈德逊框架中是否会被重新评估(从「不可替代的资产」降级为「可牺牲的前哨」),仍待观察。
- **哈德逊内部是否存在未公开的分歧**:在推动战略清晰的速度与程度上,Pillsbury 的政治路线与 Cropsey 的军事路线是否完全一致,抑或在「是否应推动台湾法理独立」上有潜在张力,目前缺乏公开证据。
---
## 与其他文件的关系
- Brookings:`entities/foreign_actors/brookings.md` —— 智库立场对照,战略清晰 vs. 战略模糊,对抗 vs. 管控
- Quincy Institute:`entities/foreign_actors/quincy_institute.md` —— 智库立场对照,霸权对抗 vs. 霸权收缩
- CSIS:`entities/foreign_actors/csis.md` —— 智库立场对照,意识形态驱动 vs. 数据技术驱动
- 关键美国台海学者:`entities/foreign_actors/key_us_taiwan_scholars.md` —— 学者个人网络与立场定位,追踪 Pillsbury 等人的政治影响力
- 战略模糊概念:`primitives/concepts/strategic_ambiguity.md` —— 哈德逊试图摧毁的核心概念
- 美对台政策分析框架:`primitives/frameworks/us_taiwan_policy_analysis.md` —— 理解鹰派操作逻辑
---
## 一句话浓缩
> 哈德逊研究所不是台海议题的客观分析站,而是**美方鹰派「战略清晰、意识形态对抗、不惜代价威慑」路线的知识引擎与政治攻城锤**;理解其报告,就是理解华盛顿保守派如何试图将台湾从「需管控的风险」重塑为「击败北京的武器」,以及这套论述如何在赋权台湾的同时,悄然剥夺台湾的战略自主。
---
last_updated: 2025-05-20
@@ -0,0 +1,54 @@
# 日本台湾交流协会台北事务所(Japan-Taiwan Exchange Association, Taipei Office)
---
**类型**: 机构档案
**当前角色**: 日本在台实质大使馆 / 日台非官方关系日方执行枢纽
**何时调用**: 分析日台日常互动、日方对台政策信号、驻台代表发言意涵、日台签证与经贸实务、2017年后日方对台名义升级、与AIT模式对照、台海危机时日方在台协调机制
---
## 一句话浓缩
日本台湾交流协会台北事务所是法律上的"财团法人"、实质上的"日本在台大使馆"——这种缺乏国内法保障的"行政拟制",比美方的 AIT 模式更脆弱、更依赖外务省的内部规制与日中关系的即时温度;2017 年的改名是安倍政府测试日中底线的结构性信号,而非单纯的名称更易。
---
## 交流协会的法律地位:民间外衣与行政拟制
### 设立基础与历史断点
1972 年 9 月,日本与中华人民共和国发表《中日联合声明》,建立外交关系并与中华民国断交。断交后,日方需要维持与台湾的实质关系管道,但受制于日中建交公报中"承认中华人民共和国政府是中国的唯一合法政府"的承诺,无法设立官方机构。
在此背景下,日本台湾交流协会(原"亚东关系协会")于 1972 年 12 月设立:
- **法律性质**:依据日本《民法》设立的**财团法人**(民间组织)
- **设立者**:日本外务省幕后主导,名义上由财界与亲台人士发起
- **资金来源**:外务省预算拨款(实质)+ 民间捐款(形式)
- **法律基础**:**没有类似美国《台湾关系法》的国内法授权**——这是与 AIT 最根本的差异
**分析含义**:交流协会的设立完全依赖日本行政部门的内部安排,缺乏国会立法保障。这意味着其存在与运作权限,随时可能因内阁更迭或日中关系变化而被单方面调整,制度韧性远低于 AIT。
详见 `entities/foreign_actors/japan_mofa_taiwan_policy.md`。
### 双重身份的日方版本
| 维度 | 法律身份 | 实质身份 |
|---|---|---|
| 性质 | 财团法人(民间组织) | 日本在台大使馆 |
| 资金 | 民间捐款与事业收入 | 外务省预算拨款 |
| 人员 | 交流协会"雇员" | 外务省、经产省等派任之现职官僚 |
| 职能 | "民间经济文化交流" | 全部实质外交与领事职能 |
| 法律保障 | 无专门国内法 | 依赖外务省内部规制与内阁判断 |
**分析含义**:日方的"行政拟制"比美方的"法律拟制"更不稳定。AIT 有《台湾关系法》作为国会监督与授权的框架,而交流协会仅是外务省的行政延伸。分析日台关系时,必须将此"制度脆弱性"纳入考量——日方对台任何实质升级,都面临比美方更大的内部法律制约与外部(北京)反弹风险。
---
## 组织结构:东京本部与在台驻点
### 东京本部
日本台湾交流协会的本部位于东京,是日台关系的日方指挥中枢:
- **会长**:通常由日本财界重量级人士或资深政治家出任(如已故�的�的�的�的大�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的大�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�Line truncated
@@ -0,0 +1,317 @@
# 日本自民黨涉台委員會與親台網絡
---
**類型**: 機構檔案
**當前角色**: 自民黨內部對台政策的協調、倡議與審查機制;執政黨親台網絡的制度化中樞
**成立時間**: 1990 年代末(以外交部會涉台小委員會等形式持續演進)
**何時調用此檔案**: 分析自民黨對台政策的黨內生成機制、政綱與競選宣言的涉台條款、黨內派系對台政策的博弈與妥協、日台執政黨交流(2+2等)的日方決策內幕、涉台立法在國會通過前的黨內審查、安倍派弱化後的親台網絡重組
---
## 一句話濃縮
自民黨涉台委員會與親台網絡是日本對台政策的「黨內引擎」——它不同於跨黨派的日華議員懇談會,而是深嵌於執政黨政調會(PARC)審查機制中的制度性權力節點,將親台派的政治意志轉化為具有政府約束力的政策綱領與法案,在安倍派弱化後正經歷從「強人主導」向「網絡分散化」的結構轉型。
---
## 制度性質:黨機器內部的對台政策引擎
### 法律性質與實質角色的張力
自民黨涉台委員會(主要指自民黨政務調查會外交部會下的「涉台小委員會」及相關聯席機制)在法律上僅為執政黨的內部政策研議單位,不具備任何國家行政權力或立法權。其決議僅為「黨議」,對外務省、防衛省等官僚體系無直接法律約束力。
然而,在日本「黨高政低」(執政黨政調會審查優先於內閣法案)的政治慣例下,自民黨的涉台委員會實質上掌握了對台政策的「否決權」與「發起權」。任何涉及台海安保、日台實質關係提升的政府法案或政策,在送交內閣會議前,必須經過政調會相關部會及小委員會的審查與事前批准。這使得涉台委員會成為官僚體系無法繞過的制度性關卡。
**分析含義**:分析日本對台政策時,不能僅盯住外務省的聲明或首相的表態。涉台委員會的動向——特別是其是否將某項倡議列入「黨的三役」(幹事長、政調會長、總務會長)的優先議程——是判斷政策能否落地的前置指標。若涉台委員會反對,即便外務省有意推進,政策也會在黨內審查階段被擱置。
### 自民黨涉台小委員會的組織結構
自民黨對台政策的黨內協調並非由單一委員會壟斷,而是由多個層級的機構疊加構成:
- **外交部會涉台小委員會**:隸屬於政調會外交部會,是專門處理對台議題的核心機構。成員由自民黨內關注台海議題的議員組成,負責研擬對台政策草案、聽取專家意見、與台灣執政黨進行黨際交流的對口單位。
- **安全保障調查會**:在台海安保議題上,外交部會涉台小委員會需與安保調查會協調。安保調查會負責防衛大綱、安保戰略等宏觀框架,涉台小委員會則負責將台海情境嵌入這些框架的具體表述。
- **經濟產業部會與經濟安全保障推進本部**:在半導體供應鏈、經濟韌性等涉台經濟議題上,這些機構與涉台小委員會存在管轄權重疊,需透過政調會高層協調。
**分析含義**:涉台小委員會並非封閉的黑箱,而是政調會內部網絡的節點之一。其影響力大小取決於是否能與安保、經產等強勢部會建立聯盟。當涉台小委員會的倡議與安保調查會的戰略方向一致時,政策推進阻力最小;當兩者出現張力(如安保需求要求強硬表態,但外務省系統擔憂外交摩擦),涉台小委員會往往成為黨內妥協的談判場。
---
## 與日華議員懇談會的分工與互補
### 黨內決策 vs. 跨黨派動員
自民黨涉台委員會與日華議員懇談會(日華懇)構成了日本親台網絡的「雙軌制」。兩者在人員上高度重疊,但在制度功能上嚴格分工:
| 維度 | 自民黨涉台委員會 | 日華議員懇談會 |
|---|---|---|
| **制度歸屬** | 執政黨內部機構(政調會體系) | 國會跨黨派議員聯盟 |
| **核心功能** | 政策研擬、黨綱制定、法案審查 | 議會外交、政治信號發送、跨黨派動員 |
| **產出性質** | 具黨議約束力,可轉化為政府政策 | 無法律約束力,僅具政治宣示意義 |
| **對北京的可否認性** | 低(執政黨行為即政府意志前兆) | 高(可辯稱為議員個人或國會行為) |
| **運作邏輯** | 制度化審查與派系妥協 | 寡頭主導的俱樂部式動員 |
**結構性命題**:日華懇是親台網絡的「擴音器」與「白手套」,負責在非官方框架下與台灣互動並向北京發送信號;涉台委員會則是「變速箱」,負責將親台意志轉化為執政黨的正式政策綱領。兩者互補但存在內在張力:日華懇的高調表態有時會超前於涉台委員會的政策準備,迫使黨內決策層被動回應;反之,涉台委員會的務實妥協也可能被日華懇的強硬派視為「退縮」。
詳見 `entities/foreign_actors/japan_taiwan_diet_members_consortium.md`。
### 人員重疊與功能分化
涉台委員會的核心成員幾乎都是日華懇的骨幹。古屋圭司等人同時在兩個平台上運作,這種重疊確保了信號與政策之間的銜接。然而,功能分化明顯:
- **日華懇場合**:古屋圭司以會長身份率團訪台,發表強烈支持台灣的聲明,向台灣提供政治再保證,向北京展示決心。
- **涉台委員會場合**:古屋圭司作為自民黨資深議員,需在政調會內部與其他部會協調,確保涉台倡議不會因過度挑釁北京而遭到外務省系統或黨內務實派的否決。
**分析含義**:分析時必須區分同一人物在不同場合的發言性質。在日華懇的公開聲明是「政治上限」,在涉台委員會的妥協方案是「政策底線」。兩者之間的落差,就是日本對台政策「說與做」的真實空間。
---
## 派系基礎與權力結構
### 安倍派的遺產與2024後的弱化
安倍派(清和會)長期是自民黨內親台網絡的核心支柱。安倍晉三的「台灣有事就是日本有事」論述,為涉台委員會推動安保政策轉型提供了最高級別的政治保護傘。在安倍影響力鼎盛時期,涉台委員會的倡議幾乎可以無阻力地進入黨決策核心。
2024年,安倍派因政治資金問題解散,這對親台網絡造成結構性衝擊:
- **失去頂層保護**:涉台委員會的倡議不再享有前首相派系的自動護航,在政調會審查中面臨更多來自其他派系的制衡。
- **選票重組**:原安倍派成員分散至無派系或新成立的政策集團,其對台立場的凝聚力下降。部分成員為了在新的權力格局中生存,可能在對台議題上採取更低調的姿態。
- **論述繼承權爭奪**:安倍留下的「台灣有事」論述遺產,目前由多個派系爭相繼承,但缺乏單一強人賦予其權威性。
**分析含義**:安倍派弱化不代表親台路線逆轉,而是意味著親台網絡從「金字塔型」轉向「網絡型」。涉台委員會的運作不再依賴單一派系的指令,而需要更複雜的跨派系協商。這降低了政策推進的速度,但可能提高了政策的跨派系正當性。
詳見 `entities/people/shinzo_abe_legacy.md` 及 `narratives/japan_taiwan/lpd_pro_taiwan_faction.md`。
### 岸田派與麻生派的務實親台路線
在安倍派弱化後,岸田派(宏池會)與麻生派(志公會)成為涉台委員會運作的關鍵支撐:
- **岸田派**:傳統上對華較為務實,但在岸田文雄執政期間,出於配合美國印太戰略及強化經濟安全的考量,實質上延續了安倍時期的對台政策框架。岸田派在涉台委員會中的角色是「穩定器」,確保政策不暴衝也不倒退。
- **麻生派**:麻生太郎長期支持台灣,其派系在涉台議題上具有高度一致性。麻生派在涉台委員會中扮演「推進器」,在關鍵時刻提供政治動能。
**破除常見簡化**:將岸田派的「務實」等同於「親中」或「反對親台」是錯誤的。岸田派對台政策的邏輯是「戰略遵從美國、經濟避險中國」,這與安倍派的「意識形態抗中」路徑不同,但在強化台海嚇阻的客觀效果上並無根本分歧。
### 派系重組下的網絡重構
當前涉台委員會正處於網絡重構期。原安倍派的親台骨幹(如萩生田光一、高市早苗等)仍在委員會中活躍,但他們的行動邏輯從「執行派系意志」轉變為「個人政治品牌經營」。這意味著:
- 涉台倡議更可能成為議員個人爭取曝光與黨內影響力的工具,而非派系整體戰略的一環。
- 委員會內部的協調難度增加,因為缺乏派系領袖的「一言九鼎」,不同意見的整合需要更長時間。
詳見 `entities/foreign_actors/ldp_pro_taiwan.md`。
---
## 核心人物與行動者網絡
### 古屋圭司:雙軌運作的樞紐
古屋圭司是當前自民黨涉台網絡中最關鍵的「守門人」。他同時擔任日華懇會長與自民黨涉台小委員會的核心顧問,這種雙重身份使他成為連接「外部信號」與「內部決策」的橋樑。
- **操作特徵**:古屋擅長在日華懇的高調訪台與涉台委員會的低調審查之間取得平衡。他會在訪台時釋放強烈信號,但在黨內協調時則表現出妥協意願,確保倡議不被務實派否決。
- **制約因素**:古屋的影響力建立在個人資歷與安倍時期的遺產上,缺乏強大派系支撐。在後安倍時代,他更像是一個協調者而非決策者。
**分析含義**:觀察古屋的發言場合是解讀政策信號的關鍵。若他在日華懇場合發言,代表親台派的「期望上限」;若他在政調會場合發言,代表黨內「可妥協的下限」。
### 岸信夫與安保路線的推手
岸信夫(安倍晉三之弟)曾任防衛大臣,是將台海安保議題嵌入自民黨防衛政策的核心人物。雖然因健康因素逐漸淡出第一線,但他建立的「台海安保與日本防衛不可分割」的政策路線,仍通過其派系殘餘影響力在涉台委員會與安保調查會中延續。
- **制度遺產**:岸信夫在防衛大臣任內推動的防衛白皮書明確寫入台海穩定重要性,這為涉台委員會後續推動安保相關倡議提供了法理與政策基礎。
### 中生代的崛起與路線差異
後安倍時代,涉台委員會中的中生代議員影響力上升。這些議員(如鈴木貴子、長島昭久等)的特徵是:
- **戰略導向**:相較於老一輩基於歷史情感與反共意識的親台,中生代更基於對中威脅的戰略認知支持台灣。
- **議題聚焦**:專注於經濟安全、供應鏈韌性、認知戰等非傳統安全領域,而非傳統的政治承認或主權議題。
- **美國連動**:其對台倡議往往與美國國會的相關法案(如《台灣關係強化法案》)形成呼應,藉由日美同盟框架增加黨內正當性。
**分析含義**:中生代的崛起意味著涉台委員會的關注焦點正在轉移。未來的涉台倡議可能更多體現在經濟安全法案的附帶條款中,而非單獨的「台灣決議」。
---
## 對自民黨對台政策的實際影響機制
### 政綱與競選宣言的制定
自民黨在每次眾議院選舉前發布的「政權公約」(競選宣言),是涉台委員會發揮長期影響力的核心場域。涉台委員會透過參與政調會的政綱起草過程,將對台支持表述嵌入執政黨的正式承諾中。
- **操作機制**:涉台小委員會提出建議案,經外交部會彙整後,提交政調會綜合協調,最終由黨高層(總裁、幹事長、政調會長)拍板納入政權公約。
- **實例**:2021年自民黨政權公約中首次明確寫入「台灣海峽的和平與穩定」,這是涉台委員會與安保調查會聯合推動的結果。
**分析含義**:政權公約中的涉台表述是觀察自民黨對台政策「錨點」的指標。一旦寫入公約,便成為政府施政的合法性基礎,外務省官僚必須在此框架內行事。分析時應對比歷次公約的措辭變化(如從「關切」升級為「重要性」再到「不可或缺」),這反映了涉台委員會推動的政策升級軌跡。
### 政策提案的升級路徑
涉台委員會的倡議要轉化為政府政策,需經歷嚴格的黨內審查程序:
1. **小委員會研議**:涉台小委員會通過建議案。
2. **部會承認**:外交部會(及相關聯席部會)審議通過。
3. **政調會審查會**:政調會高層協調,解決派系分歧。
4. **總務會承認**:自民黨最高決策機構確認,成為「黨議」。
5. **內閣送交**:政府根據黨議提出法案或修改政策。
**分析含義**:這一升級路徑意味著涉台委員會的倡議在每一個關卡都可能被稀釋或擱置。觀察倡議停留在哪個層級,是判斷黨內共識程度的精確方法。若僅停留在小委員會層級,代表僅是親台派的期望;若推進至總務會,則代表即將轉化為國家政策。
詳見 `entities/foreign_actors/japan_pm_office.md`。
### 對官僚體系的施壓與綁定
涉台委員會對外務省、防衛省等官僚體系的影響,主要透過「國會質詢腳本協調」與「預算審查」實現:
- **質詢協調**:涉台委員會成員在國會預算委員會等場合的質詢,往往事先與黨政調會及相關省廳協調。透過預設的質詢與答辯,迫使官僚在公開場合做出比內部決策更進步的表態,從而「綁定」官僚體系。
- **預算審查**:在防衛預算及ODA(政府開發援助)預算審查中,涉台委員會透過關注特定項目(如西南諸島防衛、對「同志國」基礎設施援助),引導資源流向實質上有利於台海嚇阻的領域。
**分析含義**:涉台委員會與官僚體系的關係是「既施壓又合作」。外務省內部的親台官僚有時會利用涉台委員會的壓力,作為對抗省內親中派或避險派的內部籌碼。分析時不應將委員會與官僚體系視為零和博弈,而應觀察兩者如何共謀突破政策限制。
---
## 重要涉台立法與政策推動
### ODA大綱修改與「同志國」概念
2023年日本修改ODA大綱(更名為「開發合作大綱」),引入對「同志國」提供防衛裝備及基礎設施援助的條款。這是涉台委員會與安保調查會聯合推動的結構性突破:
- **事實層**:大綱雖未點名台灣,但「同志國」概念明確涵蓋台灣。這為日本未來向台灣提供非致命性軍事裝備(如防災救難設備、監視雷達技術)或協助台灣強化基礎設施韌性開闢了法理空間。
- **脈絡層**:此舉是涉台委員會「切香腸」策略的典型——不直接挑戰「一中政策」的紅線,而是透過修改適用對象的定義,將台灣納入援助框架。
- **分析含義**:分析時應關注ODA預算的實際執行對象。若日本開始透過國際協力機構(JICA)與台灣的技術合作項目增加,即代表此政策突破已進入實質操作階段。
### 經濟安全保障推進法與供應鏈韌性
2022年通過的《經濟安全保障推進法》是涉台委員會與經產部會合作的產物。該法雖為國內法,但其「供應鏈韌性」條款直接指向台灣的半導體戰略地位:
- **事實層**:法案提供補貼吸引半導體廠商赴日設廠(如台積電熊本廠),並要求關鍵物資供應鏈多元化。
- **脈絡層**:涉台委員會在推動過程中,成功將「經濟依賴台灣」的脆弱性論述轉化為「強化日台經濟安全合作」的正當性,使對台經濟合作從單純的商業行為升級為安保戰略的一環。
**分析含義**:涉台委員會推動的立法往往不帶「台灣」之名,卻行「挺台」之實。分析時不應僅搜尋法案名稱中的「台灣」字樣,而應追蹤經濟安全、供應鏈、基礎設施等領域法案的實質影響。
### 防衛大綱與安保戰略的台海定位
2022年底修訂的《國家安全保障戰略》、《防衛計畫大綱》及《中期防衛力整備計畫》,明確將中國列為「前所未有的最大戰略挑戰」,並強調台海和平穩定的重要性。涉台委員會在此過程中的角色是:
- **論述嵌入**:確保「台海穩定是日本安全不可或缺的要素」這一表述被寫入戰略文件,為自衛隊的西南諸島防衛部署及反擊能力建設提供正當性。
- **跨國協調**:與美國印太戰略協調,確保日美安保共同聲明中持續包含台海條款。
---
## 與台灣政界的直接管道
### 黨對黨交流的制度化
自民黨涉台委員會與台灣執政黨(民進黨)的「黨際交流」(如日台執政黨2+2會談),是當前日台之間最具實質政策對話性質的管道:
- **事實層**:自民黨外交部會與民進黨國際事務部定期舉行線上或實體會議,討論安保、經濟、外交議題。
- **脈絡層**:這種黨對黨交流巧妙規避了官方關係的限制。自民黨作為執政黨,其外交部會的決議可直接影響政府政策;民進黨作為執政黨,其國際部負責人亦具備高度政策代表性。雙方在非官方名義下進行的,實質上是「準官方」的政策對話。
- **分析含義**:觀察2+2會談的出席層級與會後聲明,是評估日台實質關係深度的最佳指標。若自民黨出席者從年輕議員升級為政調會長級別,代表政策對話的權重提升。
### 繞過外務省的非正式溝通
涉台委員會在與台灣政界溝通時,有時會刻意繞過外務省的嚴格審查,透過議員個人的秘密渠道傳遞訊息。這種做法的風險在於:
- **優點**:能傳遞外務省不願放行的敏感訊息(如安保情報、政策底線),增進雙方戰略互信。
- **缺點**:若被媒體揭露,可能引發外務省的反彈,甚至導致日中外交風波,反而壓縮了涉台委員會後續的操作空間。
**分析含義**:涉台委員會與外務省的關係是「既合作又競爭」。分析時需注意,委員會的高調行動有時是向外務省施壓的手段,而非單純對台示好。
---
## 關聯分析
### 關聯一:與日華議員懇談會的功能互補
涉台委員會是「決策內核」,日華懇是「動員外殼」。日華懇負責向北京發送信號、向台灣提供再保證;涉台委員會負責將信號轉化為具約束力的黨議。兩者人員重疊但邏輯不同,分析時必須區分場合。
**功能**:提供結構層分析,解釋親台意志如何分化為「信號」與「政策」雙軌。
詳見 `entities/foreign_actors/japan_taiwan_diet_members_consortium.md`。
### 關聯二:與安倍遺產的繼承與稀釋
安倍派弱化後,涉台委員會失去了最高保護傘,但安倍的「台灣有事」論述已被委員會制度化為黨綱的一部分。當前的挑戰是:這一論述在缺乏強人護航下,能否抵禦經濟利益與務實外交的侵蝕。
**功能**:提供脈絡層分析,揭示派系重組對政策延續性的影響。
詳見 `entities/people/shinzo_abe_legacy.md`。
### 關聯三:與首相官邸的博弈
涉台委員會的倡議最終需首相官邸背書。岸田時期的特徵是「戰略遵從但戰術克制」——官邸接受委員會的安保邏輯,但控制推進速度以避免日中關係失控。分析委員會影響力,必須同時評估官邸的制約意願。
**功能**:用於交叉驗證,區分黨內倡議與政府實際政策底線。
詳見 `entities/foreign_actors/japan_pm_office.md`。
### 關聯四:與自民黨親台派總覽的對照
本檔案聚焦黨內組織化機制,而親台派總覽涵蓋更廣泛的派系生態與個人網絡。涉台委員會是親台派在制度內的「抓手」,親台派則是委員會的社會基礎。兩者對照有助於區分「組織行動」與「個人行動」。
**功能**:用於對照,避免將所有親台言行歸因於制度性決策。
詳見 `entities/foreign_actors/ldp_pro_taiwan.md`。
### 關聯五:與派系論述的關聯
涉台委員會的政策產出,是各派系在親台論述上妥協的結果。分析委員會的聲明措辭,可反推各派系在具體議題上的力量對比。
**功能**:提供事實層驗證,透過文本分析揭示派系博弈痕跡。
詳見 `narratives/japan_taiwan/lpd_pro_taiwan_faction.md`。
### 關聯六:與美國國會涉台行動的共振
涉台委員會的倡議往往與美國國會的涉台法案形成呼應。這種共振既是策略(藉美國壓力增加黨內正當性),也是結構性結果(日美同盟的戰略協調)。觀察兩者的時間差與措辭差異,可判斷日方是主動跟進還是被動配合。
**功能**:用於對照與交叉驗證,評估日本對台政策的自主性程度。
---
## 常見的分析陷阱
### 陷阱一:將涉台委員會的倡議直接等同於政府政策
錯。委員會的倡議需經政調會、總務會及內閣多層審查,每一層都可能稀釋或擱置。將委員會的建議案直接解讀為政策定局,會嚴重高估親台網絡的政策轉化率。
### 陷阱二:將安倍派弱化等同於親台路線逆轉
錯。安倍派弱化改變的是決策速度與保護傘效應,而非政策方向。「台灣有事」論述已被制度化為黨綱與安保文件,具有政策慣性。將派系重組誤讀為路線轉向,會錯判日本對台政策的穩定性。
### 陷阱三:混淆日華懇與涉台委員會的功能
日華懇是信號發送器,涉台委員會是政策引擎。將日華懇的高調聲明誤認為政策突破,或將涉台委員會的內部妥協誤認為退縮,都會導致誤判。兩者必須放在雙軌制中對照解讀。
### 陷阱四:忽略官僚體系的反制能力
涉台委員會雖有黨議約束力,但外務省等官僚體系掌握政策執行權與資訊優勢。官僚可透過技術性擱置、狹義解釋、程序拖延等方式架空委員會的倡議。忽略官僚的反制,會高估委員會的實際影響力。
### 陷阱五:將「非台灣命名」法案排除在涉台分析之外
涉台委員會推動的最重要立法(如經濟安全保障推進法、ODA大綱修改)往往不包含「台灣」字樣。若僅搜尋名稱含「台灣」的法案,會嚴重低估委員會的實質影響力。
### 陷阱六:假設黨際2+2會談具備官方決策性質
日台執政黨2+2會談雖具高度代表性,但仍屬非官方的政黨交流。將其結論直接等同於雙方政府的政策承諾,忽略了政黨交流不具條約約束力的事實。
---
## 仍不確定的部分
- **後安倍時代的跨派系協商效率**:安倍派弱化後,涉台委員會需依賴岸田派、麻生派及無派系議員的臨時聯盟。這種網絡化運作在面對重大危機時的決策效率,仍待觀察。
- **年輕世代議員的戰略忠誠度**:基於對中威脅認知而支持台灣的中生代議員,其立場是否會隨中美關係緩和或國內經濟壓力而動搖,尚不確定。與老一輩基於歷史情感的親台相比,其韌性可能較低。
- **ODA「同志國」條款的執行邊界**:大綱修改後,日本實際向台灣提供援助的類型與規模仍不確定。外務省在執行層面的保守傾向可能限制條款的適用範圍。
- **台海危機時委員會的實質角色**:在和平時期,涉台委員會是有效的政策推進器;但在軍事危機時,決策權將迅速集中於首相官邸與美日同盟軍事體系,委員會的邊緣化程度仍屬未知。
---
## 與其他文件的關係
- 自民黨親台派總覽:`entities/foreign_actors/ldp_pro_taiwan.md` —— 涉台委員會的人事基礎與派系網絡來源
- 日華議員懇談會:`entities/foreign_actors/japan_taiwan_diet_members_consortium.md` —— 涉台委員會的外部信號發送器與白手套
- 安倍遺產:`entities/people/shinzo_abe_legacy.md` —— 涉台委員會當前政策路線的意識形態源頭與失去的保護傘
- 派系論述:`narratives/japan_taiwan/lpd_pro_taiwan_faction.md` —— 解讀委員會聲明背後的派系妥協痕跡
- 首相官邸:`entities/foreign_actors/japan_pm_office.md` —— 涉台委員會倡議的最終否決者與背書者
---
## 一句話濃縮
> 自民黨涉台委員會是深嵌於執政黨政調會審查機制中的「政策變速箱」——它將日華懇的外部信號與親台派的政治意志,轉化為具有政府約束力的黨綱與法案,在安倍派弱化後從強人護航轉向網絡化協商,其推進速度雖受制於派系博弈與官僚反制,但政策方向因安保論述的制度化而具備高度慣性。
---
last_updated: 2025-05-19
@@ -0,0 +1,354 @@
# 日本外务省(涉台政策)
---
**类型**: 机构档案
**当前角色**: 日本行政体系中涉台实质关系的管辖者与形式上非官方框架的维护者
**何时调用**: 分析日台互动升级或降级、日本涉台声明措辞微调、日中围绕台湾问题的外交交锋、日本内部防卫与外交系统的协调、美日对台政策对齐
---
## 一句话浓缩
日本外务省是『1972 年体制』的形式守护者与涉台实质关系的操盘手——它在法律上否认对台官方关系,却在组织与功能上深度管理对台实质交往;理解外务省,就是理解日本如何在『一个中国』的紧箍咒下,最大化对台实质影响力。
---
## 外务省的法律与制度性质(关键基础)
### 1972 年体制的约束
日本对台关系的法律基础源于 **1972 年《中日联合声明》**:
- 日本承认中华人民共和国政府是中国的唯一合法政府
- 日本**『充分理解和尊重』**(十分理解し、尊重する)中国政府关于台湾是中华人民共和国领土不可分割一部分的立场
- 日本坚持遵循《波茨坦公告》第八条的立场(即日本放弃对台湾的权利,但不自行界定归属)
**与美方表述的关键差异**:美国对中方的一中立场是『认识到』(acknowledges),日本是『理解与尊重』。这并非法律上的承认,而是政治上的认知。外务省在此法律模糊带上建立了整套运作逻辑。
### 双重身份的运作
| 维度 | 法律/形式身份 | 实质身份 |
|---|---|---|
| 对台关系性质 | 非政府、民间往来 | 准官方的实质外交 |
| 交流机构 | 日本台湾交流协会(民间法人) | 外务省管辖的涉外执行机构 |
| 人员派驻 | 交流协会『职员』 | 外务省退休/现职官僚支援 |
| 涉台表态 | 关切『单方面改变现状』 | 对中国威慑的隐性安全承诺 |
**分析含义**:外务省的运作核心是『形式上的否认与实质上的掌控』并存。这种双重性不是虚伪,而是日本在美中台三角中最大化自身安全与利益的制度设计。分析时,不能因为其形式上的低调而低估其实质介入的深度,也不能因为其实质介入而误判其形式上的底线。
详见 `primitives/frameworks/japan_taiwan_policy_analysis.md`。
---
## 外务省的组织结构与涉台业务归属
### 核心部门:亚洲大洋洲局
涉台业务主要归属于**亚洲大洋洲局(亚大局)**,这是外务省内最具政策影响力的局之一:
- **中国・蒙古课**:名义上涉台事务归属此课。这是最关键的结构设定——台湾在组织法上被编入『中国』范畴。
- **中国・蒙古课内的『台湾班』**:实际处理对台实质业务的专门人员。虽无正式编制名,但为惯例设置。
- **区域政策课**:有时涉及台海安全的宏观政策协调。
### 结构性悖论:『中国课管台湾』
台湾业务归属『中国课』,产生了一个深刻的结构性矛盾:
1. **认知框架的内化**:负责对台业务的官僚,其日常业务与对中关系高度重叠。这导致官僚体系自然将台湾问题视为『中日关系的附属问题』,而非独立的对外关系。
2. **利益冲突**:同一位官僚既要维护对中关系的稳定,又要推进对台实质合作。当两者冲突时,对中关系(被视为更重要的战略利益)往往优先。
3. **信息隔离的困难**:对台沟通的内容,在对中交涉时可能成为筹码或必须隐瞒的信息。
**分析含义**:当观察到外务省在对台议题上表现出迟疑或退缩时,不要简单归因为『亲华』,而应理解为『中国课管台湾』这一组织结构导致的内生性约束。这是官僚理性,而非个人政治倾向。
### 其他涉台部门
- **经济局**:处理半导体供应链、台日经济合作(如 CPTPP 台湾入案)
- **国际法局**:处理台海法律战、航行自由的法律解释
- **领事局**:在日台湾人户籍、护照标记等实务问题
- **报道官・广报文化交流部**:对台公共外交与舆论战
---
## 对台关系的『非外交关系下的实质管理』
### 管道:日本台湾交流协会
外务省对台实质关系的执行载体是**日本台湾交流协会(日台交流协会)**:
- **法律性质**:日本民法上的一般财团法人(民间组织)
- **实质性质**:外务省的外围执行机构,相当于『日本在台大使馆』
- **人事结构**:理事长通常由外务省退休常务次官级人物出任;台北事务所长由外务省资深官员(常为大使级)担任
- **资金来源**:外务省预算补助与会员会费
**分析含义**:交流协会理事长与台北事务所长的人选,是判断外务省对台重视程度的指标。若由对中强硬派或资深安全背景人物出任,代表对台安全合作升级;若由传统中国学派出任,代表重视对中关系的稳定。
### 实质管理的范围
外务省通过交流协会管理的实质业务包括:
1. **政治沟通**:与台湾外交部、国安会的日常与危机沟通
2. **安全对话**:日台海巡、防务人员的二轨与一点五轨交流
3. **经济合作**:半导体供应链韧性、经贸对话
4. **国会外交**:日台议员交流(日华议员恳谈会)的幕后协调
5. **签证与领事**:在台签证发放业务
**分析含义**:外务省的实质管理是『只做不说』的典型。分析时,不应只看官方声明,而应追踪交流协会的人事异动、访台频次、以及日台议员互动的幕后推手。
---
## 对北京关系与对台关系的平衡机制
### 外务省的『双重遏制』逻辑
外务省在台海问题上的平衡,并非简单的『等距外交』,而是基于一种『双重遏制』的逻辑:
1. **对中遏制**:通过强化与美台的安全合作,威慑北京单方面改变现状。
2. **对台遏制**:通过坚持『1972 年体制』,防止台北采取法理独立行动将日本拖入战争。
这种逻辑体现在外务省的日常表态中:既反对中国武力威胁,也反对台湾改变现状。
### 平衡的操作手法
| 情境 | 对中操作 | 对台操作 |
|---|---|---|
| 台海紧张升级 | 呼吁和平解决、反对武力 | 私下传递克制信号 |
| 台湾寻求国际参与 | 不支持以主权国家身份加入 | 支持务实参与国际组织 |
| 台日双边升级 | 避免官方名义接触 | 以民间/议员名义推进 |
| 美国对台升级 | 跟进但可能滞后 | 私下鼓励、公开低调 |
**分析含义**:外务省的平衡机制是『非对称的』——对中关系的权重始终高于对台关系。分析时,若看到日台关系突破,必须追问:这是外务省主动为之,还是在美国压力或防卫省推动下的被动跟进?
---
## 外务省内部的派系:中国学派 vs 亲台派
### 中国学派(チャイナ・スクール)的支配地位
外务省内长期存在一个强大的**中国学派**:
- **特征**:通晓中文、长期在对中关系领域任职、重视中日关系稳定
- **逻辑**:中日关系是日本外交的基石之一,台湾问题不应破坏这一基石
- **影响**:控制亚大局中国课的人事与政策起草
**破除常见简化**:将中国学派等同于『亲华卖国』是错误的。他们的核心逻辑是**风险管理**——认为台海冲突将毁灭日本的安全环境,因此必须避免任何刺激北京的举动。这是基于国家利益的理性计算,而非对中国的情感认同。
### 亲台派的崛起与限制
近年来,外务省内也出现了更多**亲台倾向**的官僚:
- **来源**:多来自安全领域(如北美局、防卫省出向者)、经济局
- **逻辑**:台湾是日本安全与经济的关键节点,应提升实质关系
- **限制**:在组织结构上仍受中国学派主导的亚大局节制
**结构性命题**:外务省内的派系斗争,本质上是『风险管理优先』与『威慑力强化优先』两种战略逻辑的冲突。中国学派主张通过安抚中国来管理风险,亲台派主张通过强化威慑来管理风险。这两种逻辑并非不可调和,但在具体政策上常产生摩擦。
**分析含义**:当观察到外务省的公开声明与防卫省的评估出现温差时,这往往反映了外务省内两派博弈的临时结果。追踪具体官僚的升迁路径,可以预判政策重心的偏移。
---
## 重要涉台外务大臣的历史立场
外务大臣是外务省的最高政治任命官员,其立场直接影响涉台政策的政治天花板:
| 外务大臣 | 任期 | 涉台立场特征 | 关键事件 |
|---|---|---|---|
| 町村信孝 | 2004-2005 | 传统保守派,重视日美同盟 | 李登辉访日签证争议(最终发放) |
| 麻生太郎 | 2005-2007 | 亲台倾向明显 | 提出『自由与繁荣之弧』,台湾被纳入 |
| 岸田文雄 | 2012-2017 | 传统中国学派,谨慎平衡 | 2016年日台海洋事务对话启动 |
| 河野太郎 | 2017-2019 | 亲美强硬派,对中批判 | 积极推动印太战略,对台安全关注上升 |
| 茂木敏充 | 2019-2021 | 实务型,重视经济安全 | 沿袭安倍路线,对台实质合作深化 |
| 林芳正 | 2021-2023 | 中国学派代表 | 任内引发亲台派担忧,但政策未实质倒退 |
| 上川阳子 | 2023-2024 | 法务官僚出身,外交经验较浅 | 延续既有路线,对台表态跟随美方 |
| 岩屋毅 | 2024- | 重视与中韩关系 | 主张稳定对中关系,但承认台湾重要性 |
**分析含义**:外务大臣的个人立场设定了政策的『语气』,但外务省的官僚体系设定了政策的『语法』。即使亲台派大臣上任,也难以突破『1972 年体制』的形式框架;即使中国学派大臣上任,也难以逆转美日同盟强化下的对台安全合作趋势。分析时,应区分『修辞变化』与『结构变化』。
---
## 近年涉台公开声明的措辞演变
### 从『关切』到『重要性』的质变
外务省涉台声明的措辞演变,是观察日本对台政策升级的最精确指标:
| 阶段 | 时间 | 典型措辞 | 结构意义 |
|---|---|---|---|
| 模糊关切期 | 1972-1990s | 『期待和平解决』 | 将台湾问题视为中国内政的延伸 |
| 利益关切期 | 2000s-2010s | 『关乎日本安全』 | 开始将台湾问题与日本安全挂钩 |
| 明确定位期 | 2021- | 『台湾海峡的和平与稳定对国际社会极其重要』 | 将台海问题国际化,脱离中日双边框架 |
### 关键转折点:2021 年防卫白皮书与外交蓝皮书
2021 年是一个分水岭:
- **防卫白皮书**首次明确写入『台湾局势的稳定对日本的安全与国际社会很重要』
- **外交蓝皮书**将台湾定位为『极为重要的伙伴与重要朋友』
- 这些措辞虽由防卫省与外务省分别发布,但经过跨省厅协调,代表日本政府的整体立场升级
**分析含义**:措辞演变不是文字游戏,而是**法律与战略基础的重新锚定**。当台海问题从『中日间问题』变为『国际社会问题』,日本就获得了更大的介入空间——这是外务省在形式约束下拓展实质空间的经典手法。
---
## 与防卫省、首相官邸的协调
### 三角协调机制
涉台政策的实际决策,是外务省、防卫省、首相官邸(官邸)三方博弈的结果:
| 机构 | 角色定位 | 倾向 |
|---|---|---|
| 首相官邸 | 政治决断者 | 跟随首相战略意图,安倍时期推动升级,岸田时期谨慎平衡 |
| 防卫省 | 威胁评估者 | 最关注台海军事平衡,倾向强化威慑与对台合作 |
| 外务省 | 形式维护者 | 最关注外交冲击,倾向控制对台升级的幅度与速度 |
### 协调的张力
- **防卫省的『前瞻性』vs 外务省的『制动性』**:防卫省常基于安全逻辑推动对台合作,外务省则基于外交逻辑踩刹车。
- **官邸的『政治性』vs 外务省的『官僚性』**:官邸可能为了政治象征意义推动突破,外务省则担忧后续的外交成本。
**分析含义**:当看到日本对台政策出现突破时,通常是官邸与防卫省联合压倒外务省的结果;当看到政策停滞时,通常是外务省成功发挥了制动功能。分析时,必须识别具体政策背后的推手与阻力来源。
详见 `entities/foreign_actors/japan_pm_office.md` 与 `entities/foreign_actors/japan_self_defense_forces_southwest.md`。
---
## 与美方国务院的协调
### 美日对台政策的对齐机制
外务省与美方国务院的对齐,是日本涉台政策的关键外部约束:
1. **事前协商**:日本在涉台重大表态前,通常会与美方确认立场。
2. **措辞同步**:观察日美联合声明中的涉台段落,措辞常高度一致(如『强调台湾海峡和平稳定的重要性』)。
3. **滞后性**:日本的对台政策升级,往往滞后于美国——美国先突破,日本跟进。
### 对齐的内在矛盾
- **美方的『战略清晰』vs 日方的『战略模糊』**:美国日益倾向于明确表态防卫台湾,日本则坚持不明确回答『是否出兵』。
- **美方的『一中政策』vs 日方的『一中原则』**:美国的一中政策包含台湾关系法与六项保证,日本的一中原则则更受 1972 年联合声明约束。
**分析含义**:外务省对美协调的核心任务是**『跟上但不超越』**——既不能落后于美国太多导致同盟摩擦,也不能超越美国太多导致对中关系失控。分析时,应将日本的涉台表态放在美日协调的时间线上看,判断其是主动引领还是被动跟进。
---
## 关联分析
### 关联一:与首相官邸的从属与自主
外务省在涉台政策上从属于官邸的政治决断,但拥有巨大的**裁量权**:
- 官邸决定方向,外务省决定执行速度
- 官邸设定天花板,外务省决定地板
- 这种关系让外务省可以在不违抗官邸的前提下,通过行政拖延或措辞微调来影响政策
**功能**:提供脉络层——理解外务省行为的政治边界。
详见 `entities/foreign_actors/japan_pm_office.md`。
### 关联二:与防卫省的互补与牵制
防卫省提供安全逻辑,外务省提供外交逻辑。两者在台海议题上既互补又牵制:
- 互补:防卫省的威慑力为外务省的外交提供后盾
- 牵制:外务省的外交制约防卫省的过度升级
**功能**:用于对照——分析政策出台时的省厅博弈。
详见 `entities/foreign_actors/japan_self_defense_forces_southwest.md`。
### 关联三:与日本台湾交流协会的管辖关系
交流协会是外务省的执行手臂,但这一管辖关系是隐性的:
- 外务省不公开承认对交流协会的管辖
- 但人事、预算、政策方向均由外务省决定
**功能**:提供事实层——追踪对台实质关系的执行管道。
### 关联四:与美方国务院的同盟协调
美日在涉台政策上的协调,是外务省最重要的外部变量:
- 美国的压力可以打破外务省内部的惯性
- 但外务省也会以『对中关系特殊性』为由抵抗美方压力
**功能**:提供脉络层——理解日本涉台政策的外部动力。
### 关联五:与日华议员恳谈会的内外配合
日华议员恳谈会是国会层面的亲台组织,外务省与其形成『红白脸』配合:
- 议员做黑脸:推动突破性议案(如台湾旅行法)
- 外务省做白脸:维持形式上的非官方框架
**功能**:用于交叉验证——判断突破是官方意志还是议员自发。
### 关联六:与整体涉台机制的结构性位置
外务省是日本涉台机制中的『形式维护者』,与防卫省的『威慑强化者』、官邸的『政治决断者』共同构成三角结构。
**功能**:提供脉络层——定位外务省在整体机制中的角色。
详见 `entities/foreign_actors/japan_taiwan_policy_mechanism.md` 与 `primitives/frameworks/japan_taiwan_policy_analysis.md`。
---
## 常见的分析陷阱
### 陷阱一:将外务省的谨慎等同于亲华
外务省的谨慎源于风险管理逻辑,而非对中国的政治认同。将其简单标签为『亲华派』会忽略其官僚理性的本质。
### 陷阱二:忽略『中国课管台湾』的结构效应
台湾业务归属中国课,这不仅是组织安排,更是认知框架的塑造。分析时若忽略这一点,就无法理解外务省为何常将对台议题视为对中关系的附属。
### 陷阱三:高估公开声明的信息量
外务省的公开声明是多方妥协的产物,信息密度低。真正的政策信号藏在人事安排、交流协会活动、非公开对话中。
### 陷阱四:假设日本会完全跟随美国
日本有独立的对中关系考量(地理邻近、经济依赖),不会在所有涉台议题上完全跟随美国。外务省会在美日协调的框架内寻找自主空间。
### 陷阱五:将『战略模糊』视为缺乏战略
外务省坚持不明确回答『是否防卫台湾』,这不是没有战略,而是一种经过计算的威慑策略——让对手无法精确计算日本的反应,从而增加威慑的不确定性。
### 陷阱六:忽略官僚体系的连续性
外务大臣更替频繁,但官僚体系稳定。分析时若只看政治任命官员的表态,而忽略中国学派官僚的持续影响,会高估政策转向的幅度。
---
## 仍不确定的部分
- **危机时的真实反应**:外务省在台海危机时的实际操作流程仍是黑箱,未经实战检验。
- **内部派系的具体力量对比**:中国学派与亲台派在具体政策制定中的影响力权重,缺乏公开信息。
- **与台湾国安体系的热线机制**:是否已建立可靠的紧急沟通管道,外务省从未证实。
- **对中私下承诺的内容**:外务省在与中方的非公开对话中,是否就台湾问题做出了超越公开立场的口头保证,仍不确定。
- **岸田/石破后时代的政策惯性**:安倍时期的对台升级是否已制度化到不可逆转,还是随政治人物更替可能回调,仍需观察。
---
## 与其他文件的关系
- 首相官邸档案:`entities/foreign_actors/japan_pm_office.md` —— 理解外务省的政治天花板
- 防卫省西南诸岛防卫档案:`entities/foreign_actors/japan_self_defense_forces_southwest.md` —— 理解安全逻辑对外交的推力
- 日本涉台政策机制档案:`entities/foreign_actors/japan_taiwan_policy_mechanism.md` —— 理解整体决策流程
- 日本涉台政策分析框架:`primitives/frameworks/japan_taiwan_policy_analysis.md` —— 提供分析工具
- 美方国务院档案(占位):`entities/foreign_actors/us_state_dept.md` —— 理解美日协调
- 日本台湾交流协会档案(占位):`entities/foreign_actors/japan_taiwan_exchange_association.md` —— 理解执行管道
---
## 一句话浓缩
> 日本外务省是『1972 年体制』的守门人与涉台实质关系的操盘手——它在法律上否认对台官方关系,却在组织与功能上深度管理对台交往;其内部的『中国课管台湾』结构与『风险管理 vs 威慑强化』的派系博弈,决定了日本涉台政策的节奏与边界。
> 分析外务省时,不能只看公开声明的措辞——要看人事安排、交流协会活动、与防卫省的温差、以及美日协调的时间差。
> 外务省的『战略模糊』不是没有战略,而是一种经过计算的威慑与避险机制。
---
last_updated: 2025-05-19
Loaded 100 of 553 files, more files were not shown because too many files have changed in this diff. Show more