Compare commits

..
20 Commits
Author SHA1 Message Date
董宏凯 aa1f81a30a ceshi1 2026-05-13 22:08:37 +08:00
fengxiangdebaozi 1c50c73aa4 样式修改 2026-05-13 21:41:13 +08:00
fengxiangdebaozi 6cc28c7edd Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server 2026-05-13 21:25:40 +08:00
fengxiangdebaozi 1c2cf768f8 样式修改 2026-05-13 21:25:29 +08:00
吉鑫升 481e718d86 Merge remote-tracking branch 'origin/master' 2026-05-13 21:02:27 +08:00
吉鑫升andClaude Opus 4.7 29f74afbf6 fix(scheduler): give every scheduled run its own throwaway thread
All scheduled tasks for a user shared one scheduler thread, so each run
appended its messages to the same LangGraph checkpoint. Over time the
history blew past the model's input window and every task on that
account started failing with context-too-long.

_execute_task now mints a fresh thread_id per run and threads it through
ensure_thread_meta / snapshot / start_run / extract_*. The per-task
scheduler_thread_id column is left in place for backwards compatibility
but no longer drives the LLM call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:58:23 +08:00
fengxiangdebaozi 02111ab5b8 文档修改 2026-05-13 20:22:14 +08:00
吉鑫升 a2f7157bc3 chore: remove .claude/settings.local.json from tracking
Followup to previous commit which only landed the .gitignore rule but
left the file still tracked in the index.
2026-05-13 20:14:37 +08:00
吉鑫升 e6591abfbc chore: stop tracking .claude/settings.local.json
This file holds per-user Claude Code permission allowlists and should not
be shared across the team. Adding it to .gitignore and removing from the
index; existing working copies are preserved.
2026-05-13 20:14:00 +08:00
吉鑫升 50bda1ddee Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/pages/NewAgentPage.tsx
2026-05-13 20:10:21 +08:00
吉鑫升 31abad86d5 智能体配置 2026-05-13 20:04:06 +08:00
fengxiangdebaozi af7e90c87a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server 2026-05-13 18:23:25 +08:00
fengxiangdebaozi 2e4bdf122e 页面结构修改 2026-05-13 18:18:05 +08:00
吉鑫升 83d7fa26fe Merge remote master docs 2026-05-13 17:28:04 +08:00
fengxiangdebaozi efc4d89fdf 文档 2026-05-13 17:17:46 +08:00
吉鑫升 a27206c543 Update local frontend and backend changes 2026-05-13 17:17:42 +08:00
吉鑫升 faca2c9a2d Merge branch 'likun' into master 2026-05-13 17:02:22 +08:00
fengxiangdebaozi 0cbd137b4f wendang1xiug1 2026-05-13 16:38:34 +08:00
吉鑫升 aa034f8669 定时任务订阅模式 2026-05-13 14:59:27 +08:00
吉鑫升 839386f0b4 test: verify new remote push 2026-05-13 14:23:55 +08:00
642 changed files with 11455 additions and 189753 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
remote connectivity test - 2026-05-13
+12 -6
View File
@@ -1,13 +1,17 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
# Local runtime logs and pid files
.runtime/
# 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/
# 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
@@ -54,6 +58,8 @@ 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
@@ -0,0 +1,140 @@
# 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
@@ -0,0 +1,975 @@
# 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>DeerFlow Vite</title>
<title>CM 助手</title>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -50,6 +50,7 @@
"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",
+50 -155
View File
@@ -1,155 +1,50 @@
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>
);
}
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>
);
}
@@ -674,12 +674,22 @@ 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>
);
+2 -77
View File
@@ -1,14 +1,9 @@
"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 = {
@@ -30,11 +25,11 @@ export function Header({ className, homeURL }: HeaderProps) {
>
<div className="flex items-center gap-6">
<a
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
href={homeURL ?? "/"}
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">
@@ -51,77 +46,7 @@ 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,10 +1,6 @@
"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";
@@ -16,20 +12,9 @@ export function CommunitySection() {
Join the Community
</AuroraText>
}
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
>
<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>
<div />
</Section>
);
}
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
title="Agent Runtime Environment"
subtitle={
<p>
We give DeerFlow a &quot;computer&quot;, which can execute commands,
We give CM Assistant 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 DeerFlow with your own skill files, or use our built-in
Extend CM Assistant 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} />
@@ -0,0 +1,387 @@
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,11 +59,17 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [],
);
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
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) {
@@ -235,40 +241,46 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{isOwned && (
{(canEdit || isOwned) && (
<div className="flex gap-1">
<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>
{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>
)}
</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("/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("/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>
);
}
@@ -2,16 +2,15 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,14 +33,11 @@ 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,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
@@ -117,18 +113,13 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
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 { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const displayContent = content ?? "";
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
@@ -148,20 +139,8 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setAllowAutoViewSwitch(true);
@@ -329,7 +308,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -349,33 +328,7 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [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,
]);
}, [threadId, filepath, isInstalling]);
return (
<Artifact className={cn(className)}>
@@ -452,19 +405,10 @@ 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">
{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}>
<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}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -550,19 +494,13 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<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
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{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"
@@ -574,31 +512,21 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -613,17 +541,15 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
export function ArtifactFilePreview({
content,
language,
animated = false,
}: {
content: string;
language: string;
animated?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => {
@@ -654,15 +580,12 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
>
{content ?? ""}
</Streamdown>
</div>
);
}
if (language === "html") {
return (
@@ -34,27 +34,23 @@ const customLightTheme = basicLightInit({
},
});
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;
}) {
export function CodeEditor({
className,
placeholder,
value,
readonly,
disabled,
autoFocus,
settings,
}: {
className?: string;
placeholder?: string;
value: string;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
}) {
const {
thread: { isLoading },
} = useThread();
@@ -81,8 +77,8 @@ export function CodeEditor({
className,
)}
>
{isLoading && streamingReadonlyFallback ? (
<Textarea
{isLoading ? (
<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!",
@@ -109,11 +105,10 @@ export function CodeEditor({
lineNumbers:
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
}}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
autoFocus={autoFocus}
value={value}
/>
)}
</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("/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("/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>
</>
);
}
@@ -2,9 +2,8 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
FileTextIcon,
GraduationCapIcon,
CheckIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
PlusIcon,
@@ -123,10 +122,9 @@ 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";
writing_mode?: boolean;
};
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
threadId: string;
@@ -136,11 +134,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";
writing_mode?: boolean;
},
) => void;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
@@ -239,22 +236,15 @@ export function InputBox({
[onContextChange, context, supportThinking],
);
const handleReasoningEffortSelect = useCallback(
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
[onContextChange, context],
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -515,28 +505,8 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</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>
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
<PromptInputActionMenu>
<ModeHoverGuide
mode={
context.mode === "flash" ||
@@ -329,43 +329,41 @@ 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;
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);
}
} 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);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath) {
return;
}
select(artifactPath);
setOpen(true);
}}
>
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -1,14 +1,14 @@
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 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 {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -42,17 +42,21 @@ export function MessageList({
className,
threadId,
thread,
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>;
}) {
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>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
@@ -83,13 +87,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}
@@ -99,6 +103,23 @@ 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) {
@@ -246,150 +267,289 @@ 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;
};
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;
};
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>
);
}
@@ -117,14 +117,7 @@ export function RecentChatList() {
const handleShare = useCallback(
async (thread: AgentThread) => {
// 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)}`;
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied);
@@ -1,67 +1,18 @@
/**
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
* About CM Assistant markdown content. Inlined to avoid raw-loader dependency
* (Turbopack cannot resolve raw-loader for .md imports).
*/
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
export const aboutMarkdown = `# About CM Assistant
> **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**.
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.
---
## 🚀 Core Features
* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow can do almost anything.
* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant 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,55 +1,15 @@
# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
# About CM Assistant
> **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.
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.
---
## 🌟 GitHub Repository
## Core Features
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.
* **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.
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
try {
setIsExporting(true);
const exportedMemory = await exportMemory();
const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
type: "application/json",
});
@@ -1,10 +1,19 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
@@ -23,9 +32,16 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/core/i18n/hooks";
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useEnableSkill,
useInstallSkillUpload,
useSkills,
useValidateSkillUpload,
} 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";
@@ -58,6 +74,7 @@ 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),
@@ -78,7 +95,16 @@ function SkillSettingsList({
</TabsList>
</Tabs>
</div>
<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>
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
@@ -110,6 +136,10 @@ function SkillSettingsList({
</ItemActions>
</Item>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
/>
</div>
);
}
@@ -133,3 +163,265 @@ 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,9 +15,6 @@ 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,
@@ -90,18 +87,6 @@ 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="/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="/page/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 === "/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>
);
}
"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>
);
}
+2 -2
View File
@@ -74,7 +74,7 @@ export async function checkAgentName(
);
} catch {
throw new AgentNameCheckError(
"Could not reach the DeerFlow backend.",
"Could not reach the CM Assistant 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 DeerFlow backend.",
"Could not reach the CM Assistant backend.",
"backend_unreachable",
);
}
-40
View File
@@ -1,40 +0,0 @@
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();
}
+16 -101
View File
@@ -1,65 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
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;
}
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
export function useArtifactContent({
filepath,
@@ -81,48 +25,19 @@ 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,
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,
},
);
},
});
}
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,
};
}
+2 -3
View File
@@ -1,3 +1,2 @@
export * from "./api";
export * from "./download";
export * from "./loader";
export * from "./download";
export * from "./loader";
+39 -16
View File
@@ -61,11 +61,11 @@ export const enUS: Translations = {
welcome: {
greeting: "Hello, again!",
description:
"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.",
"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.",
createYourOwnSkill: "Create Your Own Skill",
createYourOwnSkillDescription:
"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.",
"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.",
},
// Clipboard
@@ -208,13 +208,13 @@ export const enUS: Translations = {
save: "Save agent",
saving: "Saving agent...",
saveRequested:
"Save requested. DeerFlow is generating and saving an initial version now.",
"Save requested. CM Assistant 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 DeerFlow could not load it yet. Please refresh this page in a moment.",
"The agent was created, but CM Assistant 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: "DeerFlow's official website",
githubTooltip: "DeerFlow on Github",
officialWebsite: "CM Assistant's official website",
githubTooltip: "CM Assistant on Github",
settingsAndMore: "Settings and more",
visitGithub: "DeerFlow on GitHub",
visitGithub: "CM Assistant on GitHub",
reportIssue: "Report a issue",
contactUs: "Contact us",
about: "About DeerFlow",
about: "About CM Assistant",
},
// Conversation
@@ -251,7 +251,7 @@ export const enUS: Translations = {
// Page titles (document title)
pages: {
appName: "DeerFlow",
appName: "CM Assistant",
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 DeerFlow",
skillInstallTooltip: "Install skill and make it available to CM Assistant",
},
// Subtasks
@@ -315,7 +315,7 @@ export const enUS: Translations = {
actions: "Actions",
keyboardShortcuts: "Keyboard Shortcuts",
keyboardShortcutsDescription:
"Navigate DeerFlow faster with keyboard shortcuts.",
"Navigate CM Assistant 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 DeerFlow looks and behaves for you.",
description: "Adjust how CM Assistant looks and behaves for you.",
sections: {
appearance: "Appearance",
memory: "Memory",
@@ -335,7 +335,7 @@ export const enUS: Translations = {
memory: {
title: "Memory",
description:
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
empty: "No memory data to display.",
rawJson: "Raw JSON",
exportButton: "Export memory",
@@ -433,20 +433,43 @@ 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 DeerFlow.",
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
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:
"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.",
"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.",
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: "DeerFlow",
testTitle: "CM Assistant",
testBody: "This is a test notification.",
notSupported: "Your browser does not support notifications.",
disableNotification: "Disable notification",
@@ -354,9 +354,30 @@ 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;
+38 -16
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,20 +414,42 @@ 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: "关闭通知",
+38 -18
View File
@@ -14,17 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSubagentGroup;
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
@@ -41,10 +44,11 @@ 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 &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
) {
return last;
}
@@ -66,16 +70,23 @@ 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 {
messages: [message],
});
} else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
open.messages.push(message);
@@ -286,10 +297,19 @@ export function hasPresentFiles(message: Message) {
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
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 extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
+287 -216
View File
@@ -1,216 +1,287 @@
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",
}),
);
}
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",
}),
);
}
+148 -111
View File
@@ -1,111 +1,148 @@
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"],
}),
}),
};
}
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,
}),
};
}
@@ -0,0 +1,274 @@
/**
* 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);
}
+101 -67
View File
@@ -1,67 +1,101 @@
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;
};
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;
};
+5 -6
View File
@@ -5,12 +5,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
enabled: true,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
};
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
+49
View File
@@ -34,6 +34,18 @@ 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(
@@ -61,3 +73,40 @@ 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>;
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill } from "./api";
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
import { loadSkills } from ".";
@@ -29,3 +29,20 @@ 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"] });
},
});
}
+3 -5
View File
@@ -14,11 +14,9 @@ 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";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
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
? `/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";
}
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";
}
+213 -222
View File
@@ -1,233 +1,224 @@
"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 });
}
},
});
"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 });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
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 });
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { 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(`/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(`/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}
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>
);
}
+190 -216
View File
@@ -1,101 +1,89 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
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);
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);
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();
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}#/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;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
}, [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 });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
@@ -103,135 +91,121 @@ export default function ChatPage() {
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
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: [] });
void sendMessage(threadId, message);
},
[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("/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("/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>
);
}
+477 -517
View File
@@ -1,517 +1,477 @@
"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>
);
}
"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>
);
}
+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="/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="/page/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
@@ -0,0 +1,40 @@
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={`/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={`/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>
);
}
@@ -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("/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>
);
}
"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>
);
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
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
+425 -424
View File
@@ -1,424 +1,425 @@
# 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.
# 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.
@@ -89,6 +89,12 @@ 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:
@@ -203,6 +209,29 @@ 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,
@@ -322,7 +351,10 @@ 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)
current = await _get_owned_or_raise(store, agent_id, user_id)
is_admin = _is_admin_user(http_request)
current, edit_mode = await _get_editable_or_raise(
store, agent_id, user_id, is_admin=is_admin
)
fields_set = request.model_fields_set
update_data: dict[str, Any] = {}
@@ -354,7 +386,12 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
if request.soul is not None:
_write_agent_soul(agent_id, request.soul)
record = await store.update(agent_id, user_id, update_data) if update_data else current
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)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -4,38 +4,21 @@ 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 pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
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:
@@ -197,45 +180,4 @@ 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)})
@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")
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
@@ -1,327 +1,419 @@
"""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}
"""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}
@@ -1,8 +1,9 @@
import json
import logging
import tempfile
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
@@ -56,6 +57,20 @@ 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):
@@ -100,6 +115,110 @@ 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,10 +110,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"is_plan_mode",
"reasoning_effort",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
@@ -0,0 +1,126 @@
# 后端启动与停止说明(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`。
@@ -0,0 +1,825 @@
# 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*
@@ -1,271 +0,0 @@
# 台湾选举分析知识库 — 顶层入口
模型每次分析前必读此文件。本文件不含实质政治知识,仅定义工作流程、知识库结构、与必须遵循的元规则。
---
## 一、本知识库的目的与边界
### 目的
为 2028 年台湾大选相关的研究分析提供:
- 结构化的分析框架与方法论
- 政治行动者、议题、事件的档案
- 标准化的分析流程(playbooks)
### 适用任务类型
- 政治论述与发言的解读
- 选情分析与情境推演
- 两岸议题分析
- 美台、日台关系动态分析
- 国内政党与派系动态分析
- 舆论事件的深度解读
- 历史类比与趋势追踪
### 不适用任务类型
- 经济、科技、法律等非政治领域的专门问题
- 个人隐私、未公开通讯
- 未经查证的指控
- 撰写政治宣传、竞选文案、攻击性内容
- 价值评判("X 党是好/坏"、"应该/不应该")
遇到不适用任务,应明确告知"超出本知识库设计范围",不勉强回应。
---
## 二、知识库结构
| 目录 | 角色 | 何时读 |
|---|---|---|
| `_FILE_INDEX.md` | 全局文件索引:列出所有正文知识文件的路径和标题 | routing 或各级 `_MANIFEST.md` 未命中具体文件时作为兜底目录 |
| `routing/` | 分诊层:根据问题类型选择该读哪些其他文件 | 接到任务后第一站 |
| `playbooks/` | 操作手册:针对各类分析任务的具体流程 | 每次分析必有至少一个 |
| `primitives/` | 分析工具:概念定义、理论框架、方法论 | 当 playbook 调用时 |
| `entities/` | 行动者档案:人物、政党、机构、媒体、外国行动者 | 当分析涉及具体行动者 |
| `audiences/` | 受众层:世代、政治认同、社会身份群体 | 当分析涉及传播对象、议题敏感点、认知脆弱点或选民分层 |
| `narratives/` | 叙事层:政治论述与其变化 | 当分析涉及政治论述时 |
| `events/` | 事件层:历史事件、时间线、重复模式 | 当需要事件背景或类比时 |
| `live/` | 近期态势层:每日热点事件简报 | 当问题涉及今天、最近、当前、最新进展时 |
| `data_pointers/` | 实时数据接口说明 | 当需要当前素材时 |
| `reference/` | 参考资料:术语、人物索引、对照表 | 按需查询 |
| `analysis/` | 专题分析层:跨文件综合研判与选举前景 | 当问题需要综合判断或情境推演 |
| `policies/` | 政策资料层:具体政策细节和政策争议 | 当问题涉及具体政策内容 |
---
## 三、接到任务的标准工作流
### 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_audience.md`
- 任务涉及"美国对台军售/军售流程/军售反制/军售法理/军售舆论" → `routing/by_arms_sales.md`
- 任务以"事件"为核心 → `routing/by_event.md`
- 任务涉及多重视角 → `routing/by_analytical_lens.md`
- 任务以时间范围为核心 → `routing/by_timeframe.md`
- 任务涉及多议题/多人物 → 加读 `routing/cross_reference_rules.md`
routing 文件会进一步指引该读哪些具体内容文件与 playbook。
### Step 2.5: 兜底定位文件
若 Step 2 的 routing 文件没有覆盖用户提到的具体对象、议题、文件名或长尾概念,不要直接判断“知识库未涵盖”。应按以下顺序继续定位:
1. 读取对应一级目录或二级目录的 `_MANIFEST.md`,查看是否存在近似文件。
2. 若仍未命中,读取 `_FILE_INDEX.md`,按关键词检索全库正文文件。
3. 找到候选文件后,再回到对应目录 `_MANIFEST.md`、相关 routing 或 `cross_reference_rules.md`,确认应联读哪些上下游文件。
4. 若正文文件中出现指向不存在路径的旧引用或生成时残留引用,不要在该路径上反复尝试;应抽取其关键词,到 `_FILE_INDEX.md` 中查找当前真实文件。
5. 若 `_FILE_INDEX.md` 也没有候选文件,才说明“知识库未涵盖此问题”。
注意:`_FILE_INDEX.md` 只是路径发现工具,不替代 routing、playbook 或正文知识。
### Step 3: 选定 playbook
根据 routing 指引,选 1-3 个 playbook。多 playbook 时遵循 `playbooks/multi_playbook_orchestration.md` 的组合规则。
若任务涉及受众画像、选民群体、传播对象或认知脆弱点,优先考虑:
- `playbooks/audience_profile_analysis.md` — 分析某一受众群体自身特征
- `playbooks/narrative_audience_match.md` — 分析某一叙事会影响哪些受众、为何影响、如何扩散
### Step 4: 按 playbook 流程执行
playbook 每个 step 会指示读取具体的 entities / narratives / events / primitives 文件。**严格按 playbook 流程执行**,不要自行裁断要不要某步。
### Step 5: 调用近期态势素材(如需要)
若分析涉及当前事件、近期资料或最新态势,读取 `live/` 中的每日热点文件。
当前实施方式:
1. 先读取 `live/_MANIFEST.md`,确认每日热点文件的用途、限制和读取规则。
2. 根据用户问题中的日期表达选择文件:
- 指定日期 → 读取对应 `live/YYYY-MM-DD.txt`
- “今天” → 读取当前日期文件
- “最近/近期/当前” → 优先读取最近 1-3 个日期文件,先读最新文件
3. 从每日热点中筛选与用户问题相关的事件,不要机械复述全部事件。
4. 将近期事件放回 Step 3-4 已建立的稳定知识框架中分析。
重要约束:
- `live/` 是近期素材层,不是稳定知识本体。
- 不要先读 `live/` 就直接回答;必须先通过 routing/playbook/稳定知识建立分析框架。
- 不要把单日热点直接当作长期趋势。
- 若 `live/` 中信息来源复杂或可信度不一,应在输出中说明其来自每日热点整理,必要时提示仍需核验。
### 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: 近期态势素材必须经过审视
近期态势素材(`live/` 每日热点文件中的内容)是"分析对象",不是"分析框架"。
不要把素材中的观点直接吸收为知识库的稳定知识。
使用 `live/` 时,输出必须区分:
- 稳定知识背景
- 每日热点中的近期事件事实
- 基于二者形成的分析推论
### 元规则 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-22
- `live/` 每日热点态势层接入:2026-05-18
- p1-p6 知识文件补充后,已整体刷新各级 MANIFEST 与 routing 路由文件:2026-05-20
- p7 受众层知识文件与受众分析 playbook 接入:2026-05-21
- p8 美国对台军售专题知识、军售专用 routing 与军售综合 playbook 接入:2026-05-22
- 全局 `_FILE_INDEX.md` 兜底索引接入:2026-05-22
- 当知识库结构有重大改动时,本文件应同步更新
- 当宪法文件有改动时,本文件应反映新的工作流
---
## 八、最简版工作流(提醒卡)
如果模型读到这里只能记住一件事,应该是这个流程:
```
1. 读 4 份宪法文件
2. 走 routing → 选 playbook
3. 按 playbook 执行
4. 自检 → 输出
5. 不知道就说不知道
```
其他都是这五步的展开。
@@ -1,510 +0,0 @@
# Knowledge Base 全局文件索引
最后更新:2026-05-22
本文件是 `knowledge_base` 的兜底总目录,用于在 routing 或目录 `_MANIFEST.md` 未直接命中时,帮助模型按文件路径定位知识文件。
使用规则:
- 优先走 `INDEX.md` 指定的 routing/playbook 流程。
- 若 routing 未覆盖具体对象、议题或文件名,再读取本文件按关键词定位候选文件。
- 定位后仍应回到对应目录 `_MANIFEST.md` 或相关 routing,确认上下游联读关系。
- 本文件不替代正文知识,只提供路径发现能力。
文件总数:473
## analysis/
- `analysis/elections/2028_election_outlook.md` — 2028 台湾选举前景分析框架;**当前角色**: 总统大选整合分析工具
## audiences/
- `audiences/generational/generation_comparison.md` — 世代受众对照分析;**当前角色**: 多维对照分析工具 / 选民结构拆解框架
- `audiences/generational/middle_age_36_55.md` — 中年世代受眾(36-55歲)
- `audiences/generational/senior_56plus.md` — 中老年世代受眾(56 歲以上)
- `audiences/generational/youth_18_35.md` — 青年世代受众(18-35 岁);**当前角色**: 受众分析 / 政治叙事载体
- `audiences/political_identity/deep_blue.md` — 深藍受眾
- `audiences/political_identity/deep_green.md` — 深綠受眾
- `audiences/political_identity/light_blue.md` — 淺藍受眾
- `audiences/political_identity/light_green.md` — 淺綠受眾
- `audiences/political_identity/political_identity_overview.md` — 政治認同光譜總覽 [TW_POLITICAL_IDENTITY_SPECTRUM]
- `audiences/political_identity/politically_apathetic.md` — 政治冷感族群
- `audiences/political_identity/swing_voters.md` — 摆荡选民;**当前角色**: 分析对象与选举预测关键变量
- `audiences/political_identity/white_camp_audience.md` — 白營受眾
- `audiences/social_identity/farmers_fishers.md` — 農漁民族群受眾分析
- `audiences/social_identity/hakka_community.md` — 客家族群受眾分析
- `audiences/social_identity/hoklo_community.md` — 本省閩南族群
- `audiences/social_identity/industrial_workers.md` — 產業工人族群
- `audiences/social_identity/mainlander_community.md` — 外省/眷村族群受眾分析
- `audiences/social_identity/military_civil_education.md` — 軍公教族群受眾分析
- `audiences/social_identity/service_industry.md` — 基層服務業從業人員
- `audiences/social_identity/tech_sector.md` — 科技業從業人員
## data_pointers/
- `data_pointers/data_quality_warnings.md` — 知识库数据质量警示框架;**当前角色**: 元方法论工具(知识库数据质量校准与输入端过滤)
- `data_pointers/event_document_format.md` — 事件知識文件格式規範
- `data_pointers/tao_press_archive.md` — 國台辦新聞發布資料指引
## entities/
- `entities/alliances/us_japan_alliance_and_taiwan.md` — 美日同盟与台湾;**当前角色**: 台海安全架构的核心制度基础 / 印太战略的轴心
- `entities/equipment/anti_ship_missiles_taiwan.md` — 反舰飞弹(雄风、鱼叉);**当前角色**: 台湾拒止登陆的核心战力 / 豪猪战略的实体「刺」 / 国造与美制军事体系的竞合场
- `entities/equipment/drones_taiwan.md` — 台灣無人機發展敘事
- `entities/equipment/ew_capabilities.md` — 电子战(EW)能力;**当前角色**: 台湾防卫体系的电磁频谱守门员 / 杀伤链存续的关键变量 / 美台军事技术协作的最深黑箱
- `entities/equipment/f16v_taiwan.md` — F-16V 战斗机案(F-16V Combat Aircraft Program);**当前角色**: 台湾空军核心战机 / 美台军售政治象征 / 豪猪战略辩论焦点
- `entities/equipment/himars_taiwan.md` — 海馬士(HIMARS)多管火箭系統
- `entities/equipment/indigenous_submarine.md` — 国造潜舰(IDS);**当前角色**: 台湾国防自主核心象征 / 海军不对称威慑支柱 / 豪猪战略辩论焦点 / 对外技术整合测试案
- `entities/equipment/patriot_taiwan.md` — 爱国者飞弹(PAC-3);**当前角色**: 台湾防空体系核心锚点 / 美对台军售常项
- `entities/equipment/stinger_javelin_taiwan.md` — 毒刺与标枪——便携式防空 / 反战车飞弹叙事;**当前角色**: 豪猪战略核心装备 / 不对称防卫论述的物质载体
- `entities/equipment/tank_armor_taiwan.md` — M1A2T 战车与陆军装甲叙事;**当前角色**: 台湾陆军核心战车 / 豪猪战略辩论标靶 / 城镇与滩岸作战角色争议焦点
- `entities/equipment/us_origin_weapon_allocation_matrix.md` — 美制装备在台湾军种中的配属矩阵;当前角色: 美制装备在台湾军种中的配属矩阵分析框架
- `entities/foreign_actors/ait.md` — 美国在台协会(American Institute in Taiwan, AIT);**何时调用**: 分析美台官方互动管道、AIT 馆长涉台发言、美方对台态度的"日常温度"、川普 2.0 时期 AIT 的运作变化
- `entities/foreign_actors/beijing_mofcom_sanctions.md` — 北京商務部及其對台制裁工具
- `entities/foreign_actors/brookings.md` — 布鲁金斯学会涉台研究(Brookings Institution Taiwan Studies);**当前角色**: 华盛顿建制派智库、温和派/战略模糊维持派的知识大本营
- `entities/foreign_actors/ccp_taiwan_work_leadership.md` — 中共中央对台工作领导小组
- `entities/foreign_actors/china_defense_ministry_arms_sales_response.md` — 国防部对美售台武器的发声模式;**当前角色**: 北京对美售台武器军事回应的官方信号发射器、中美两军关系危机的预警阀、解放军物理反制行动的政治背书者、将外交抗议转译为军事威慑的修辞机器
- `entities/foreign_actors/china_foreign_ministry_arms_sales_response.md` — 外交部对美售台武器的发声模式;**当前角色**: 对美售台武器的一线法理定性主体与官方口径基准
- `entities/foreign_actors/csis.md` — CSIS 战略与国际研究中心(Center for Strategic and International Studies);**何时调用**: 引用 CSIS 涉台报告时;分析美国主流智库对台立场;评估"中国实力计画"等具体项目;理解智库与政府的人事流动
- `entities/foreign_actors/donald_trump.md` — 唐纳·川普(涉台立场专档) [US_PERSON_TRUMP_TAIWAN];**当前角色**: 美国第 47 任总统(2025.1- );美台关系与两岸格局的最大外部变数
- `entities/foreign_actors/eu_external_action_service.md` — 欧盟对外行动署(European External Action Service, EEAS);**当前角色**: 欧盟外交与安全政策的执行机构,受成员国共识约束的「刹车系统」
- `entities/foreign_actors/hudson_institute.md` — 哈德逊研究所(Hudson Institute);**当前角色**: 华盛顿保守派鹰派智库、战略清晰派与意识形态对抗路线的知识引擎、MAGA 派对台政策的核心输入源
- `entities/foreign_actors/japan_jaa_taipei.md` — 日本台湾交流协会台北事务所(Japan-Taiwan Exchange Association, Taipei Office);**当前角色**: 日本在台实质大使馆 / 日台非官方关系日方执行枢纽
- `entities/foreign_actors/japan_lpd_taiwan_committee.md` — 日本自民黨涉台委員會與親台網絡
- `entities/foreign_actors/japan_mofa_taiwan_policy.md` — 日本外务省(涉台政策);**当前角色**: 日本行政体系中涉台实质关系的管辖者与形式上非官方框架的维护者
- `entities/foreign_actors/japan_pm_office.md` — 日本首相官邸(涉台决策);**当前角色**: 日本行政体系最高决策中枢,涉台安全与外交政策的最终裁定与协调者
- `entities/foreign_actors/japan_self_defense_forces_southwest.md` — 日本自卫队西南方面防卫体系;**当前角色**: 第一岛链北段防卫枢纽、台海冲突潜在介入前沿、美日同盟西南拒止核心、日本从“专守防卫”向“反击能力”转型的物理试验场、北京评估“日本介入风险”的首要观察指标
- `entities/foreign_actors/japan_taiwan_business.md` — 日本对台经济界;**当前角色**: 日台非官方关系的实质底盘与政治天花板
- `entities/foreign_actors/japan_taiwan_diet_members_consortium.md` — 日華議員懇談會
- `entities/foreign_actors/jd_vance.md` — 万斯 / JD Vance [US_PERSON_VANCE];**当前角色**: 美国第 50 任副总统(2025.1- )
- `entities/foreign_actors/key_2028_us_presidential_aspirants.md` — 2028 美国总统潜在人选与涉台立场 [US_2028_PRESIDENTIAL_ASPIRANTS];**当前角色**: 2028 美国总统大选潜在竞选人之涉台立场结构图谱
- `entities/foreign_actors/key_eu_member_states_taiwan.md` — 歐盟關鍵成員國涉台立場
- `entities/foreign_actors/key_japanese_politicians.md` — 日本关键政治人物涉台立场 [KEY_JAPANESE_POLITICIANS_TAIWAN_STANCE];**当前角色**: 日本对台政策的『人物地图』与立场结构图谱
- `entities/foreign_actors/key_taiwan_political_scientists.md` — 台灣關鍵政治學者群(Key Taiwan Political Scientists)
- `entities/foreign_actors/key_us_taiwan_scholars.md` — 关键美国台海学者群(Key US Taiwan Scholars);**何时调用**: 引用美方学者涉台报告或言论时;分析智库辩论;理解学者立场分布;识别政策圈的人脉网络
- `entities/foreign_actors/ldp_pro_taiwan.md` — 自民党亲台派;**何时调用**: 分析日本对台政策的政治基础、两岸情势变化时的日方动态、首相与党内亲台势力的关系、日本国会涉台立法、政党外交与台日关系
- `entities/foreign_actors/marco_rubio.md` — 鲁比奥 / Marco Rubio [US_PERSON_RUBIO];**当前角色**: 美国国务卿(2025.1- )
- `entities/foreign_actors/mike_waltz.md` — Mike Waltz / 邁克·沃爾茲 [US_PERSON_WALTZ]
- `entities/foreign_actors/mitch_mcconnell.md` — 麦康诺 / Mitch McConnell [US_PERSON_MCCONNELL];**当前角色**: 美国联邦参议员(肯塔基州)、参议院规则委员会主席、前参议院共和党领袖(2007-2025)
- `entities/foreign_actors/petr_pavel.md` — 彼得·帕維爾 / Petr Pavel [CZ_PERSON_PAVEL]
- `entities/foreign_actors/pla_eastern_theater.md` — 解放军东部战区;**当前角色**: 北京对台军事压力的核心执行者、台海方向联合作战的最高战役指挥机构、围台与锁台演训的物理实施者、将政治威慑转化为军事新常态的操盘手
- `entities/foreign_actors/prc_coercion_toolbox_europe.md` — 北京對歐洲的脅迫工具集(PRC Coercion Toolbox: Europe)
- `entities/foreign_actors/quad_and_taiwan.md` — QUAD(四方安全对话)与台湾;**当前角色**: 印太战略多边对话与协调机制
- `entities/foreign_actors/quincy_institute.md` — 昆西研究所(Quincy Institute for Responsible Statecraft);**当前角色**: 华盛顿克制派/反干预主义智库、美方战略收缩与离岸平衡论述的知识引擎、建制外交政策圈的异见者
- `entities/foreign_actors/rand_corporation.md` — 兰德公司(RAND Corporation);**当前角色**: 美国联邦资助研发中心(FFRDC)、五角大厦的军事技术分析与兵棋推演核心供应商
- `entities/foreign_actors/taiwan_affairs_office.md` — 国台办(国务院台湾事务办公室)
- `entities/foreign_actors/taiwan_affairs_office_arms_sales_response.md` — 国台办对美售台武器的发声模式;当前角色:北京对台工作机构的特定议题发声机制
- `entities/foreign_actors/tecro.md` — 驻美国台北经济文化代表处(TECRO)—— 运作细节;**当前角色**: 台湾对美实质大使馆、对美外交与安全沟通枢纽
- `entities/foreign_actors/ted_cruz.md` — 泰德·克魯茲 / Ted Cruz [US_PERSON_CRUZ]
- `entities/foreign_actors/trump_administration_2.md` — 川普第二任政府涉台体系;**当前角色**: 美国联邦行政体系(2025-2029)
- `entities/foreign_actors/us_congress_arms_sales_review_role.md` — 美国国会在对台军售中的角色;**当前角色**: 对台军售的推动者、安全援助的授权与拨款者、交付积压的监督者、行政裁量权的制度性施压源
- `entities/foreign_actors/us_congress_taiwan_caucus.md` — 美国国会台湾连线(Congressional Taiwan Caucus);**何时调用**: 分析国会涉台立法、跨党派对台支持的稳定性、国会议员涉台访问、国会对行政部门的"绑定"作用
- `entities/foreign_actors/us_legislation_on_taiwan.md` — 美国涉台立法体系;**当前角色**: 美方对台制度性承诺的载体、行政裁量权的约束框架、跨党派共识的固化工具
- `entities/foreign_actors/us_state_department.md` — 美国国务院(涉台职能) / US State Department (Taiwan-related Functions);**当前角色**: 美国联邦政府主管外交事务的内阁级机构;美台非官方关系的主管机构;美国「一中政策」的日常执行与诠释者
- `entities/foreign_actors/us_white_house_arms_sales_role.md` — 美国白宫在对台军售中的角色;**当前角色**: 对台军售的最终政治权衡中心与节奏控制器
- `entities/governments/japan_taiwan_policy_mechanism.md` — 日本对台政策决策机制(跨部会);**当前角色**: 日本涉台政策跨部会协调与约束系统的运作机制
- `entities/institutions/judicial_yuan.md` — 司法院(含宪法法庭);**当前角色**: 台湾司法体系最高机关、宪法解释与违宪审查核心
- `entities/institutions/legislative_yuan_overview.md` — 立法院(机构概览);**当前角色**: 台湾最高立法机关
- `entities/institutions/mnd.md` — 国防部(机构概览);**当前角色**: 台湾军事政策核心执行机构、国防预算分配枢纽、对美军事合作对口、军政军令一元化制度载体
- `entities/media/china_times.md` — 中国时报 / 旺中集团(China Times / Want Want China Times Group);**何时调用**: 分析台湾媒体光谱、亲北京媒体生态、媒体所有权与中资影响争议、2008 后媒体生态变化、特定 2024 / 2028 选战中的媒体效应
- `entities/media/chinese_capital_in_taiwan_media.md` — 中资在台湾媒体的影响(Chinese Capital Influence in Taiwan Media);**当前角色**: 台湾媒体所有权与资金依赖的隐性结构分析框架
- `entities/media/cti_news_youtube.md` — 中天电视 YouTube 频道(CTI News YouTube Channel);**当前角色**: 台湾媒体『下架后线上化』的标志案例、脱离广电监管的极化政论引擎、旺中集团网络论述核心载体
- `entities/media/digital_native_media_taiwan.md` — 台湾网络原生媒体生态;**当前角色**: 台湾新闻供给结构的重组者与政治论述的孵化器
- `entities/media/japanese_media_taiwan_coverage.md` — 日本媒体的对台报导生态;**当前角色**: 日本主流媒体集团(报纸与广播)的对台报导框架与政治功能
- `entities/media/key_political_influencers.md` — 台湾关键政治影响者(KOL、网红、名嘴);**当前角色**: 台湾政治情绪的去中心化放大器、政党论述的测试气球与年轻/中间选民的认知框架塑形节点
- `entities/media/liberty_times.md` — 自由时报(Liberty Times);**当前角色**: 台湾本土派/泛绿立场主流大报、绿营论述主要载体
- `entities/media/line_groups_political.md` — LINE 政治群组生态;**当前角色**: 封闭式政治信息传播与动员场域
- `entities/media/media_ownership_map.md` — 台灣媒體所有權圖譜
- `entities/media/media_political_spectrum.md` — 台湾媒体政治光谱;**当前角色**: 台湾政治论述的坐标系统与立场映射框架
- `entities/media/min_shi_tv.md` — 民视 / 民间全民电视(Formosa TV / FTV);**当前角色**: 深绿/本土派论述载具、台语文化基础设施、绿营基本盘情绪锚点
- `entities/media/political_talk_shows_overview.md` — 台灣政論節目生態概覽
- `entities/media/sanli_tv.md` — 三立電視(Sanli Television)
- `entities/media/social_media_political_landscape.md` — 台湾社群媒体政治生态;**当前角色**: 台湾政治情绪的分层放大器、世代认同的数位隔离墙与跨平台协同作战的碎片化载体
- `entities/media/tvbs.md` — TVBS(聯利媒體股份有限公司)
- `entities/media/united_daily_news.md` — 聯合報 / 聯合報系(United Daily News / UDN Group)
- `entities/media/youtube_political_channels.md` — 台湾 YouTube 政治频道生态;**当前角色**: 台湾政治情绪的演算法放大器、碎片化论述载体与年轻/中年选民的认知框架塑形场
- `entities/organizations/arats_and_tao.md` — 海协会与国台办(双轨机构)
- `entities/organizations/chung_shan_institute.md` — 国家中山科学研究院(National Chung-Shan Institute of Science and Technology, NCSIST);**当前角色**: 台湾国防自主战略的物理执行核心、不对称战力(飞弹与无人机)的研发与制造枢纽、对美军工技术的在地整合者
- `entities/organizations/coast_guard_taiwan.md` — 海洋委员会海巡署;**当前角色**: 台湾应对北京灰色地带战术的第一线物理力量、海疆主权争议的『缓冲器』与『施压阀』、平战转换机制下的海军预备队
- `entities/organizations/general_staff_taiwan.md` — 参谋本部;**当前角色**: 台湾军令系统核心、联合作战指挥枢纽、国防部长之军事幕僚长
- `entities/organizations/legislative_yuan.md` — 立法院(当代结构与攻防);**何时调用**: 分析台湾立法机构运作、国会攻防、政党席次结构、重大立法争议、行政立法关系、2024 后政治平衡
- `entities/organizations/mac_taiwan.md` — 行政院大陆委员会(陆委会);**何时调用**: 分析两岸政策制定、两岸官方互动管道、两岸交流事务的制度框架、两岸冷对抗时期的沟通状态
- `entities/organizations/mnd.md` — 国防部(Ministry of National Defense, MND);**当前角色**: 台湾军事政策核心执行机构、国防预算分配枢纽、对美军事合作对口
- `entities/organizations/moda_taiwan.md` — 數位發展部(數位部)
- `entities/organizations/mofa_taiwan.md` — 中华民国外交部;**何时调用**: 分析台湾对外关系、邦交国维护、非邦交国实质关系、外交部组织运作、台湾国际参与、外长人事与政策方向
- `entities/organizations/ncc_taiwan.md` — 國家通訊傳播委員會(NCC)
- `entities/organizations/nsb_taiwan.md` — 国家安全局(National Security Bureau, NSB);**当前角色**: 台湾对外情报与反情报中枢、总统的专属战略耳目、对中情报与反渗透核心、对美日情报合作对口
- `entities/organizations/nsc_taiwan.md` — 国家安全会议(国安会);**何时调用**: 分析台湾安全政策决策机制、两岸战略制定、国防外交协调、重大危机处理
- `entities/organizations/polling_ecosystem_taiwan.md` — 台湾民调机构生态;**当前角色**: 台湾政治民调的系统性环境
- `entities/organizations/reserve_command.md` — 后备指挥部 / 全民防卫动员(All-Out Defense Mobilization Agency / Reserve Command);**当前角色**: 台湾后备军人管理与动员执行枢纽、民防体系军事端接口、全民防卫论述的制度化载体
- `entities/organizations/roc_air_force.md` — 中华民国空军;**当前角色**: 台湾制空权捍卫者、防空飞弹与预警指挥中枢、豪猪战略下『先进战机 vs 防空飞弹』路线张力的主要承载军种、F-16V 换装执行者、台海灰色地带应对的第一线消耗者
- `entities/organizations/roc_army.md` — 中华民国陆军(Republic of China Army, ROCA);**当前角色**: 台湾地面防卫主力、兵力规模最大军种、豪猪战略下城镇作战与地面拒止的核心执行者、役男主要接收军种
- `entities/organizations/roc_navy.md` — 中华民国海军(含海军陆战队);**当前角色**: 台湾海域控制与反封锁核心力量、潜舰国造执行者、滩岸防卫(陆战队)提供者、豪猪战略下『大型平台 vs 不对称』路线张力的主要承载军种
- `entities/organizations/sef_taiwan.md` — 海峡交流基金会(海基会);**何时调用**: 分析两岸事务管道、两岸协议谈判、海基会人事与两岸关系温度、两岸交流事务的处理
- `entities/organizations/taiwan_independence_organizations.md` — 台湾独立运动组织;**当前角色**: DPP 党外的『独派压力来源』,台湾内部政治光谱的极端一端,北京定义下的『分裂势力核心』
- `entities/organizations/taiwan_marine_corps_and_outer_islands.md` — 台湾海军陆战队与外岛部队体系;当前角色: 滩岸与港口防卫骨干、外岛前沿预警与迟滞力量
- `entities/organizations/taiwan_military.md` — 中华民国国军(Republic of China Armed Forces, ROCAF);**当前角色**: 台湾防卫主体、两岸军事对峙第一线执行者、不对称作战转型载体、全民国防体系核心
- `entities/organizations/taiwan_thinktanks_overview.md` — 台湾智库与政策研究机构生态;**当前角色**: 政策辩论场域、人才储备库、第二轨道外交介面
- `entities/organizations/tecro.md` — 驻美国台北经济文化代表处(TECRO);**何时调用**: 分析台美非官方关系运作、台北驻美外交活动、美台沟通管道、TECRO 代表角色、Trump 2.0 时期台美互动
- `entities/organizations/theater_commands_taiwan.md` — 各战区与作战区概览;**当前角色**: 台湾本岛与外岛的空间防卫载体、联合作战指挥的地理执行节点
- `entities/parties/cross_party_alliances.md` — 跨党派联盟(蓝白合、绿白合等);**当前角色**: 决定台湾多党政治下立法院多数归属与2028在野整合成败的核心机制
- `entities/parties/dpp_2028_path.md` — 民主进步党 2028 路径分析 (DPP Path to 2028) [TW_PARTY_DPP_2028];**当前角色**: 执政党(2016- ),面临三连任挑战与立法院少数困境
- `entities/parties/dpp_factions.md` — DPP 派系详解;**何时调用**: 分析 DPP 的人事任命、政策决定、提名安排、内部辩论;预测 2028 党内动态;理解任何"DPP 决定 X"背后的实质机制
- `entities/parties/dpp_internal_succession.md` — DPP 内部接班动态;**当前角色**: 执政党内部权力代际更替机制
- `entities/parties/dpp_overview.md` — 民主进步党总论 (DPP) [TW_PARTY_DPP];## 一句话定位
- `entities/parties/dpp_voter_base.md` — DPP 選民基礎結構
- `entities/parties/kmt_factions.md` — KMT 派系详解;**何时调用**: 分析 KMT 党内决策、人事任命、提名安排、内部辩论;预测 2028 KMT 候选人选择
- `entities/parties/kmt_overview.md` — 中国国民党总览(KMT);**当前角色**: 主要反对党;立法院第一大党(2024-)
- `entities/parties/kmt_two_lines_struggle.md` — KMT 两条路线斗争;**当前角色**: 决定 KMT 当代政治定位与 2028 候选人提名逻辑的核心结构性张力
- `entities/parties/kmt_voter_base.md` — KMT 選民基礎結構
- `entities/parties/parties_ideological_map.md` — 台湾政党意识形态地图 [TW_PARTY_IDEOLOGICAL_MAP];**当前角色**: 分析框架与定位工具
- `entities/parties/tpp_overview.md` — 台湾民众党总览(TPP);**当前角色**: 立法院第三大党(2024 起 8 席);柯文哲司法风暴后的"后柯阶段"
- `entities/parties/tpp_post_ko.md` — TPP 後柯文哲時期
- `entities/parties/tpp_voter_base.md` — TPP 選民基礎結構
- `entities/people/audrey_tang.md` — 唐鳳 [TW_PERSON_AUDREY_TANG]
- `entities/people/chen_chi_mai.md` — 陈其迈 [TW_PERSON_CHEN_CHI_MAI];**当前角色**: 高雄市长(2020.08- )
- `entities/people/chen_chien_jen.md` — 陈建仁 [TW_PERSON_CHEN_CHIEN_JEN];**当前角色**: 无现任党公职(前副总统、前行政院长)
- `entities/people/cheng_wen_tsan.md` — 郑文灿 [TW_PERSON_CHENG_WEN_TSAN];**当前角色**: 政治停摆中(2024- );前行政院副院长(2023-2024)、前桃园市长(2014-2022)
- `entities/people/chiang_wan_an.md` — 蒋万安 [TW_PERSON_CHIANG_WAN_AN];**当前角色**: 台北市长(2022.12-)
- `entities/people/chiu_chui_cheng.md` — 邱垂正 [TW_PERSON_CHIU_CHUI_CHENG];**当前角色**: 行政院大陆委员会主任委员(2024.5- )
- `entities/people/cho_jung_tai.md` — 卓荣泰 [TW_PERSON_CHO_JUNG_TAI];**当前角色**: 中华民国第 26 任行政院长(2024.5- )
- `entities/people/current_japan_pm_taiwan_stance.md` — 當代日本首相的對台立場 [JP_PM_TAIWAN_STANCE]
- `entities/people/donald_trump.md` — 唐纳·川普 / 唐納·川普 [US_PERSON_TRUMP];## 一句话定位
- `entities/people/dpp_factions_key_figures.md` — DPP 派系关键人物群;**当前角色**: DPP 内部权力结构的行动者图谱与派系动态分析基础
- `entities/people/eric_chu.md` — 朱立伦 [TW_PERSON_ERIC_CHU];**当前角色**: 中国国民党主席(2021.10-)
- `entities/people/fu_kun_fang.md` — 傅崐萁 [TW_PERSON_FU_KUN_FANG];**当前角色**: 中国国民党立法院党团总召(2024-);立法委员(2024-,不分区)
- `entities/people/han_kuo_yu.md` — 韩国瑜 [TW_PERSON_HAN_KUO_YU];**当前角色**: 立法院院长(2024.02-)
- `entities/people/hsiao_bi_khim.md` — 萧美琴 [TW_PERSON_HSIAO_BI_KHIM];**当前角色**: 中华民国第 16 任副总统(2024.5- )
- `entities/people/huang_kuo_chang.md` — 黄国昌 [TW_PERSON_HUANG_KUO_CHANG];**当前角色**: 台湾民众党立法院党团总召(2024-);TPP 当代核心领导者之一
- `entities/people/huang_min_hui.md` — 黄敏惠 [TW_PERSON_HUANG_MIN_HUI];**当前角色**: 嘉义市长(2005-2014;2018-)、前 KMT 副主席
- `entities/people/huang_shan_shan.md` — 黄珊珊 [TW_PERSON_HUANG_SHAN_SHAN];**当前角色**: 台湾民众党(TPP)核心干部、前台北市副市长
- `entities/people/joseph_wu.md` — 吴钊燮(Joseph Wu)[TW_PERSON_WU_JOSEPH];**当前角色**: 中华民国国家安全会议秘书长(2024.5- )
- `entities/people/kao_chin_su_mei.md` — 高金素梅 [TW_PERSON_KAO_CHIN_SU_MEI];**当前角色**: 第11届山地原住民立法委员(无党籍,亲蓝);立法院亲蓝原住民意见领袖;北京对台统战之「中华民族」叙事在原住民族群内的最高标竿代理人
- `entities/people/ko_wen_je.md` — 柯文哲 [TW_PERSON_KO_WEN_JE];**当前角色**: 民众党创办人(已退党主席);面对司法诉讼(2024-)
- `entities/people/lai_ching_te.md` — 赖清德 [TW_PERSON_LAI_CHING_TE];## 一句话定位
- `entities/people/lin_chia_lung.md` — 林佳龙 [TW_PERSON_LIN_CHIA_LUNG];**当前角色**: 中华民国第 15 任外交部长(2024.5- )
- `entities/people/lu_shiow_yen.md` — 卢秀燕 [TW_PERSON_LU_SHIOW_YEN];**当前角色**: 台中市长(2018.12-)
- `entities/people/ma_ying_jeou.md` — 马英九 [TW_PERSON_MA_YING_JEOU];**当前角色**: 卸任总统(2008-2016 在任);KMT 元老但已退出党中央
- `entities/people/mei_chia_shu.md` — 梅家树 [TW_PERSON_MEI_CHIA_SHU];**当前角色**: 中华民国参谋总长(2024.5- )
- `entities/people/shinzo_abe_legacy.md` — 安倍晋三的对台遗产;**当前角色**: 已故日本前首相(2006-2007, 2012-2020)
- `entities/people/tsai_ing_wen.md` — 蔡英文 [TW_PERSON_TSAI_ING_WEN];**当前角色**: 卸任总统(2016.05-2024.05 在任)
- `entities/people/tsai_ming_yan.md` — 蔡明彦 [TW_PERSON_TSAI_MING_YAN];**当前角色**: 中华民国国家安全会议秘书长(2024.4- )
- `entities/people/wellington_koo.md` — 顾立雄 [TW_PERSON_WELLINGTON_KOO];**当前角色**: 中华民国国防部部长(2024.5- )
- `entities/people/wu_chao_hsieh.md` — 吴朝雄 [TW_PERSON_WU_CHAO_HSIEH];**当前角色**: 中央选举委员会主任委员(任期跨越 2024,预期涵盖 2026 与 2028 选举周期)
- `entities/people/wu_zhao_xie.md` — 吴钊燮(Wu Zhao-xie / Joseph Wu)[TW_PERSON_WU_JOSEPH_ALIAS];**当前角色**: 中华民国国家安全会议秘书长(2024.5- )
- `entities/people/xi_jinping_taiwan_policy.md` — 习近平的台湾政策 [CN_PERSON_XI_JINPING];**何时调用此档案**: 分析北京对台政策意图、两岸危机升级动因、中共对台论述演变、2028前北京干预台湾选举手段、中共内部权力结构与对台决策关联时
## events/
- `events/key_events/event_2018_kahan_mayor_election.md` — 2018 高雄市长选举(韩流现象);**当前角色**: 无(历史事件)
- `events/key_events/event_2024_election_three_way.md` — 2024 三脚督总统选战;**当前角色**: 无(历史事件)
- `events/key_events/event_2024_legislative_election.md` — 2024 立法院选举;**当前角色**: 无(历史事件)
- `events/key_events/event_2024_legislative_no_majority.md` — 2024 后立法院无单一过半;**当前角色**: 当代台湾立法院政治的根本结构
- `events/key_events/event_22_articles_2024.md` — 惩治台独 22 条意见(2024);**当前角色**: 北京对台法律战的微观操作工具
- `events/key_events/event_anti_secession_law_2005.md` — 反分裂国家法立法(Anti-Secession Law, 2005);**何时调用**: 分析北京对台武力威慑的法律基础、北京"启动武统"的法定条件、反分裂法对当代两岸的持续影响
- `events/key_events/event_blockade_drills_aug_2022.md` — 围台军演(2022.08, Around-Taiwan Drills);**何时调用**: 分析裴洛西访台后果、解放军军演的工具化、海峡中线消失、北京反应阶梯升级、台海军事压力的转折点
- `events/key_events/event_blue_white_collaboration_failure_2023.md` — 蓝白合协商破局(2023.10-11);**何时调用**: 分析2024年三脚督选战结构形成、2028年蓝白合可能性、台湾主要在野党整合失败的原因、台湾政党政治的结构性矛盾
- `events/key_events/event_chen_yun_lin_visit_2008.md` — 陈云林访台事件(Chen Yunlin's Visit to Taiwan, 2008.11);**当前角色**: 两岸关系历史事件 / 互动先例
- `events/key_events/event_conscription_extension_2022.md` — 兵役延长决定(Conscription Extension Decision, 2022.12-2024.01);**当前角色**: 台湾国防人力结构转折点
- `events/key_events/event_constitutional_court_ruling_2024.md` — 2024 宪法法庭国会改革判决;**当前角色**: 当代台湾宪政关系与司法审查正当性的转折点
- `events/key_events/event_first_party_alternation_2000.md` — 2000 首次政党轮替(陈水扁当选);**当前角色**: 无(历史事件)
- `events/key_events/event_hong_kong_2019_taiwan_impact.md` — 香港 2019 反送中对台冲击(Hong Kong 2019 Anti-Extradition Bill Protests and Its Impact on Taiwan);**当前角色**: 不适用
- `events/key_events/event_joint_sword_2024_drills.md` — 联合利剑系列军演(Joint Sword Drills, 2024);**何时调用**: 分析赖清德就职后两岸军事动态、联合利剑系列军演的工具化、北京当代施压模式、军演的"周期化"
- `events/key_events/event_kmt_chairman_election_2025.md` — 2025 KMT 主席选举;**当前角色**: 决定 KMT 2028 总统提名规则、路线定位与蓝白合架构的前置性结构性事件
- `events/key_events/event_legislative_reform_controversy_2024.md` — 国会改革法案争议(2024 Legislative Reform Controversy);**何时调用**: 分析 2024 后台湾宪政关系、总统与立法院的权力动态、蓝白合作与 DPP 对抗、公民运动的新形态、2028 选举的前哨战
- `events/key_events/event_lithuania_taiwan_office_2021.md` — 立陶宛设台代表处事件(Lithuania Taiwan Office Event, 2021);**当前角色**: 非邦交国涉台命名突破与北京系统性经济胁迫的標杆事件
- `events/key_events/event_ma_xi_meeting_2015.md` — 马习会(Ma-Xi Meeting, 2015.11.07);**当前角色**: 两岸关系历史事件 / 互动先例
- `events/key_events/event_pelosi_visit_2022.md` — 裴洛西访台事件(Pelosi's Taiwan Visit, 2022.08);**何时调用**: 分析美台高层互动模式、北京反应阶梯、2022 后两岸关系结构变化、选举与两岸事件的互动、台海军演的常态化进程
- `events/key_events/event_pla_drills_around_taiwan_routine.md` — 解放軍繞台常態化(2016-當代演變)
- `events/key_events/event_recall_movements_2024_25.md` — 大罢免运动(2024-2025);**何时调用**: 分析 2024 后台湾政治分裂、立法院结构变化、蓝白合关系的演变、2028 选战起点、公民运动与政党政治的关系
- `events/key_events/event_select_committee_ccp_2023.md` — 美国众议院『中国共产党特别委员会』成立(2023);**当前角色**: 美国联邦众议院第 118 届及后续国会之特别委员会(Select Committee)
- `events/key_events/event_strait_missile_crisis_1996.md` — 1996 台海飛彈危機(Third Taiwan Strait Crisis)
- `events/key_events/event_sunflower_2014.md` — 太阳花学运(Sunflower Student Movement, 2014.03-04);**何时调用**: 分析 2014 后台湾政治转折、两岸服贸协议、两岸合作时代的终结、第三势力的崛起、年轻世代的政治觉醒
- `events/key_events/event_tsai_mccarthy_meeting_2023.md` — 蔡麦会(Tsai-McCarthy Meeting, 2023.04);**何时调用**: 分析美台高层互动的"调整模式"、裴洛西访台后的台美方共同摸索、过境外交的政治学、北京反应的相对克制
- `events/key_events/event_xi_20th_congress_taiwan.md` — 中共二十大涉台部分(2022);**当前角色**: 标志性政治事件 / 制度节点
- `events/key_events/event_xi_jan_2_2019_speech.md` — 习近平 2019 元二讲话(Xi's Jan 2, 2019 Speech);**当前角色**: 习近平(中华人民共和国最高领导人)
- `events/recurring_patterns/pattern_arms_sales_timing.md` — 美对台军售时机模式(Pattern: US Arms Sales Timing);**何时调用**: 分析特定军售案的政治意义;预测下一波军售时机;理解军售作为政治信号
- `events/recurring_patterns/pattern_blue_green_return.md` — 蓝绿基本盘回归模式(Pattern: Blue-Green Base Return);**当前角色**: 无(结构性模式)
- `events/recurring_patterns/pattern_china_response_to_arms_sales.md` — 中國對美售台武器官方反應總覽(Pattern: China Response to Arms Sales)
- `events/recurring_patterns/pattern_diplomatic_switch.md` — 邦交国转向北京模式(Pattern: Diplomatic Switch to Beijing);**当前角色**: 两岸外交竞逐的结构性现象
- `events/recurring_patterns/pattern_drills_after_provocation.md` — 北京軍演回應模式(Pattern: Drills After Provocation)
- `events/recurring_patterns/pattern_economic_coercion.md` — 北京对台经济胁迫模式(Pattern: Beijing's Economic Coercion against Taiwan);**当前角色**: 经济胁迫行动的规律性分析框架
- `events/recurring_patterns/pattern_eight_year_alternation.md` — 台湾八年政党轮替模式(Pattern: Eight-Year Party Alternation);**当前角色**: 台湾总统选举周期的结构性规律
- `events/recurring_patterns/pattern_inauguration_response.md` — 北京对台湾总统就职的反应模式(Pattern: Beijing's Inauguration Response);**当前角色**: 两岸关系结构性互动节点
- `events/recurring_patterns/pattern_mid_term_punishment.md` — 台湾期中选举惩罚模式(Pattern: Mid-Term Election Punishment);**当前角色**: 九合一选举中的执政党支持度流失机制
- `events/recurring_patterns/pattern_pendulum_swings.md` — 选后民意摆荡模式(Pattern: Post-Election Pendulum Swings);**当前角色**: 执政党政经曲线的结构性描述
- `events/recurring_patterns/pattern_post_election_signal.md` — 北京选后信号模式(Pattern: Post-Election Signal from Beijing);**当前角色**: 两岸关系动态分析框架
- `events/recurring_patterns/pattern_pre_election_pressure.md` — 北京选前施压模式(Pattern: Pre-Election Pressure from Beijing);**何时调用**: 分析台湾选举周期中的两岸事件;预测特定选举前的北京可能行动;评估北京施压的策略意图与效果
- `events/recurring_patterns/pattern_recall_weaponization.md` — 罢免工具化模式;**当前角色**: 政治博弈结构 / 制度异化模式
- `events/recurring_patterns/pattern_taiwan_president_transit.md` — 台湾总统过境美国的模式(Pattern: Taiwan President Transit through the US);**当前角色**: 美台非官方关系的气压计与信号释放机制
- `events/recurring_patterns/pattern_third_force_emergence.md` — 台湾第三势力崛起 / 衰落模式(Pattern: Third Force Emergence and Decline);**当前角色**: 政治结构模式
- `events/recurring_patterns/pattern_third_party_emergence_decline.md` — 台湾第三势力崛起与衰落模式(Pattern: Third Party Emergence and Decline);**当前角色**: 政治结构模式
- `events/recurring_patterns/pattern_tsai_lai_continuity_break.md` — 蔡赖路线的连续与断裂模式;**当前角色**: 当代 DPP 两岸路线的演变机制
- `events/recurring_patterns/pattern_us_visits_response.md` — 北京对美方涉台访问的反应模式(Pattern: Beijing's Response to US Visits to Taiwan);**当前角色**: 无(事件模式)
- `events/timelines/democratization_timeline.md` — 台湾民主化时间线(Democratization Timeline);**当前角色**: 历史脉络与分析框架
- `events/timelines/economic_engagement_timeline.md` — 两岸经贸交流时间线(Economic Engagement Timeline);**当前角色**: 追踪两岸经贸关系的结构性演变
- `events/timelines/informal_relations_upgrade.md` — 台美非邦交关系实质升级时间线;**当前角色**: 追踪 1979 年至今台美非邦交关系实质升级的脉络与结构
- `events/timelines/major_us_taiwan_arms_sales_timeline.md` — 美国对台主要军售时间线(2007- );**当前角色**: 追踪美对台军售案的结构性演变与交付状态
- `events/timelines/military_pressure_timeline.md` — 大陆对台军事施压时间线(Military Pressure Timeline);**何时调用**: 分析特定军演的脉络位置、追踪军事施压的累积趋势、评估"基线变化"、理解军事施压工具的演变
- `events/timelines/taiwan_diplomatic_allies_loss.md` — 台湾邦交国流失时间线;**何时调用**: 分析特定邦交国断交事件的脉络位置、追踪台湾国际法理地位的边缘化趋势、评估"邦交数 vs 实质关系"的结构性脱钩、理解北京将断交作为惩罚工具的演变
- `events/timelines/us_taiwan_2024_present_trump_2.md` — 川普二任时期美台关系时间线(2024.11- );**何时调用**: 分析川普 2.0 时期具体事件的脉络;追踪美台关系动态;评估趋势变化
## narratives/
- `narratives/china_side/1992_consensus_beijing_version.md` — 九二共识的北京版本;**当前角色**: 北京对台政治论述的核心前提 / 两岸主权归属的定调工具
- `narratives/china_side/china_official_arms_sale_discourse.md` — 中国官方对美售台武器话语框架;**当前角色**: 北京反制美台军事互动的核心因果框架与行动正当化工具
- `narratives/china_side/economic_coercion_examples.md` — 北京对台经济胁迫案例集(Economic Coercion Examples: Beijing against Taiwan);**当前角色**: 经济胁迫行动的具体案例拆解与目标-工具-效果分析
- `narratives/china_side/external_interference_discourse.md` — 北京『外部势力干涉』论述;**当前角色**: 北京涉台话语的核心因果框架
- `narratives/china_side/legal_warfare_anti_secession.md` — 北京法律战与反分裂法;**当前角色**: 北京对台复合施压体系中的法理建构与规范吞并工具
- `narratives/china_side/military_pressure_normalization.md` — 北京对台军事施压常态化论述;**当前角色**: 军事行动的话语包装与正当化框架
- `narratives/china_side/one_country_two_systems_taiwan_version.md` — 一国两制台湾方案(One Country Two Systems, Taiwan Version);**何时调用**: 分析北京对台「统一方案」的具体内容、习近平涉台讲话、台湾内部对一国两制的反应、香港因素对台湾的影响
- `narratives/china_side/unification_process_discourse.md` — 大陆侧统一进程叙事(Unification Process Discourse);**何时调用**: 分析北京涉台话语、习近平涉台讲话、大陆涉台白皮书、北京内部对统一时间表的辩论、当代北京政策方向
- `narratives/cross_strait/2_28_legacy.md` — 二二八事件历史遗产论述;**当前角色**: 台湾本土认同的创世神话与道德基石;KMT 在台湾政治中无法清偿的『原罪』;当代选举中阻断 KMT 扩张与连结两岸威胁的核心论述武器
- `narratives/cross_strait/92_consensus_reinterpretation.md` — 九二共识再诠释论述;**当前角色**: KMT 试图在两岸主权僵局中维持话语权与选举生存的防御性论述工程
- `narratives/cross_strait/avoid_war_frame.md` — 避战论述框架;**何时调用此档案**: 分析台湾选举中的两岸议题攻防、政党对国防与外交政策的论述调整、民意对军事冲突与灰色地带胁迫的风险感知变化、北京威慑行动在台湾内部的政治转化、以及美国对台战略模糊/清晰辩论的本地折射时
- `narratives/cross_strait/blockade_scenarios.md` — 海上封锁情境分析;**当前角色**: 威慑论述与军事情境
- `narratives/cross_strait/chinese_market_dependency.md` — 对中国市场依赖论述;**当前角色**: 台湾内部关于两岸经贸关系核心辩论的框架设定者
- `narratives/cross_strait/civilian_defense_discourse.md` — 民防論述(Civilian Defense Discourse)
- `narratives/cross_strait/cognitive_warfare_taiwan.md` — 对台湾的认知作战论述;**何时调用此档案**: 当分析任务涉及「北京对台资讯操作」、「台湾内部对认知作战的争议」、「归因困难与政治标签化」、「两岸论述交锋的媒介形式」时调用
- `narratives/cross_strait/cross_strait_exchanges_current.md` — 两岸交流的当代实务;**何时调用**: 分析两岸观光、学位互认、医疗合作、小三通、商业投资、文化交流等实务议题;理解「正式管道关闭」与「实务交流仍部分存在」的结构性张力;评估两岸交流在选战中的政治用途
- `narratives/cross_strait/defense_self_strengthening.md` — 国防自强论述;**当前角色**: 台湾安全与两岸关系中的核心战略叙事
- `narratives/cross_strait/ecfa_legacy_debate.md` — ECFA 与两岸经贸的政治争议(ECFA Legacy Debate);**何时调用**: 分析两岸经贸议题、ECFA 早收清单调整、北京经济胁迫工具、KMT 的两岸经贸论述、2008-2016 时期的两岸合作遗产
- `narratives/cross_strait/hua_du_discourse.md` — 『华独』论述;**当前角色**: 台湾主流政治光谱的隐性最大公约数与两岸主权争议的模糊地带
- `narratives/cross_strait/kmt_authoritarian_legacy.md` — KMT 威权遗产论述;**当前角色**: DPP 攻击 KMT 的核心论述武器;KMT 当代政治的『历史包袱』与合法性挑战
- `narratives/cross_strait/military_balance.md` — 两岸军事平衡论述;**何时调用此档案**: 分析台湾内部对两岸军力差距的感知与辩论;解读国防预算分配、兵力结构调整与军购案背后的政治逻辑;评估「不对称作战」、「持久战」、「美方援助」等概念在政策与选举中的功能;分析台湾社会对战争风险的信心与焦虑波动;拆解各政
- `narratives/cross_strait/peace_talk_discourse.md` — 两岸和谈论述;**当前角色**: 台湾各党派与北京在台海博弈中,用于争夺「和平话语权」、转移战争责任、并设定两岸互动门槛的核心战略框架
- `narratives/cross_strait/pla_amphibious_capability.md` — 解放军两栖与登陆能力
- `narratives/cross_strait/pla_capabilities_assessment.md` — 解放军对台能力评估(PLA Capabilities Assessment Narrative);**何时调用此档案**: 分析台海军事平衡变动;评估北京威慑与胁迫的军事基础;解读军演展现的战术意图;分析台湾内部(选举、政策)对军事威胁的感知与动员;拆解美方对台军售与战略调整的论述依据;比较不同来源的军力评估差异
- `narratives/cross_strait/red_media_discourse.md` — 『红媒』论述;**当前角色**: 台湾媒体生态中的敌我识别机制与政治标签
- `narratives/cross_strait/roc_constitution_position.md` — 中华民国宪法定位论述;**当前角色**: 台湾内部各政治势力争夺两岸现状诠释权的核心法理战场,同时是维系台海现状的锚点与触发法理冲突的震源
- `narratives/cross_strait/skepticism_of_us.md` — 疑美论 (Skepticism of US Commitment);## 一句话浓缩
- `narratives/cross_strait/status_quo_drift.md` — 蔡赖路线的现状偏移(Status Quo Drift);**何时调用**: 分析当代 DPP 两岸路线、蔡赖政府的两岸论述演变、北京判定「现状偏移」的依据、各方对「维持现状」的不同诠释
- `narratives/cross_strait/taiwan_independence_discourse.md` — 台湾独立论述谱系;**当前角色**: 政治光谱核心论述轴
- `narratives/cross_strait/taiwan_normalization_discourse.md` — 台湾国家正常化论述;**何时调用**: 分析台独路线的渐进版本、两岸最敏感议题之一、DPP内部路线分歧、北京法理台独判定依据、2028选举相关议题
- `narratives/cross_strait/taiwan_strait_security_discourse.md` — 台海安全论述:国际化 vs 内政化的话语权争夺;**何时调用**: 分析多边国际组织涉台声明;评估美日欧对台海立场演变;处理北京对台海定位的论述反制;解析台湾内部选举的两岸安全辩论;理解台海议题在全球地缘政治中的升级逻辑
- `narratives/cross_strait/taiwanese_identity_narrative.md` — 台湾人认同论述;当前角色: 政治叙事 / 身份认同框架
- `narratives/cross_strait/transitional_justice_discourse.md` — 转型正义论述;**当前角色**: DPP 重塑台湾国族认同与剥夺 KMT 道德正当性的核心制度性武器;KMT 当代政治运作的持续性失血点;定义台湾民主化历史诠释与资源分配规则的元叙事
- `narratives/cross_strait/us_china_tech_war.md` — 美中科技战与台湾的角色叙事(US-China Tech War and Taiwan Narrative);**当前角色**: 无(结构性论述)
- `narratives/cross_strait/war_peace_dichotomy.md` — 战争 vs 和平二分论述 (War vs. Peace Dichotomy);**何时调用此档案**: 分析台湾选举中的两岸议题设定、政党攻防策略、中间民意的风险权衡、北京军事威慑的政治效果、以及美方表态对台内部政治的折射时
- `narratives/cross_strait/window_of_vulnerability.md` — 「窗口期」论——2027/2030 的争议 (Window of Vulnerability Narrative);## 一句话浓缩
- `narratives/domestic/ai_governance_taiwan.md` — AI 治理与台湾;## 一句话浓缩
- `narratives/domestic/all_out_defense_discourse.md` — 全民防衛論述(All-Out Defense Discourse)
- `narratives/domestic/asymmetric_defense_discourse.md` — 不对称防御论述(台湾内部);**当前角色**: 台湾内部界定国防路线、分配军事资源、协商台美军事关系与主权象征的核心辩论框架
- `narratives/domestic/cognitive_warfare_defense.md` — 认知作战与国防的连结;## 一句话浓缩
- `narratives/domestic/corruption_clean_government.md` — 贪腐与清廉政府论述;**当前角色**: 台湾政党竞争的终极道德武器;各党互毁的『核武级』议题;2024 后重绘政治版图的核心变量
- `narratives/domestic/cyberspace_taiwan_strait.md` — 網絡空間與台海:灰色地帶作戰的核心維度
- `narratives/domestic/defense_white_paper_narrative.md` — 国防报告书与官方国防论述;**何时调用此档案**: 分析台湾官方国防政策转向、两岸军事平衡的官方评估、台美军事整合的信号传递、国防预算与战略的匹配度、后备动员与不对称作战的制度化进程、2028 前国防议题的官方话语框架
- `narratives/domestic/dpp_long_rule_fatigue.md` — DPP 长期执政疲乏论述(Narrative: DPP Long-Rule Fatigue);**当前角色**: 在野阵营(KMT、TPP)挑战 DPP 执政正当性的核心框架,以及 DPP 寻求延续执政必须克服的叙事障碍
- `narratives/domestic/election_cycles_2028.md` — 2028 選舉周期論述
- `narratives/domestic/energy_policy_controversy.md` — 能源政策争议论述;**当前角色**: 台湾内政与选举核心争议论述
- `narratives/domestic/energy_transition_discourse.md` — 能源转型与缺电议题论述;**当前角色**: 台湾内政核心争议论述
- `narratives/domestic/energy_vulnerability_discourse.md` — 能源脆弱性论述;**当前角色**: 台湾国安与能源政策交叉议题的核心安全论述
- `narratives/domestic/generational_divide_voting.md` — 世代投票差异论述;**当前角色**: 政治叙事 / 选民结构分析框架
- `narratives/domestic/government_efficiency_discourse.md` — 政府效能论述;**当前角色**: 朝野双方争夺「治理失败归责权」的核心框架——执政党用以将施政受阻归咎于在野杯葛,在野党用以将施政不顺归咎于执政无能
- `narratives/domestic/housing_crisis_discourse.md` — 居住正义与房价论述 (Housing Crisis Discourse);**何时调用**: 分析年轻世代投票行为转移时;评估 DPP 内政弱点与在野党攻击主轴时;解读社会住宅、囤房税等政策背后的政治攻防逻辑时;预测 2028 选举议题优先级时
- `narratives/domestic/housing_justice_discourse.md` — 居住正义论述 (Housing Justice Discourse);## 一句话浓缩
- `narratives/domestic/indigenous_defense_industry.md` — 国机国造与国防自主论述;**何时调用**: 分析台湾国防产业政策、国防自主与军购的关系、国防产业作为经济议题、2028 选战国防议题、两岸军备竞争叙事
- `narratives/domestic/indigenous_political_voice.md` — 原住民政治声音;**当前角色**: 台湾国族建构叙事的解构性力量;立法院蓝绿竞争的关键少数(6席);汉人中心产权体制与转型正义底线的试金石
- `narratives/domestic/industrial_policy_discourse.md` — 台湾产业政策论述;**当前角色**: 台湾内部界定经济发展方向、分配正义与国家安全连结的核心论述框架
- `narratives/domestic/judicial_political_intervention.md` — 司法与政治介入论述;**当前角色**: 台湾宪政体制正当性争议的核心载体;朝野政党互毁的『制度性核武』;2024 后台湾民主信任危机的显影剂
- `narratives/domestic/lgbtq_post_2019.md` — 同志权益 2019 同婚后论述;**当前角色**: 台湾社会从「婚姻平权门槛」迈向「实质平等」的制度检验场;宗教保守派重整阵地对抗进步价值的代理战场;DPP 进步价值资产与地方选举包袱的双面刃;定义台湾「亚洲民主灯塔」国际品牌的核心元素
- `narratives/domestic/low_birthrate_discourse.md` — 低生育率议题论述 (Low Birthrate Discourse);**当前角色**: 国家生存危机与内政失能的复合指控引擎
- `narratives/domestic/migrant_workers_discourse.md` — 移工议题论述
- `narratives/domestic/military_service_branches_tensions.md` — 军种利益与豪猪战略的内部张力;**何时调用**: 分析台湾国防改革路径、军购决策争议、美台军事关系、台内部队现代化辩论、三军利益博弈
- `narratives/domestic/mobilization_vs_air_war.md` — 組織戰 vs 空戰論述(Ground War vs. Air War Narrative)
- `narratives/domestic/negative_campaigning_taiwan.md` — 台灣負面選戰論述
- `narratives/domestic/new_immigrants_discourse.md` — 新住民议题论述
- `narratives/domestic/pendulum_narrative.md` — 台湾政治『钟摆效应』叙事(Narrative: The Pendulum Effect);**当前角色**: 台湾选举结果与民意起伏的主流诠释框架
- `narratives/domestic/same_sex_marriage_legacy.md` — 同婚立法遗产论述;**当前角色**: DPP 进步价值品牌的核心认证标章;KMT 保守包袱的持续性失血点;台湾「宪法法庭主导」宪政民主模式的试金石;两岸文明边界划设的关键外交资本
- `narratives/domestic/semiconductor_geopolitics.md` — 半导体地缘政治叙事(Semiconductor Geopolitics Narrative);**当前角色**: 无(结构性论述)
- `narratives/domestic/semiconductor_politics.md` — 半导体产业的政治化;**当前角色**: N/A(政治叙事与国内论述框架)
- `narratives/domestic/taiwan_self_defense_arms_sales_discourse.md` — 台灣民主自衛與維持現狀敘事(台灣對美軍購論述)
- `narratives/domestic/taiwanese_identity.md` — 台湾人认同(叙事档案);当前角色: 政治叙事 / 合法性校验框架 / 选举动员底层操作系统
- `narratives/domestic/taiwanese_identity_evolution.md` — 台湾人认同的世代演变;当前角色: 政治叙事 / 世代结构变迁框架
- `narratives/domestic/wage_stagnation_narrative.md` — 薪资停滞论述 (Wage Stagnation Narrative);**当前角色**: 青年世代相对剥夺感的核心源头与跨议题政治否定的基础引擎
- `narratives/domestic/youth_political_alienation.md` — 年輕世代政治疏離論述
- `narratives/international/eu_china_decoupling_taiwan.md` — 欧中脱钩与台湾;**何时调用**: 分析欧盟对台经济与科技合作升级的结构性原因;评估欧中经贸脱钩/去风险政策对台湾的利弊;解读欧盟《芯片法案》与半导体供应链重组的地缘政治意涵;比较美欧对台供应链合作的版本差异;处理立陶宛/捷克突破与德法谨慎之间的制度性张力
- `narratives/international/eu_china_derisking.md` — 欧盟对中『去风险』政策叙事;**何时调用**: 分析欧盟对中贸易防御工具(FDI审查、反胁迫、补贴规管)的政治逻辑;评估欧盟成员国对中政策的分歧与收敛;解读欧中高层互动中的话语交锋;判断欧盟经济安全战略对台湾供应链地位的间接影响;比较欧美对中经济遏制战略的异同
- `narratives/international/eu_china_relations.md` — 欧中关系整体框架叙事;**何时调用**: 分析欧洲对台立场变化的底层逻辑;评估欧中经贸脱钩/去风险的政治边界;解读欧盟内部对华政策分歧的结构性根源;判断北京对欧经济胁迫的效力与反作用力;处理人权、主权与经贸利益在欧中关系中的优先级冲突;预测欧中关系未来走向对台海
- `narratives/international/eu_taiwan_relations.md` — 欧盟与台湾关系叙事;**何时调用**: 分析欧洲议会对台决议的政治效力;评估欧盟成员国对台立场的光谱差异;理解中欧关系下的台湾定位;分析台湾半导体对欧战略价值;处理「经济去风险」与「政治升级」的矛盾
- `narratives/international/india_taiwan_relations.md` — 印度对台立场;**当前角色**: 印度在印太战略格局下对台互动的政策框架与战略叙事
- `narratives/international/new_southbound_policy.md` — 新南向政策(New Southbound Policy);**当前角色**: 台湾官方对外经济战略核心叙事
- `narratives/international/pacific_islands_taiwan_china_competition.md` — 太平洋岛国的台中竞争;**当前角色**: 台中外交零和博弈的地缘战略前线与岛链控制权代理变量
- `narratives/international/south_korea_taiwan_economic.md` — 台韩经济关系——竞争与合作;**何时调用**: 分析台韩双边实质关系、半导体产业地缘政治、东亚供应链重组、中美科技战下的民主阵营供应链协调、台湾与韩国在印太战略中的经济互动
- `narratives/international/south_korea_taiwan_relations.md` — 韩国对台立场叙事:多重安全困境下的受限平衡;**当前角色**: 东北亚地缘政治行为体、美韩同盟成员、中国主要贸易伙伴
- `narratives/international/taiwan_diplomatic_allies_strategy.md` — 台湾邦交国战略;**当前角色**: 台湾国际存在论述的核心支柱之一
- `narratives/international/taiwan_in_indo_pacific.md` — 台湾在印太战略中的角色;**当前角色**: 印太战略架构下的多重战略节点
- `narratives/international/taiwan_un_participation.md` — 台湾联合国参与论述;**何时调用此档案**: 分析台湾国际法地位、UN 2758 诠释战、台湾国内选举中的国际空间议题、邦交国与多边外交策略、北京法理封杀机制、美台在国际组织的合作与局限、台湾社会对国际孤立的心理学反应、以及 2028 选举中涉及联合国或国际参
- `narratives/international/taiwan_who_advocacy.md` — 台湾 WHO 参与倡议;**当前角色**: 台湾以「专业功能」对抗「主权封杀」的核心阵地,以及国际社会测试北京红线与自身一中政策弹性的压力计
- `narratives/international/us_china_supply_chain_restructuring.md` — 美中供應鏈重組與台灣的角色敘事(US-China Supply Chain Restructuring and Taiwan Narrative)
- `narratives/international/vietnam_philippines_taiwan.md` — 越南、菲律宾对台立场叙事:南海博弈下的议题解耦与杠杆转换;**当前角色**: 东南亚地缘政治行为体、南海声索国、对台经济吸纳国(越南);美菲同盟成员、第一岛链南端关键节点(菲律宾)
- `narratives/international/wto_who_icao_taiwan.md` — WHO / ICAO / WTO 等国际组织议题;**当前角色**: 台湾国际孤立感的核心论述场域与主权地位的压力测试点
- `narratives/japan_taiwan/abe_legacy_taiwan.md` — 安倍晋三的台湾遗产论述;**当前角色**: 论述档案(非人物档案)
- `narratives/japan_taiwan/japan_security_evolution.md` — 日本安全政策演变与台湾;**何时调用**: 分析日本防卫预算与法制变革的深层动因;评估日美同盟在台海情境下的操作可行性;判断日本「反击能力」对区域权力平衡的冲击;解读宪法九条在当代的政治功能;处理两岸冲突扩大化时的多国介入评估
- `narratives/japan_taiwan/japan_taiwan_strategic_cooperation.md` — 日台经济与战略合作的多重面貌;**何时调用**: 分析日台关系深度、半导体产业政治、印太战略格局、两岸关系中的日本因素
- `narratives/japan_taiwan/japan_us_alliance_taiwan_role.md` — 美日同盟下台湾的角色;**何时调用**: 分析美日联合声明涉台措辞变化;评估台海冲突时日本的法律与军事行动空间;解读「台湾海峡和平稳定」表述的结构意涵;处理美日台三边安全协调进展;评估北京对同盟涉台明示化的反应;预测川普 2.0 时期美日同盟涉台论述的调整方向
- `narratives/japan_taiwan/japanese_colonial_legacy_taiwan.md` — 日本殖民遗产与台湾;**当前角色**: 当代台湾建构本土认同与区隔中国的历史资源;台日地缘安全与情感连结的合法性基础;北京对台统战与抗日民族主义叙事的批判靶点
- `narratives/japan_taiwan/lpd_pro_taiwan_faction.md` — 自民党亲台派系论述;**当前角色**: 论述分析
- `narratives/japan_taiwan/pro_japan_taiwan_culture.md` — 台湾亲日文化论述;**当前角色**: 台湾社会区隔中国认同、建构本土现代性的文化黏着剂;台日实质关系深化的社会润滑剂;北京对台认知战中难以穿透的软性防壁
- `narratives/japan_taiwan/semiconductor_supply_chain.md` — 日台半导体供应链合作叙事;**当前角色**: 产业与地缘政治的交汇叙事
- `narratives/japan_taiwan/taiwan_emergency_japan_emergency.md` — 「台湾有事即日本有事」论述;**何时调用**: 分析日本对台政策走向;评估日台关系上限;理解美日台安全联动;处理两岸论述交叉分析
- `narratives/japan_taiwan/us_japan_taiwan_triangle.md` — 美日台三角关系论述;**何时调用**: 分析美日台三方安全协调机制;评估台海冲突时的外部干预联动;解读日本在美台之间的中介角色;处理川普 2.0 时期的美日台互动变局;分析对北京的多边威慑效力
- `narratives/us_taiwan/arms_dealer_cash_machine_discourse.md` — 『台湾是美国军火商提款机』叙事;**何时调用此档案**: 当分析任务涉及『对美军购案的成本效益』、『台湾国防预算与民生排挤效应』、『美国军工复合体对台海政策的影响』、『疑美论的经济版论证』、『北京反对外国干涉的统战话语』、或『2028 选举中的国防与财政辩论』时
- `narratives/us_taiwan/arms_sales_dynamics.md` — 美对台军售动态(Arms Sales Dynamics);**何时调用**: 分析特定军售公告;评估军售时机的政治信号;理解军售品项变化的战略意义;观察军售作为美方对台支持的"日常温度计"
- `narratives/us_taiwan/asymmetric_warfare_discourse.md` — 不对称作战论述(Asymmetric Warfare Discourse);**当前角色**: 美方规范台湾建军方向的核心论述框架;台湾内部国防资源分配与主权象征辩论的焦点
- `narratives/us_taiwan/chip_act_taiwan_impact.md` — CHIPS Act 对台湾的影响叙事(CHIPS Act Taiwan Impact Narrative);**当前角色**: 无(结构性论述与政策叙事)
- `narratives/us_taiwan/chip_island_discourse.md` — 『芯片岛』论述;**当前角色**: N/A(概念与叙事框架)
- `narratives/us_taiwan/congressional_support_pattern.md` — 美国国会涉台支持模式;**当前角色**: 制度性约束机制与跨党派共识载体
- `narratives/us_taiwan/economic_decoupling_taiwan_role.md` — 經濟脫鉤下台灣的角色敘事(Economic Decoupling and Taiwan's Role Narrative)
- `narratives/us_taiwan/friend_shoring_discourse.md` — 『友岸外包』論述:地緣分類機制與台灣的結構性錯位(Friend-Shoring Discourse)
- `narratives/us_taiwan/joint_exercises_signaling.md` — 美台联合演训信号;**当前角色**: 论述分析对象
- `narratives/us_taiwan/porcupine_strategy.md` — 豪猪战略与不对称作战论述(Porcupine Strategy & Asymmetric Warfare Discourse);**何时调用**: 分析美方对台军事建议、台湾国防改革辩论、不对称作战路线争议、军种利益与战略路线的张力
- `narratives/us_taiwan/skepticism_of_us.md` — 疑美论(美台关系视角);## 一句话浓缩
- `narratives/us_taiwan/taiwan_as_us_pawn.md` — 『台湾作为美方棋子』论述;**何时调用此档案**: 当分析任务涉及『棋子论』、『代理人战争』、『以台制华』、『台湾主体性辩论』、『国际克制派对台海论述』、或『2028 选举中的避战/反战框架』时
- `narratives/us_taiwan/taiwan_pay_protection_discourse.md` — 『台湾付保护费』论述;**何时调用此档案**: 当分析任务涉及『川普对台军费要求』、『台美安全承诺的商品化』、『国防预算增加的论述正当性』、『疑美论的当代证据』、『半导体与安全脱钩风险』、或『2028 选举中的台美关系辩论』时
- `narratives/us_taiwan/tariff_on_taiwan_chips.md` — 对台湾芯片关税论述(Tariff on Taiwan Chips Narrative);**当前角色**: 无(胁迫性政策论述与地缘经济叙事)
- `narratives/us_taiwan/trump_china_deal_concern.md` — 川普与北京交易的担忧论述;**何时调用此档案**: 当分析任务涉及『川普交易主义对台影响』、『美中谈判中的台湾因素』、『疑美论的当代变体』、『台湾内部对美安全承诺的质疑』、或『2028 选举中的美台关系辩论』时
- `narratives/us_taiwan/trump_taiwan_uncertainty.md` — 川普 2.0 时期对台政策不确定性 (Trump 2.0 Taiwan Policy Uncertainty);## 一句话浓缩
- `narratives/us_taiwan/tsmc_us_factory_discourse.md` — 台积电赴美设厂的多重叙事(TSMC US Factory Discourse);**何时调用**: 分析半导体产业政治、台积电与国家安全的关系、「硅盾」论辩、川普关税威胁、台湾对美经济议题
- `narratives/us_taiwan/ukraine_reference_discourse.md` — 乌克兰参照论述;**何时调用此档案**: 分析台海安全论述演变、不对称作战辩论、美方援助模式争议、疑美论升温事件、民防与韧性政策推动、2028 候选人国安论述交锋时
- `narratives/us_taiwan/urban_warfare_discourse.md` — 城镇作战论述;**当前角色**: 美方与部分战略社群推动的台海终局防卫模型;台湾内部引发主权存续与人性伦理激烈辩论的政治引信
- `narratives/us_taiwan/us_actual_policy_record.md` — 美方对台实际政策纪录(US Actual Policy Record);**何时调用**: 需要检验疑美论或信美论的事实基础时;评估美方承诺从"话语"转化为"行动"的比率时;分析美台关系具体进展与边界时;比对不同总统任期的政策连续性时
- `narratives/us_taiwan/us_commitment_evidence.md` — 美方对台承诺可信论据(US Commitment Evidence);**何时调用**: 分析疑美论辩论的"反方论据";评估美方承诺可信度;理解挺美派立场的事实基础
- `narratives/us_taiwan/us_defensive_arms_sale_discourse.md` — 美国防御性对台军售叙事(US Defensive Arms Sale Discourse);**何时调用**: 分析美国对台军售决策逻辑、美中台三角关于军售的修辞交锋、台湾自我防御论述的外部支撑、战略模糊下的威慑操作、以及疑美论中关于军售效能的辩论
- `narratives/us_taiwan/us_taiwan_back_channels.md` — 台美第二轨道沟通管道(US-Taiwan Second Track Channels);**何时调用**: 分析台美关系时需识别非官方管道的实际作用;评估美方政策信号传递渠道;理解危机时刻的沟通机制
- `narratives/us_taiwan/us_taiwan_military_ties.md` — 美台军事联系论述;**当前角色**: 论述分析对象
- `narratives/us_taiwan/us_troops_in_taiwan.md` — 美军驻台与协训论述;**当前角色**: 论述分析对象
- `narratives/us_taiwan/us_will_abandon_taiwan.md` — 『美国将抛弃台湾』论述;**何时调用此档案**: 当分析任务涉及『弃台论』、『历史类比(越南/阿富汗/库尔德)』、『美国战略收缩』、『疑美论的极端推演』、『台湾内部的政治瘫痪与宿命论』、或『2028 选举中的避战论述』时
## playbooks/
- `playbooks/_how_to_use_playbooks.md` — Playbook 使用与写作规范;1. [区块 1,例如:一句话结论]
- `playbooks/analyzing_arms_sales_announcement.md` — 美國對台軍售公告分析方法
- `playbooks/analyzing_beijing_signal.md` — 解讀北京涉台信號的方法
- `playbooks/analyzing_congressional_action.md` — 美國國會涉台行動分析方法
- `playbooks/analyzing_cross_strait_rhetoric.md` — Playbook: 分析两岸论述;1. **一句话定位**: 此文本是什么、要做什么
- `playbooks/analyzing_disinformation_campaign.md` — 分析假讯息行动的方法
- `playbooks/analyzing_economic_coercion.md` — 經濟脅迫行動分析方法
- `playbooks/analyzing_faction_dynamics.md` — 分析政党派系动态的方法;**当前角色**: 分析工具与方法论
- `playbooks/analyzing_inauguration_speech.md` — 台灣領導人就職講話分析方法
- `playbooks/analyzing_japanese_position.md` — 日本涉台立场分析方法;**当前角色**: 涉日信号解码与立场评估工具
- `playbooks/analyzing_party_strategy.md` — 分析政黨策略的方法
- `playbooks/analyzing_pla_activity.md` — 解放軍對台活動分析方法
- `playbooks/analyzing_us_official_statements.md` — 美國官方涉台聲明分析方法
- `playbooks/analyzing_us_taiwan_signals.md` — 解讀美台關係信號的方法
- `playbooks/analyzing_us_think_tank_reports.md` — 美國智庫涉台報告分析方法
- `playbooks/analyzing_vote_transfer.md` — 选票转移与弃保分析方法;**当前角色**: 三脚督结构下的选票流动拆解与判定工具
- `playbooks/arms_sales_case_reconstruction.md` — 单次对台军售案统一复盘模板;当前角色: 单次对台军售案统一复盘分析工具
- `playbooks/arms_sales_comprehensive_report_generation.md` — 美国对台军售综合研究报告生成框架;当前角色: 综合研究报告生成与结构化分析指引
- `playbooks/assessing_escalation_risk.md` — 台海升級風險評估方法
- `playbooks/audience_profile_analysis.md` — Playbook:受众群体分析方法;## 一句话浓缩
- `playbooks/avoid_pitfalls.md` — 政治分析的常见陷阱
- `playbooks/bias_self_check.md` — 分析者偏见自检方法;**当前角色**: 元方法论工具(分析者自我校准)
- `playbooks/coalition_scenario_analysis.md` — 台灣政黨聯盟情景分析方法
- `playbooks/comparative_speech_analysis.md` — 講話比較分析方法
- `playbooks/decoding_signal_words.md` — 解讀信號詞詞典的方法
- `playbooks/detecting_coordinated_behavior.md` — 协同异常行为识别方法
- `playbooks/electoral_operations.md` — 選舉操作分析方法
- `playbooks/historical_analogy_analysis.md` — 历史类比分析方法;**当前角色**: 元方法论工具(分析者认知校准与推论校验)
- `playbooks/historical_analogy_use.md` — 历史类比使用方法;**当前角色**: 实务操作手册(类比部署、滥用防范与决策沟通工具)
- `playbooks/multi_playbook_orchestration.md` — 多 Playbook 组合方法
- `playbooks/narrative_attribution_protocol.md` — 叙事归因评估协议
- `playbooks/narrative_audience_match.md` — Playbook:叙事与受众匹配分析;## 一句话判断
- `playbooks/precinct_level_swing_analysis.md` — 选区级摆荡分析方法;**当前角色**: 村里与乡镇级选票摆荡的拆解、识别与预测工具
- `playbooks/scenario_planning_2028.md` — 2028 情境规划方法;**当前角色**: 2028 选战与两岸关系情境规划工具
- `playbooks/tracking_narrative_lifecycle.md` — 追踪叙事生命周期的方法;**当前角色**: 操作手册
- `playbooks/uncertainty_communication_protocol.md` — 不确定性沟通协议
## policies/
- `policies/housing_policy_details.md` — 居住政策细节 (Housing Policy Details);**当前角色**: 居住政策的多维分析工具箱——解构政策工具的内部逻辑、执行落差与政治效应
## primitives/
- `primitives/concepts/92_consensus_evolution.md` — 九二共识的演变史;**当前角色**: 两岸政治论述的争议性核心概念 / 选战政治试金石
- `primitives/concepts/anti_infiltration_law.md` — 反渗透法;**当前角色**: 台湾规范境外敌对势力渗透的国内刑法工具
- `primitives/concepts/anti_secession_law.md` — 反分裂国家法(Anti-Secession Law);**当前角色**: 北京对台法律战的核心国内法依据 / 非和平手段的国内法授权
- `primitives/concepts/arms_sale_notification_delivery_gap.md` — 军售通知、成交、交付与形成战力的区别
- `primitives/concepts/asymmetric_defense.md` — 不对称防御(Asymmetric Defense);**当前角色**: 台湾防卫战略的核心指导框架
- `primitives/concepts/china_anti_foreign_sanctions_law.md` — 中國《反外國制裁法》
- `primitives/concepts/civilian_mobilization_system.md` — 民防與全民防衛動員體系(Civil Defense and All-Out Defense Mobilization System)
- `primitives/concepts/coattail_effect.md` — 母鸡带小鸡(Coattail Effect);**何时调用**: 分析总统与立委合并选举、政党票与候选人票连动、三脚督下的选票解耦、2028 席次预测、候选人联合竞选策略、分裂投票现象
- `primitives/concepts/cognitive_warfare.md` — 认知作战(Cognitive Warfare);**当前角色**: 分析框架与政策标签
- `primitives/concepts/coordinated_inauthentic_behavior.md` — 協同異常行為(Coordinated Inauthentic Behavior, CIB)
- `primitives/concepts/cross_strait_frameworks.md` — 兩岸論述框架譜系
- `primitives/concepts/cross_strait_naming.md` — 两岸称谓辨析;**何时调用此档案**: 分析任何涉及两岸政治文本、官方声明、新闻稿、政治人物发言、民调数据时,需要判定其主权定位与立场倾向;比较不同时期或不同行动者的措辞变化时;解读国际场合的称谓妥协与底线时。
- `primitives/concepts/electoral_system_taiwan.md` — 台湾选举制度——立委选制、总统选制;**当前角色**: 政治竞争的底层规则 / 政党体系的塑型机制
- `primitives/concepts/first_island_chain.md` — 第一岛链(First Island Chain);**当前角色**: 地缘战略框架
- `primitives/concepts/generation_politics.md` — 世代政治(Generation Politics);**当前角色**: 分析方法与结构框架
- `primitives/concepts/generational_political_identity.md` — 世代政治认同(Generational Political Identity);**当前角色**: 政治行为分析框架 / 选民结构推演工具
- `primitives/concepts/gray_zone_operations.md` — 灰色地带作战(概念);**当前角色**: 分析框架与威胁形态识别
- `primitives/concepts/gray_zone_tactics.md` — 灰色地带作战的具体战术(Gray Zone Tactics);**当前角色**: 操作概念与分析框架
- `primitives/concepts/information_electronic_warfare.md` — 資訊戰 / 電子戰 / 太空戰:台海衝突的非動能維度
- `primitives/concepts/island_chain_strategy.md` — 「岛链」与印太战略架构;**当前角色**: 地缘战略框架
- `primitives/concepts/issue_ownership.md` — 议题所有权(Issue Ownership);**当前角色**: 分析框架
- `primitives/concepts/littoral_vs_offshore_defense.md` — 滩岸决胜 vs 距岸阻击的转变;**当前角色**: 台湾防卫作战核心构想
- `primitives/concepts/local_factions_and_black_gold.md` — 地方派系与黑金政治;**当前角色**: 分析台湾地方政治与选举动员的结构性框架
- `primitives/concepts/mixed_electoral_system.md` — 混合选制(单一选区两票制);**当前角色**: 政党体系的塑型机制 / 立法院权力分配的底层语法
- `primitives/concepts/narrative_attribution.md` — 论述源头归因(Narrative Attribution);## 一句话浓缩
- `primitives/concepts/national_identity.md` — 国族认同(National Identity);**当前角色**: 分析框架与测量标的
- `primitives/concepts/one_china_three_versions.md` — "一个中国"的三种版本;北京、华府、台北能否在同一句话上"看似同意"
- `primitives/concepts/overall_defense_concept.md` — 整体防卫构想(Overall Defense Concept, ODC);**当前角色**: 台湾国防改革与建军规划的理论基础
- `primitives/concepts/party_politics_taiwan.md` — 台湾政党政治概念;**当前角色**: 政治竞争的底层语法 / 权力分配的结构性框架
- `primitives/concepts/recall_mechanism.md` — 罢免机制概念;**当前角色**: 民主理论工具 / 代议制纠错机制 / 政治博弈武器
- `primitives/concepts/recall_system_taiwan.md` — 台湾罢免制度;**当前角色**: 选举制度 / 政治攻防工具
- `primitives/concepts/referendum_mechanism.md` — 公投机制概念
- `primitives/concepts/referendum_system_taiwan.md` — 台湾公投制度
- `primitives/concepts/revolving_door_taiwan.md` — 旋转门(台湾版);**当前角色**: 分析方法 / 政治结构透视镜
- `primitives/concepts/roc_constitution_and_defense_law.md` — 台湾宪政自卫与国防法制;**当前角色**: 台湾进行军购与自卫建设的法理与程序框架,同时是内部政治博弈与对外传递战略信号的载体
- `primitives/concepts/salami_slicing.md` — 切香肠战术(Salami Slicing);**何时调用**: 分析北京对台渐进式施压、灰色地带行动、现状改变机制、跨中线/绕台常态化、海警执法扩展、ECFA 架构调整、邦交国挖角、法律战与管辖权扩张
- `primitives/concepts/silicon_shield.md` — 硅盾(Silicon Shield);**当前角色**: 地缘政治论述 / 安全依赖框架
- `primitives/concepts/single_district_two_votes.md` — 单一选区两票制;**当前角色**: 台湾立委选举的具名底层语法 / 政党体系塑型器
- `primitives/concepts/six_assurances.md` — 对台六项保证(Six Assurances);**何时调用**: 分析美对台军售争议、美方对台承诺的具体内容、美方"不施压谈判"立场、八一七公报与对台关系的张力
- `primitives/concepts/sovereignty_terminology.md` — 主权相关术语辨析;**当前角色**: 分析工具框架
- `primitives/concepts/spiral_of_silence.md` — 沉默螺旋(Spiral of Silence);**当前角色**: 分析框架
- `primitives/concepts/status_quo_definition.md` — 「维持现状」的多重定义;**当前角色**: 核心分析概念
- `primitives/concepts/strategic_ambiguity.md` — 战略模糊(Strategic Ambiguity);**何时调用**: 分析美国对台承诺、川普对台政策、拜登"协防台湾"发言争议、台海危机评估、疑美论辩论
- `primitives/concepts/strategic_voting.md` — 战略投票(Strategic Voting);**当前角色**: 分析方法与选制行为框架
- `primitives/concepts/taiwan_relations_act.md` — 台湾关系法(Taiwan Relations Act, TRA);**何时调用**: 任何涉及美国对台法律基础、美国对台承诺的法律性质、对台军售合法性、美台关系的"国内法基础"的分析
- `primitives/concepts/three_us_china_joint_communiques_arms_sales.md` — 中美三个联合公报与对台军售争议;**当前角色**: 美中关系的法理基础文件组 / 对台军售争议的核心文本源
- `primitives/concepts/un_resolution_2758.md` — 联合国 2758 号决议;**何时调用此档案**: 任何涉及台湾国际参与、联合国体系、国际法地位、中美涉台法理战、北京"一中原则"国际法化、国际组织秘书处裁量权的分析
- `primitives/concepts/us_arms_export_control_act.md` — 美国《武器出口控制法》;当前角色: 美国对外军售法定程序与国会审查的法律基础
- `primitives/concepts/us_one_china_policy.md` — 美国"一中政策"(US One-China Policy);**何时调用**: 任何涉及美国对台外交基础、美国对中关系框架、台湾国家地位的法律分析、川普 2.0 时期对台政策的潜在调整
- `primitives/frameworks/arms_sales_cognitive_domain_case_review.md` — 对台军售认知领域事件复盘框架
- `primitives/frameworks/arms_sales_cross_topic_output_matrix.md` — 跨专题输出矩阵;当前角色: 跨专题分析整合工具
- `primitives/frameworks/beijing_response_typology.md` — 北京对台反应类型学(Beijing's Response Typology);**何时调用**: 评估北京对某事件的可能反应;预测当前两岸态势的下一步;分析北京反应的强度等级;评估升级风险
- `primitives/frameworks/coalition_building.md` — 联盟建构分析框架;**当前角色**: 不适用
- `primitives/frameworks/counter_narrative_strategy.md` — 反叙事策略框架;**何时调用**: 设计或评估针对假讯息、政治宣传的回应策略;分析某次辟谣或反击行动的成败原因;评估回应行动可能引发的反弹风险;比较不同行为体的认知域作战防御模式
- `primitives/frameworks/cross_platform_diffusion.md` — 跨平台扩散分析框架;**当前角色**: 认知域与媒体生态分析的核心架构
- `primitives/frameworks/cross_strait_military_balance.md` — 两岸军事力量平衡分析框架;**当前角色**: 不适用
- `primitives/frameworks/disinformation_attribution.md` — 假讯息归因分析框架;**当前角色**: 评估假讯息或可疑内容的来源、动机、影响的多层分析工具
- `primitives/frameworks/district_typology.md` — 选区类型分析框架;**当前角色**: 多维度选区分类与选情预测模型
- `primitives/frameworks/economic_coercion_analysis.md` — 经济胁迫分析框架;**当前角色**: 多维度分析工具
- `primitives/frameworks/economic_coercion_typology.md` — 經濟脅迫類型學框架(Economic Coercion Typology Framework)
- `primitives/frameworks/electoral_mobilization_strategies.md` — 選戰動員策略框架(Electoral Mobilization Strategies Framework)
- `primitives/frameworks/electoral_system_and_party_system.md` — 选制与政党体系框架;**当前角色**: 选制与政党互动的结构性分析透镜
- `primitives/frameworks/japan_taiwan_policy_analysis.md` — 日本对台政策多层分析框架;**当前角色**: 多层次政策解析工具
- `primitives/frameworks/maritime_law_disputes.md` — 海洋法争议框架;**何时调用**: 分析台海周边的海上摩擦事件;评估北京海警/海军行动的法律意涵;解读外国军舰过境台海的政治与法律信号;处理台日、台中、台菲海域重叠争议;评估海上封锁或灰色地带行动的法律基础与升级风险
- `primitives/frameworks/minority_government_dynamics.md` — 少数政府动态分析框架;**当前角色**: 非人物 / 非机构类框架
- `primitives/frameworks/narrative_lifecycle.md` — 政治叙事的生命周期(Narrative Lifecycle);## 一句话浓缩
- `primitives/frameworks/okinawa_and_taiwan_strait.md` — 冲绳与台海的连动分析框架;**当前角色**: 多维度连动分析工具——用于解释冲绳(含驻日美军)在台海安全结构中的枢纽地位,预测各方(美、日、中、台)在台海情境下围绕冲绳的行动逻辑与制约因素
- `primitives/frameworks/polling_methodology_analysis.md` — 民调方法学分析框架;**当前角色**: 评估民调可信度与解读偏差的多维检核工具
- `primitives/frameworks/taiwan_early_warning_surveillance_chain.md` — 联合预警与情报监视链条;当前角色: 台湾联合预警与情报监视链条分析框架
- `primitives/frameworks/taiwan_party_system.md` — 台湾政党体系框架;**当前角色**: 台湾政党版图变迁的结构性分析透镜
- `primitives/frameworks/tri_party_arms_sales_legal_narrative_comparison.md` — 中美台涉台军售法理叙事比较框架
- `primitives/frameworks/trump_factor_analysis.md` — 川普因素分析框架;**当前角色**: 美台关系与两岸分析的核心变数拆解工具
- `primitives/frameworks/us_congress_taiwan_legislation.md` — 美国国会涉台立法机制;**当前角色**: 拆解国会涉台行动的操作系统、评估立法文本约束力的校准工具、预判行政-立法博弈走向的结构模型
- `primitives/frameworks/us_foreign_military_sales_process.md` — 美国政府间对外军售流程;当前角色: 美国政府间对外军售(FMS)流程分析框架
- `primitives/frameworks/us_taiwan_military_cooperation.md` — 美台军事合作分析框架;**当前角色**: 多维分析工具
- `primitives/frameworks/us_taiwan_policy_analysis.md` — 美国对台政策的多层次分析框架;**何时调用**: 分析任何美方涉台行动;评估美方政策方向;预测特定情境下美方反应;理解美方内部分歧
- `primitives/frameworks/voter_behavior_analysis.md` — 選民投票行為分析框架(Voter Behavior Analysis Framework)
- `primitives/methods/polling_analysis.md` — 民调分析框架;**当前角色**: 多源民调数据的整合、校准与动态解读操作手册
## reference/
- `reference/code_words_and_euphemisms.md` — 政治信号词与隐语字典;## 一句话浓缩
- `reference/key_swing_districts_2028.md` — 2028 关键摆荡选区清单;**当前角色**: 2028 立法委员与地方首长选举之摆荡选区压力测试清单
- `reference/source_credibility_tiers.md` — 来源可信度分级
- `reference/traditional_political_strongholds.md` — 传统政治据点清单;**当前角色**: 台湾各党传统政治据点的结构分析、软化评估与2028影响预测模型
## routing/
- `routing/by_actor.md` — 路由:按行动者检索
- `routing/by_analytical_lens.md` — 路由:按分析视角检索
- `routing/by_arms_sales.md` — 美国对台军售综合分析总控入口;当前角色: 美国对台军售综合分析总控路由器
- `routing/by_audience.md` — 路由:按受众检索
- `routing/by_event.md` — 路由:按事件检索
- `routing/by_question_type.md` — 按问题类型分诊
- `routing/by_timeframe.md` — 路由:按时间范围检索
- `routing/by_topic.md` — 路由:按议题检索
- `routing/cross_reference_rules.md` — 交叉引用规则
@@ -1,9 +0,0 @@
# analysis/ 专题分析层
存放跨文件综合分析、选举前景和专题研判。
> 本文件按当前目录实际内容整理,避免引用尚不存在的规划文件。
## 子目录
- `elections/`
@@ -1,487 +0,0 @@
# 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
@@ -1,9 +0,0 @@
# analysis/elections/ 选举专题分析
存放选举周期、选情结构和情境推演类专题分析。
> 本文件按当前目录实际内容整理,避免引用尚不存在的规划文件。
## 文件清单
- `2028_election_outlook.md` — 2028 台湾选举前景分析框架
@@ -1,17 +0,0 @@
# audiences/ 受众层
存放面向认知分析、传播分析和选举分析的受众档案。该层用于回答“什么人群会如何接收、理解和响应某类信息”的问题。
## 子目录
- `generational/` — 按世代划分的受众档案
- `political_identity/` — 按政治认同光谱划分的受众档案
- `social_identity/` — 按社会身份、职业和族群划分的受众档案
## 使用规则
- 分析传播效果、认知脆弱点、议题敏感点时优先读取本目录。
- 涉及具体政党或人物时,加读 `entities/`。
- 涉及具体议题时,加读 `narratives/`。
- 涉及选举预测时,加读 `analysis/elections/2028_election_outlook.md` 与 `playbooks/scenario_planning_2028.md`。
@@ -1,11 +0,0 @@
# audiences/generational/ 世代受众
按年龄与政治世代划分的受众档案,用于分析不同世代的信息行为、政治倾向、议题敏感点和认知脆弱点。
## 文件清单
- `youth_18_35.md` — 青年世代受众(18-35 岁)
- `middle_age_36_55.md` — 中年世代受众(36-55 岁)
- `senior_56plus.md` — 中老年世代受众(56 岁以上)
- `generation_comparison.md` — 世代受众对照分析
@@ -1,336 +0,0 @@
# 世代受众对照分析
---
**类型**: framework
**当前角色**: 多维对照分析工具 / 选民结构拆解框架
**何时调用此档案**: 当分析涉及跨年龄层的信息传播路径设计、选举议题设定的世代靶向、认知战防御的脆弱点排查、民调数据的世代交叉解读、以及任何需要将「受众世代差异」作为核心解释变量或控制变量的政治行为推演时。
---
## 一句话浓缩
世代受众差异图谱不是静态的人口统计切片,而是**信息环境、经济结构与主权威胁交织下的动态约束矩阵**——青年受制于「演算法同温层+分配剥夺+主权预设」,中年受困于「信息茧房+资产避险+双重认同拉扯」,中老年固守于「传统媒体权威+生存稳定+法统情感」;分析者若将世代视为同质且固定的标签,而非内部异质、受生命周期与历史事件交互塑造的流动结构,将无法精准识别认知战的渗透点,并严重误判2028选战的议题催化路径。
---
## 框架的目的与核心结构
### 框架目的
本框架是**分类、解释与预测工具**。它不旨在描述单一世代的完整面貌(此功能由各特定世代档案承担),而是提供横向对照的透视镜,用于:
1. **分类**:快速将特定信息行为、政治态度或议题反应归位至其所属的世代结构位置。
2. **解释**:揭示为何同一政策或叙事在不同世代产生截然相反的反应,拆解其底层的信息源、经济基础与认同预设。
3. **预测**:推演外部事件(如北京军事行动、美国政策转向、国内经济冲击)如何沿着世代的「认知脆弱点」与「议题敏感点」产生差异化政治效应。
### 核心结构:六维对照图谱 + APC 方法学滤网
本框架由两个互锁结构组成:
1. **六维对照图谱**:信息行为、政治倾向、议题敏感点、认知脆弱点、两岸态度、国际关系态度。每个维度下,三代受众呈现结构性位移。
2. **APC 方法学滤网**:年龄效应、时期效应、世代效应。任何观察到的世代差异,在转化为分析结论前,必须通过此滤网检验,避免将生命周期必然误判为历史断裂。
---
## 维度一:三代信息行为对照
信息行为是政治态度的输入端。不同世代的信息获取平台、内容消费习惯与信任源存在根本性断裂,这导致了台湾公共领域的「不可共量性」——不同世代实际上生活在不同的政治现实中。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **主要平台** | 短影音、IG、Dcard、PTT、Threads | Facebook、新闻App、Line群组 | 电视新闻、Line长辈图/群组、广播 |
| **内容偏好** | 迷因、短评、直播、沉浸式叙事 | 深度报导、政策懒人包、同温层评论 | 名嘴政论、单一权威来源、情感连结内容 |
| **信任源** | 网路KOL、同侪、公民团体、BBS乡民 | 职场网络、特定媒体品牌、专业人士 | 电视主播、传统媒体、地方意见领袖 |
| **信息处理模式** | 质疑权威,快速查核,但易受演算法极化 | 选择性吸收,确认偏误强,依赖框架解读 | 被动接收,高信任度,易受情感诉求引导 |
| **反向特征/例外** | 高教青年仍会阅读长文,但动机多为反击 | 科技业中年信息行为趋近青年 | 高社经中老年使用YouTube获取政论 |
### 脉络层:信息茧房的世代隔离
- **青年的「演算法部落」**:青年世代看似拥有最多元的信息获取渠道,实则受演算法控制最深。他们的信息环境是高度客制化的「部落」,同温层极厚。他们不信任传统媒体,但极度信任「同温层认证」的KOL。这使得针对青年的认知战,必须包装为「反建制」或「内幕揭露」的迷因才有效。
- **中年的「确认偏误堡垒」**:中年世代是Facebook与Line群组的主力。他们具备信息查核能力,但往往只查核符合自身预设的信息。他们的信息行为是「防御性」的——寻找证据巩固既有立场,而非探索未知。
- **中老年的「权威依赖残余」**:此世代的信息行为是威权体制的遗产。他们习惯由单一权威(过去是党部,现在是电视名嘴)告知答案。对假讯息的抵抗力最低,因为其信任机制建立在「来源的熟悉度」而非「逻辑的严密性」上。
### 分析含义
分析信息传播与认知战时,不可使用「全民国民」的假设。
- **操作指南**:若要阻断针对中老年的假讯息,关键在于「权威源辟谣」(如信任的医生、名嘴);若要反驳针对青年的认知战,关键在于「提供迷因化的反叙事」与「同侪串连」,官方四平八稳的声明毫无效果。若要穿透中年世代的同温层,必须利用其职场网络或生活痛点(如健康、物价)作为切入点。
---
## 维度二:三代政治倾向对照
政治倾向是信息输入与利益结构交互作用的结果。台湾的世代政治倾向经历了从「认同绑定」到「惩罚机制」的质变。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **投票模式** | 流动性高,惩罚性投票,易弃权 | 稳定性高,避险投票,含泪投票 | 极稳定,忠诚投票,高投票率 |
| **政党偏好** | DPP(主权底线)/ TPP(分配/反建制) | DPP / KMT 拉锯,视经济/两岸情势 | KMT(绝对优势),部分地方派系 |
| **认同预设** | 台湾人是预设值,无大中国情结 | 双重认同(台湾人+中华民国) | 中华民国法统,大中国情感 |
| **政治效能感** | 低(体制被权贵把持,投票难改结构) | 中(可透过选举维持现状或微调) | 高(投票是公民义务且能有效维护利益) |
| **反向特征/例外** | 外省/军公教家庭青年可能保留蓝营倾向 | 中年受薪白领因生活压力转向TPP | 中老岁本省籍深绿基本盘同样铁杆 |
### 脉络层:从「铁票」到「流动票」的解构
- **青年的「惩罚逻辑」**:青年选票不再是DPP的资产,而是DPP的「债权人」。他们基于主权底线不投KMT,但会因分配不公而惩罚DPP(转投TPP或弃权)。详见 `narratives/domestic/generational_divide_voting.md`。
- **中年的「避险逻辑」**:中年世代是台湾政治的「摇摆州」。他们的投票决策是「两害相权取其轻」——既怕KMT亲中引发战争,又怕DPP执政引发经济动荡。他们是「含泪投票」的主力。
- **中老年的「认同逻辑」**:此世代的投票行为是情感与利益的完美重合。投票给KMT既是维护中华民国法统的情感宣泄,也是保护年金与既得利益的理性计算。
### 分析含义
预测选举时,不可假设「基本盘回归」。
- **操作指南**:评估DPP选情时,必须将青年投票率作为内生变量——DPP在分配议题上的表现,直接决定青年投票率,进而决定DPP的得票基数。评估KMT选情时,必须计算中老年世代的自然折损率。评估TPP时,必须判断其是否成功将青年的「惩罚票」转化为「组织票」。
---
## 维度三:三代议题敏感点对照
议题敏感点决定了何种政治动员能够有效触发世代的投票行为。三代人关心的议题存在严重的错位,导致国家资源分配的世代争夺。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **核心关切** | 分配正义(高房价/低薪)、主权防线、气候变迁 | 资产保值、物价稳定、两岸避战 | 战争与和平、年金与福利、传统价值维护 |
| **极度无感** | 经济成长率GDP、法统论述、传统外交辞令 | 长期国家愿景、意识形态之争、转型正义 | 居住正义、同婚/性别议题、网路自由 |
| **触发机制** | 生存剥夺感(买不起房)、主权被侵蚀感 | 资产缩水感(通膨/加税)、战争恐惧感 | 习惯被打破感(年金改革/价值变迁) |
| **反向特征/例外** | 青年创业者可能短期关注GDP与两岸经贸 | 中年租屋族对高房价同样愤怒 | 依赖长照的中老对福利分配也有左倾诉求 |
### 脉络层:公平定义的根本冲突
三代人对「公平」的定义存在不可共量的冲突:
- **青年**:公平是「起点的均等与结构性剥削的消除」。GDP成长对他们无意义,因为红利未分配。
- **中年**:公平是「规则的稳定与努力的回报」。他们害怕重分配政策破坏既有游戏规则。
- **中老年**:公平是「过去的承诺必须兑现」。年金改革对他们而言是毁约,而非重分配。
**结构性命题**:台湾的议题政治已从「左右阶级对立」转为「世代分配对立」。任何试图讨好全世代的政策(如「经济成长兼居住正义」)都会因内在逻辑矛盾而破产。
### 分析含义
分析政策利多与选举承诺时,必须进行「世代受益/受损交叉分析」。
- **操作指南**:DPP提出「抗中保台」在青年端是保底(不加分也不减分),在中年端是双面刃(可能触发避险),在老年端是扣分。KMT提出「重启服贸」在老年端是经济利好,在青年端是主权与生存的绝对红线。没有任何单一议题能同时跨越三代。
---
## 维度四:三代认知脆弱点对照
认知脆弱点指各世代在面对认知战或政治宣传时,最缺乏防御力、最容易接受哪类叙事。这是分析外部势力干预与内部政治动员的关键切入点。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **脆弱点类型** | 反建制迷因、选择性正义叙事、失败主义 | 经济恐慌叙事、疑美论、假平衡 | 恐惧诉求、伪权威健康/战争假讯息 |
| **易接受叙事** | 「蓝绿一样烂」、「DPP贪腐建商利益」、「美国会弃台」 | 「两岸打仗经济完蛋」、「美国只是利用台湾」、「DPP意识形态误国」 | 「民进党引战」、「共产党要打过来」、「年金被砍活不下去」 |
| **防御强项** | 对传统威权叙事免疫力强、具数位查核能力 | 具备社会经验与逻辑判断力、对极端言论有戒心 | 对本土情感连结的坚守、对实质利益分配的敏锐 |
| **渗透路径** | 短影音演算法推荐、同温层KOL暗示、迷因嘲讽 | Line群组长辈图转传、名嘴政论节目框架、职场同侪议论 | 电视名嘴定调、地方邻里系统动员、Line伪科学/伪新闻 |
### 脉络层:认知防线的非对称性
- **青年的「反建制盲区」**:青年世代对传统媒体和官方叙事的防御力极高,但对包裹在「反建制」和「迷因」外衣下的认知战毫无防备。当北京或其协力者将「疑美论」包装为「拒绝当美国棋子」的进步叙事时,极易在青年同温层中扩散。详见 `audiences/generational/youth_18_35.md`。
- **中年的「理性计算陷阱」**:中年世代自认理性,但其脆弱点在于「经济恐慌」。任何暗示「DPP抗中将导致经济崩溃」的叙事,都能精准击中其资产避险的软肋。他们容易接受「假平衡」叙事(如「美国也差不多」),因为这符合其犬儒的世界观。
- **中老年的「恐惧回路」**:此世代的认知防线完全建立在「恐惧」之上。无论是恐惧战争、恐惧年金被砍,还是恐惧社会价值崩坏,只要能触发恐惧,就能绕过其理性判断。详见 `audiences/generational/senior_56plus.md`。
### 分析含义
分析认知战效应时,不可假设「单一叙事打天下」。
- **操作指南**:北京若要影响台湾选举,必须采用「分众认知战」——对青年投喂「失败主义与反建制」,对中年投喂「经济恐慌与疑美」,对老年投喂「战争恐惧」。分析者看到某类叙事在特定平台蔓延时,应立即识别其靶向世代,并推演其对特定政党基本盘的侵蚀路径。
---
## 维度五:三代两岸态度对照
两岸态度是台湾世代差异最核心、最不可逆的断裂线。这不仅是政策偏好的不同,更是国家认同预设的根本差异。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **对PRC认知** | 敌对者/并吞者(零和博弈) | 竞争者/风险源(避险博弈) | 复杂情结/同胞/竞争者(多重博弈) |
| **对「九二共识」** | 绝对排斥(等同投降) | 部分容忍(作为交流工具) | 接受(历史共识/避战工具) |
| **两岸关系定位** | 国与国/一边一国 | 特殊关系/维持现状 | 内部争议/一中各表 |
| **底线思维** | 主权不可退让(即使经济受损) | 避战优先(主权可模糊处理) | 和平优先(法统可妥协) |
| **反向特征/例外** | 台商子女青年可能对PRC有工具性理解 | 中年深绿对PRC敌意不亚于青年 | 中老年本省深绿对PRC同样绝对排斥 |
### 脉络层:主权预设的不可逆位移
- **青年的「防御性民族主义」**:青年的两岸态度是「预设性敌意」。他们不需要被动员就反中,因为这是其社会化过程的底层逻辑。对他们而言,台湾是主权独立国家是物理事实,无需辩论。
- **中年的「实用主义避险」**:中年世代试图在主权与经济之间寻找平衡。他们不信任PRC,但认为完全切断联系将带来不可承受的风险。他们是「维持现状」最坚定的支持者,因为现状是他们资产保值的基础。
- **中老年的「历史连结与恐惧」**:此世代的两岸态度最为矛盾。部分外省族群有情感连结,部分本省族群有战争恐惧。两者汇聚于「避战与交流」的结论,但底层逻辑不同——前者是血浓于水,后者是打不过就避。
**破除常见简化**:把中老年/中年对「两岸交流」的偏好,等同于「不爱国」或「认同中国」是错的。这更多是年龄效应下的资产与生存避险反应。同样,把青年的「抗中」等同于「渴望战争」也是错的,这是主权防线被触碰后的防御性反弹。
### 分析含义
分析两岸危机对选举的影响时,必须区分「短期恐慌」与「长期认同」。
- **操作指南**:北京的军事施压,在短期会引发中年与中老的恐慌(时期效应),可能有利于KMT的「避战」论述;但在中长期,这会强化青年的敌意与认同防线(世代效应),从而巩固DPP的基本盘。2028选战若处于高军事张力下,DPP的策略应是拉高认同显影度,将中年的恐慌转化为对KMT亲中路线的不信任。
---
## 维度六:三代国际关系态度对照
国际关系态度反映了世代对台湾在世界体系中位置的想象,以及对安全承诺的信任程度。
| 维度 | 青年(18-35岁) | 中年(36-55岁) | 中老年(56岁以上) |
|---|---|---|---|
| **对美态度** | 疑美但依赖(批判霸权但需安全伞) | 务实亲美(安全依赖+经济脱钩风险) | 传统亲美(盟友记忆/抗共基础) |
| **对日态度** | 文化亲近/历史宽容/情感投射 | 务实合作/历史情结复杂 | 历史创伤/反日情结较深 |
| **国际参与诉求** | 价值同盟(气候/人权/LGBTQ+) | 实质参与(CPTPP/双边贸易) | 邦交国数字/传统外交 |
| **安全信任源** | 国际社会/价值同盟/自身国防 | 美日安全承诺/半导体护国山 | 美军介入/中华民国军力 |
| **反向特征/例外** | 青年左翼对美帝国主义批判强烈 | 中年反核族群对日本排海高度不信任 | 中老年本土派对日有特殊亲近感 |
### 脉络层:安全信任的世代断层
- **青年的「价值同盟幻觉」**:青年世代倾向于相信台湾的国际安全建立在与民主国家的「价值连结」上。他们对美国有批判(疑美论在青年端表现为「美国只是利用台湾」),但潜意识里仍将美国视为唯一的依靠。
- **中年的「现实主义算计」**:中年世代更看重实质的利益交换。他们对美国的安全承诺抱持怀疑,但认为别无选择。他们对「半导体护国山」的论述接受度最高,因为这符合其产业中坚的自我定位。
- **中老年的「盟友记忆」**:此世代对美国的信任建立在冷战时期的反共联盟记忆上。他们对美日仍有较高的传统信任,但极易被「弃台论」触发恐慌。
### 分析含义
分析国际事件对台湾内政的冲击时,必须注意「疑美论」的世代分化。
- **操作指南**:当美国要求台湾延长义务役或购买特定武器时,青年的反应可能是「拒绝当棋子」(反帝),中年的反应是「是否有效」(现实主义),中老的反应是「美国是否可靠」(信任验证)。北京的认知战若要操作「疑美」,在青年端应包装为「反霸权」,在中年端应包装为「美国掏空台湾」,在老年端应包装为「美国会像断交时一样跑掉」。
---
## 核心拆解:世代差异 vs 年龄效应的方法学区分
这是本框架最核心的方法论工具。混淆两者,将导致对台湾政治长期趋势的根本误判。本节基于 `primitives/concepts/generation_politics.md` 的 APC 模型,提供实务操作指南。
### 识别特征与判断标准
| 效应类型 | 识别特征 | 判断标准(实务操作) | 预测意涵 |
|---|---|---|---|
| **世代效应**<br>(Cohort Effect) | 特定出生世代终其一生保持的态度烙印 | **跨期比对**:该世代在20岁、30岁、40岁时,对某议题的态度是否保持一致,且与同时期其他世代显著不同 | 不可逆,将随人口替代改变国家整体态度 |
| **年龄效应**<br>(Age Effect) | 所有人老去时都会经历的态度变化 | **跨世代比对**:不同世代在同一年龄段(如40-50岁)是否表现出相似的态度倾向(如资产保守化) | 可逆/可预测,属于生命周期规律 |
| **时期效应**<br>(Period Effect) | 特定历史时刻对所有世代的同时影响 | **跨年龄比对**:某一年份,所有年龄层是否发生同方向的态度位移(如俄乌战争初期的避战情绪) | 短暂且普遍,消退后各世代回归原轨迹 |
### 实务应用:四步拆解法
当观察到某年龄层(如20-29岁)出现特定政治态度(如支持TPP)时,分析者应按以下步骤拆解:
1. **步骤一:排除时期效应**。检查该态度是否仅在近期出现?其他年龄层是否也有类似趋势?若是,则为时期效应(如柯文哲现象初期的全年龄吸引力),将随时间消退。
2. **步骤二:检验年龄效应**。检查过去同一年龄段(如1990年代的20-29岁)是否也有类似态度?若是,则为年龄效应(如年轻人普遍反叛),该群体老后会回归主流。
3. **步骤三:确认世代效应**。检查该世代(如1990年后出生)是否在进入不同生命阶段后仍保持此态度?且与过去同年龄层态度不同?若是,则为世代效应(如太阳花世代的反中),此态度将不可逆。
4. **步骤四:叠加分析**。多数现象是三种效应的叠加。例如:2024年青年支持TPP,是**时期效应**(对DPP执政不满)+**年龄效应**(青年普遍反建制)+**世代效应**(主权偏绿但分配偏左)的混合。分析者必须拆解其比例,才能预测2028年此群体是否仍支持TPP。
**分析含义**:最致命的分析错误是将「世代效应」误判为「年龄效应」。例如,假设太阳花世代老后会因有资产而变蓝(接受九二共识)。事实上,他们老后只会在经济分配上变保守(年龄效应),但在主权认同上绝不会软化(世代效应)。2040年的台湾,将充满「经济保守但主权强硬」的中老年选民,这是传统蓝绿光谱无法归类的全新物种。
---
## 国际比较:其他民主国家的世代差异
将台湾置于国际比较中,能凸显其特殊性,并避免错用西方分析框架。
### 西方民主国家的常态:经济左右与文化左右
在美欧,世代差异主要沿两条轴线展开:
1. **经济轴线**:青年偏左(支持福利、加税、环保),老年偏右(保护资产、减税)。
2. **文化轴线**:青年偏进步(支持移民、性别多元),老年偏保守(民族主义、传统家庭)。
其政党竞争基本映射此轴线(如美国民主党vs共和党)。
### 台湾的特殊性:主权生存对阶级议题的遮蔽
台湾的世代差异看似与西方类似,但底层逻辑截然不同:
1. **没有纯粹的经济右派**:KMT虽代表资产阶级,但其选举手段常是福利补贴(年龄效应/恩庇);DPP虽获青年支持,其执政实质是亲资本(科技新贵)。经济左右在两党间是模糊的。
2. **主权议题压倒阶级议题**:西方青年最关心气候与移民;台湾青年最关心主权与房价。当主权受威胁时,台湾青年会暂时放下阶级不满(2020);当威胁消退时,阶级不满才爆发(2024)。
3. **防御性民族主义 vs 世界主义**:西方青年左派常是世界主义者(无国界思维);台湾青年左派则是防御性民族主义者(主权是前提)。这使得台湾青年在支持国际盟友时,是基于生存利益而非纯粹的普世价值。
**分析含义**:不可用西方的「左/右」或「进步/保守」框架来归类台湾世代。TPP的年轻支持者不是西方意义的左派,他们是「主权偏绿+分配偏左+审美反建制」的特殊混合体。详见 `primitives/concepts/generation_politics.md`。
---
## 对2028选战与未来政治格局的影响
### 结构性命题一:信息茧房导致的动员模式质变
2028年,三代受众的信息隔离将达到新高点。传统「全平台买广告」的空战模式效能将剧降。
- **青年的「迷因化动员」**:青年必须用短影音与迷因触达,且内容必须是「反建制」或「嘲讽」的。正经的政见发表会在此群体中无效。
- **中年的「痛点精准打击」**:中年必须通过Line群组与职场网络,用「经济恐慌」或「战争避险」的框架穿透其确认偏误。
- **中老的「权威情感召唤」**:中老仍依赖电视与实体组织,但动员成本随人口凋零而上升。
**分析含义**:2028的胜负手,在于哪一党能最精准地实施「分众认知战」,针对不同世代投放不同权重的议题,同时避免跨世代论述的自相矛盾。
### 结构性命题二:Cohort Replacement 的数学必然
每四年,首投族(约80-100万)加入,死亡人口(约18-20万)退出。新首投族的主权认同更偏向台湾,对KMT的两岸路线更排斥。这是不可逆的数学结构。
- **对KMT的挤压**:KMT若不在2028前完成两岸论述的「去九二共识化」,其得票率天花板将持续下降。KMT面临的不是选举策略问题,而是物理基本盘的萎缩。
- **对DPP的松动**:DPP虽享受认同红利,但「认同稀释效应」正在发生。当大家都是台湾人,认同不再是区分敌我的标签,DPP必须面对青年在分配议题上的惩罚。
### 结构性命题三:中年避险逻辑的极限测试
2028年,中年世代(36-55岁)将主要由1973-1992年出生者组成,即解严后/X世代与早期千禧世代。他们是掌握社会最多资源的群体,也是最深陷「双重焦虑」的群体。
- 若北京在2028前制造极高强度的灰色地带冲突,中年世代的「避险逻辑」可能被推至极限,部分从「避战求和」转向「抗中保资产」,从而有利于DPP。
- 若两岸关系平稳,中年世代对DPP内政不满(能源、物价)将放大,有利于KMT或TPP。
**分析含义**:2028的选举结果,高度取决于外部事件如何操作中年世代的「避险临界点」。
---
## 关联分析
### 关联一:青年受众的深层结构
- **关联文件**: `audiences/generational/youth_18_35.md`
- **功能**: 提供事实层与脉络层。本档案的对照图谱中,青年维度的「惩罚机制」与「反建制审美」需依赖该档案的详细数据与案例支撑。该档案提供了青年为何从DPP独占转为DPP/TPP分流的微观机制。
### 关联二:中年受众的避险逻辑
- **关联文件**: `audiences/generational/middle_age_36_55.md`
- **功能**: 提供事实层与交叉验证。验证本档案中「中年世代是分裂与摇摆的胜负手」之论断。该档案提供中年世代在资产保值与主权风险间的具体权衡数据,是推演2028摇摆州走向的关键。
### 关联三:中老年受众的恩庇与权威网络
- **关联文件**: `audiences/generational/senior_56plus.md`
- **功能**: 提供事实层与脉络层。解释中老年为何成为KMT物理铁票,以及其信息行为(电视/Line)如何与地方派系网络嵌合。此关联是理解KMT组织盘动员效率与衰减速度的基础。
### 关联四:世代政治的方法论基础
- **关联文件**: `primitives/concepts/generation_politics.md`
- **功能**: 提供方法论。本档案的「APC方法学滤网」直接源自该档案。在实务应用中,当分析者需要判断某现象是年龄效应或世代效应时,必须调用该档案的严格定义与操作步骤,避免线性外推的谬误。
### 关联五:投票差异的实证数据
- **关联文件**: `narratives/domestic/generational_divide_voting.md`
- **功能**: 提供事实层与交叉验证。本档案的「政治倾向对照」维度,需与该档案的历次选举出口民调数据对齐。该档案提供了2020与2024年三次选举中,三党在三个年龄段的具体得票率变化,是验证「惩罚机制」与「认同稀释」的硬证据。
### 关联六:北京胁迫的反向效应
- **功能**: 提供因果验证。北京的军演与外交打压,在短期可能压制法理台独声量,但在长期却强化了年轻世代的「天然独」烙印,并拉高了主权议题的显影度。此关联解释了为何外部胁迫往往在选举中反而有利于DPP,这是分析两岸政策与选举结果时最核心的悖论。
---
## 常见的分析陷阱
### 陷阱一:「老了就会变蓝」的线性外推
这是最常见也最危险的陷阱。将早期世代随年龄增长而变保守的经验,套用于太阳花世代。忽略了两者社会化的基础预设完全不同——前者变保守是回到其青年期的预设(大中国/威权稳定),后者变保守只会是强化其青年期的预设(台湾主体/避战保台),绝不会倒退为认同PRC。年轻世代老后,只会变成「避战的台湾人」,不会变成「亲中的中国人」。
### 陷阱二:将「信息平台」等同于「政治立场」
假设用PTT的青年都是深绿,用Line的中老年都是深蓝。事实上,平台是工具,立场是内容。PTT上也有大量右翼或红统叙事,Line群组中也有深绿的长辈。分析时必须区分「平台演算法偏好」与「用户政治偏好」,避免将媒介特征误判为受众本质。
### 陷阱三:忽略中年世代的阶级分化
将中年世代视为同质的「经济保守派」。实际上,中年世代内部因职业与资产状况的差异,在分配议题上的立场截然对立。受薪白领可能因薪资停滞而支持税改,传统产业主则可能因成本考量而反对。忽略阶级交叉变量,将误判中年世代对经济政策的反应。
### 陷阱四:高估时期效应的持久性
在重大事件(如俄乌战争爆发)发生时,各年龄层的避险情绪同时上升(时期效应)。分析者常误以为这是永久的世代转向。实际上,时期效应消退极快,一旦日常化,世代效应将重新主导分布。2024年DPP年轻票流失,部分原因即是俄乌战争初期的「抗中保台」效应消退。
### 陷阱五:假设青年的「反建制」等于「支持第三势力」
认为青年反蓝绿,就必然长期支持TPP。忽略了青年的反建制是「流动的惩罚」。若TPP在立法院的表现被证明只是「为反对而反对」或「与KMT暗通款曲」,青年会以比传统选民更快的速度抛弃它,转而选择不投票。
### 陷阱六:用西方左右光谱套用台湾世代差异
假设台湾的年轻世代等同于西方的「左派进步青年」,支持环保、性别与移民。实际上,台湾年轻世代在主权议题上具有强烈的防御性民族主义色彩,这与西方的「世界主义左派」截然不同。错用西方框架,将误判台湾年轻世代对国际议题(如以巴冲突、能源转型)的态度。
---
## 仍不确定的部分
1. **AI生成内容对认知脆弱点的冲击**:2028前,AI生成的假讯息与深度伪造将呈指数增长。青年虽具数位素养,但面对高度拟真的AI迷因,其「反建制盲区」是否会被更精准地利用?中老年对AI伪造的权威人物视频,是否具备任何辨识能力?此对冲效应的净结果仍不确定。
2. **「永远买不起房」对生命周期的真实冲击**:传统生命周期理论假设人会在中年获得资产。若台湾的房价所得比持续居高不下,导致超过半数的千禧世代与Z世代终其一生无法购屋,这将是人类近代民主国家未曾面对过的全新结构。没有资产可保护的「无产中老年」,其政治态度会如何演变,缺乏历史先例。
3. **中年世代在极端危机下的避险临界点**:目前中年世代的「避险逻辑」是基于和平时期的推演。若台海发生极高强度的封锁或有限冲突,且美国未如预期介入,中年世代是否会发生从「避战求和」到「抗中保台」的断裂性转移,还是转向「妥协求和」?此临界点无法从和平时期的调查中推知。
4. **新住民二代的认同流动性**:新住民(含陆配与东南亚配偶)子女已占年轻世代相当比例。他们是否会在「台湾人认同」的主流框架下发展出更具流动性的双文化认同,从而在特定议题(如对外劳政策、对东南亚新南向)上与原生台湾人产生分歧,尚待长期观察。
5. **短影音演算法对政治审美的重塑**:年轻世代对政治人物的偏好,越来越受到短影音平台的演算法与迷因文化影响。这种审美偏好是否会在2028年催生出全新的政治人物类型,从而打破现有的政党提名逻辑,仍属未知领域。
---
## 与其他文件的关系
- `audiences/generational/youth_18_35.md` —— 关联功能:提供青年受众的微观机制与数据支撑,验证本框架中「惩罚机制」与「反建制审美」的论断。
- `audiences/generational/middle_age_36_55.md` —— 关联功能:提供中年受众的避险逻辑与阶级分化细节,支撑本框架中「中年摇摆州」的推演。
- `audiences/generational/senior_56plus.md` —— 关联功能:提供中老年受众的恩庇网络与信息行为细节,解释KMT物理铁票的构成与衰减。
- `primitives/concepts/generation_politics.md` —— 关联功能:提供APC模型的方法论基础,是本框架「世代差异vs年龄效应」区分工具的理论源头。
- `narratives/domestic/generational_divide_voting.md` —— 关联功能:提供历次选举的世代投票实证数据,作为本框架政治倾向对照维度的硬证据。
---
## 一句话浓缩(重申)
世代受众差异图谱的核心不是人口统计标签,而是**信息环境、经济结构与主权威胁交织下的动态约束矩阵**——青年受制于「演算法同温层+分配剥夺+主权预设」,中年受困于「信息茧房+资产避险+双重认同拉扯」,中老年固守于「传统媒体权威+生存稳定+法统情感」;分析者若混淆世代效应与年龄效应,或将青年视为DPP铁票,将严重误判2028年三党竞争的流动格局与认知战的渗透路径。
---
last_updated: 2025-05-19
@@ -1,260 +0,0 @@
# 中年世代受眾(36-55歲)
---
**類型**: narrative
**當前角色**: 政治敘事 / 關鍵擺盪選民結構分析
**出生時間**: 1973-1992年(以2028年為基準計算)
**何時調用此檔案**: 當分析涉及台灣選舉的決勝盤結構、資產與分配議題的交叉效應、野百合世代的政治記憶與現實妥協、家庭責任對避險邏輯的塑造、中華民國台灣化的認同機制、以及任何需要將「經濟主力與風險趨避者」作為核心解釋變量的選舉模型推演時。
---
## 一句話濃縮
中年世代的核心不是「藍綠的中間選民」,而是**雙重約束下的避險者**——他們在認同上趨向「務實的台灣人」,在制度上依賴「中華民國的保護殼」,在經濟上分裂為「資產保值者」與「房貸奴隸」,在兩岸上信奉「不挑釁不投降的維持現狀」;野百合世代的理想主義已被家庭責任與資產焦慮馴化,分析者若將此群體視為鐵板一塊的經濟保守派,或假設其會隨年齡增長而回流KMT的傳統路線,將嚴重誤判2028年「經濟分配vs.戰爭恐懼」的交叉施力點。
---
## 群體定義與邊界:分裂的承擔者
### 事實層:人口規模與關鍵區分維度
台灣36-55歲中年世代,涵蓋約450-500萬選民,是台灣社會的經濟主力與家庭支柱。此群體的邊界並非連續的,而是被兩個截然不同的政治社會化階段所切割:
1. **46-55歲(1973-1982年出生)**:約佔此群體一半。經歷威權末期與民主轉型,為「野百合世代」的主體。多數已完成資產積累(購屋期在2000-2010年低房價時代),目前處於「資產保值與子女教養」階段。
2. **36-45歲(1983-1992年出生)**:約佔此群體一半。出生即處於民主化初期,社會化完成於2000年後的藍綠對抗與全球化競爭中。多數面臨「高房價購屋或租屋」的生存壓力,為「房貸與育兒」雙重擠壓的世代。
### 脈絡層:內在結構性矛盾——階級斷裂發生在世代內部
不同於年長世代相對同質的「經濟受益者」與年輕世代相對同質的「分配受害者」,中年世代內部存在劇烈的階級斷裂。46-55歲群體中,擁有多房產者的利益與資本家一致;36-45歲群體中,無殼蝸牛的利益與年輕世代重合。這種「世代內部的階級對立」,使得任何單一的經濟政策(如囤房稅、都更)都會在此群體內引發零和博弈的撕裂感。
### 分析含義
分析中年世代的投票傾向時,不可將「年齡」作為單一控制變量。必須引入「房產狀態」(有無房貸、是否多屋)與「職業類別」(受薪白領vs.中小企業主)作為交叉變量。一個背負千萬房貸的40歲受薪階級,其對分配正義的渴望與20歲年輕人無異;而一個坐收租金的50歲房東,其對兩岸和平紅利的渴望則與傳統KMT支持者合流。混淆這兩者,將導致政策效應的誤判。
---
## 政治社會化與特殊記憶:野百合的馴化與後解嚴的務實
### 事實層:兩段斷裂的集體記憶
1. **野百合世代的理想與妥協(46-55歲)**:此群體在1990年參與或見證了野百合學運,其青年期的政治記憶是「反抗威權、追求民主」。然而,進入壯年後,他們經歷了2000年政黨輪替的震盪、2008年金融海嘯的衝擊,以及太陽花運動時作為「社會中堅」的焦慮。當年的學運領袖多已進入體制,成為政客、高管或學閥。
2. **後解嚴世代的藍綠疲勞(36-45歲)**:此群體沒有威權記憶,其政治啟蒙伴隨著陳水扁時期的藍綠惡鬥與馬英九時期的兩岸整合。他們對政治的初始印象是「對立與空轉」,而非「理想與抗爭」。2014年太陽花運動時,他們是剛入職場的青年,對主權有危機感,但更關心自身的經濟出路。
### 脈絡層:從「衝破體制」到「依賴體制」的防禦性轉向
野百合世代的核心特徵是「理想的馴化」。他們年輕時挑戰體制,中年後卻成為體制最大的受益者與防禦者。這種轉向並非因為他們背叛了理想,而是因為他們的生命階段發生了質變——他們背負了房貸、養育了子女、積累了退休金。任何「打破現狀」的號召(無論是統獨激進還是分配革命),對他們而言都是對其生命積累的威脅。
**破除常見簡化**:將野百合世代等同於DPP的鐵票是錯誤的。他們確實在認同上傾向台灣主體性,但在利益上極度趨避風險。當DPP的執政被感知為「引發戰爭風險」或「破壞經濟穩定」時,他們會毫不猶豫地用選票懲罰DPP,因為他們的「生存底線」高於「認同情結」。
### 分析含義
在分析2028選情時,不可假設野百合世代會因為「抗中保台」而自動歸隊DPP。他們對主權的支持是「防禦性」的,底線是「不要打仗」,而非「積極建國」。如果KMT或TPP能成功將DPP塑造成「挑釁者」,將有效動搖此群體的投票意向。交叉引用 `primitives/concepts/generation_politics.md`,可見「年齡效應」(風險趨避隨年齡上升)在此群體中已壓過了「世代效應」(早期的反威權烙印)。
---
## 認同的複雜位置:「中華民國台灣」的實用主義
### 事實層:雙重認同的疊合
歷年政大選研中心數據顯示,36-55歲中年世代在「台灣人/中國人」認同上,高達七成以上自認為「台灣人」。但在「統獨立場」上,此群體壓倒性地支持「維持現狀」(無論是維持現狀以後再決定,還是永遠維持現狀)。更關鍵的是,在「中華民國」的態度上,他們與年輕世代存在顯著差異:年輕世代多視「中華民國」為借殼上市的暫時性符號,中年世代則普遍將「中華民國」視為具有實質保護功能的制度載體。
### 脈絡層:認同作為避險工具
中年世代對「中華民國台灣」的接受度,本質上是一種實用主義的避險策略:
1. **對內的避險**:中華民國憲法與體制,是他們房產權狀、存款準備、退休金制度的法律基礎。更改國號或法理台獨,意味著這些資產的法理基礎可能動搖。
2. **對外的避險**:中華民國是對北京宣稱「未獨立」的擋箭牌,也是對華盛頓表明「不挑釁」的承諾書。它是在美中博弈夾縫中,維持台灣實質獨立運作的最小公約數。
**結構性命題**:中年世代是「中華民國台灣化」的社會基礎。他們在情感上是台灣人,在理智上需要中華民國。這使得他們對DPP的「中華民國台灣」論述有最高共鳴,但也對DPP內部的法理台獨傾向最為恐懼;同時,他們對KMT的「中華民國」有制度依戀,但對KMT將中華民國連結至「中國」的歷史法統極度排斥。
### 分析含義
任何試圖在2028年操作「認同極化」的政黨,都會在中年世代踢到鐵板。KMT若強調「一中同表」,會被此群體視為資產風險;DPP若暗示「法理台獨」,同樣會被視為生存威脅。交叉引用 `narratives/cross_strait/taiwanese_identity_narrative.md`,分析者必須注意,中年世代的台灣認同是「帶著安全帽的認同」,與年輕世代的「裸露認同」在風險偏好上截然不同。
---
## 信息行為:同溫層的節點與家庭群組的戰場
### 事實層:跨平台的信息穿梭
中年世代是台灣唯一同時大規模使用傳統媒體與新媒體的群體:
1. **電視新聞的慣性**:46-55歲群體仍維持對電視新聞(特別是TVBS、中天網路版)的高信賴度,將其作為日常背景音與基本資訊源。
2. **Line群組的封閉生態**:此群體是Line最活躍的用戶,家庭群組與工作群組是其核心社交場域。Line群組具有高同質性、高封閉性與高信任度的特徵,是假訊息與認知戰的溫床。
3. **Facebook的公共展演**:FB仍是他們表達政治立場的主要平台,但因顧忌同事與長輩觀感,發言常趨向「隱晦諷刺」或「生活化包裝」。
4. **YouTube的長影音消費**:此群體是YouTube政論頻道(如館長、新聞大白話、關鍵時刻)的核心收視群,習慣在通勤或做家事時以背景音方式收聽。
### 脈絡層:作為「信息節點」的焦慮
中年世代在信息網絡中扮演「節點」角色——他們同時接收來自年長世代(Line長輩圖)和年輕世代(IG/Threads迷因)的信息。這使得他們處於持續的認知失調中:他們必須在家庭群組中回應長輩的「疑美論」轉傳,又必須在親子互動中面對子女的「抗中保台」訴求。這種夾擊,迫使他們發展出一套「實用主義的過濾機制」:只相信對自己生存有利的信息,排斥增加焦慮的信息。
### 分析含義
對中年世代的認知操作,不能採用對年輕世代的「迷因化」或對年長世代的「標題黨化」。最有效的認知戰框架是「焦慮放大器」:將複雜的國際局勢簡化為「你的房貸/退休金/孩子安全會因此受損」的生存威脅。北京若能成功將「戰爭風險」與「DPP執政」深度綁定,將在此群體的Line群組中產生病毒式傳播。
---
## 政治傾向與投票模式:恐懼與利益的擺盪
### 事實層:歷次選舉的關鍵轉向
中年世代是台灣選舉的「勝負手」。回顧近兩屆總統大選:
1. **2020年**:香港反送中事件將「主權顯影度」拉至最高,中年世代基於「避戰保台」的防禦邏輯,壓倒了對DPP內政不滿的情緒,大量流向蔡英文。這是恐懼(對北京)驅動的投票。
2. **2024年**:北京壓力常態化但未升級,主權顯影度下降;同時,房價與物價飆升,分配顯影度上升。中年世代的投票行為出現分裂:46-55歲資產族部分回流KMT(期待兩岸交流紅利);36-45歲房貸族則大量流向TPP(懲罰DPP分配失敗,同時拒絕KMT親中)。這是利益(經濟)驅動的投票。
### 脈絡層:雙重恐懼的競爭
中年世代的投票邏輯,始終在兩種恐懼之間擺盪:
1. **對戰爭的恐懼**:這有利於KMT的「和平論述」,但也可能有利於DPP的「強化國防論」(若他們相信只有備戰才能避戰)。
2. **對破產的恐懼**:這有利於承諾經濟成長的政黨。但當經濟成長只表現在股市與房市,而未轉化為實質薪資成長時,此群體中的受薪階級會感到被剝削,轉向TPP的抗議路線。
**中等模型自己想不到的洞察**:中年世代對「戰爭」的定義比年輕世代寬泛。年輕世代擔心的是「登陸作戰」的物理毀滅,中年世代擔心的更是「經濟封鎖」的金融毀滅——他們害怕的不是死於砲火,而是房貸斷供、資產歸零、孩子無法出國留學。因此,北京的「灰色地帶脅迫」(如軍演常態化、ECFA中止)對中年世代的殺傷力,反而可能比飛彈試射更大,因為這直接打擊了他們的資產預期。
### 分析含義
2028年的選戰,誰能掌握中年世代的「恐懼定義權」,誰就能拿下總統大位。如果DPP能讓中年世代相信「投給KMT/TPP就是讓北京吃掉你的退休金」,DPP就能穩住基本盤;如果KMT/TPP能讓中年世代相信「繼續讓DPP執政就是讓兩岸爆發封鎖戰導致資產歸零」,在野陣營就能翻盤。交叉引用 `narratives/domestic/generational_divide_voting.md`,此群體的擺盪是決定「認同顯影度」與「分配顯影度」孰強孰弱的終極風向標。
---
## 核心議題敏感點:三明治夾擊下的剛需
### 議題一:居住與資產(最核心的分裂點)
- **46-55歲(資產保值)**:此群體多已購屋,甚至擁有第二間房產作為退休保障。他們對「居住正義」的態度是矛盾的:理智上知道高房價剝削年輕人(包括自己的子女),情感與利益上卻無法接受房價下跌。他們反對打房,但支持「協助年輕人購屋」的補貼政策(如新青安貸款),因為這可以讓自己子女上車,同時不損害自身資產價值。
- **36-45歲(生存擠壓)**:此群體是高房價的直接受害者。他們在2010年代後期才進入購屋市場,背負著極高的房貸所得比。他們對居住正義有強烈需求,但同時又恐懼利率上升或經濟衰退導致斷供。他們是「脆弱的房貸奴隸」,對任何可能增加持有成本的政策(如囤房稅轉嫁為租金上漲)極度敏感。
交叉引用 `narratives/domestic/housing_crisis_discourse.md`,分析者必須注意,中年世代對居住議題的態度不是「支持vs.反對」,而是「如何在不讓我受損的前提下解決問題」。這使得任何實質的打房政策,都會在此群體中遭遇「沉默的多數」的反彈。
### 議題二:生育與教育(未來焦慮的投射)
台灣的少子化危機,在中年世代身上體現為最直接的焦慮。他們是「最後一代有兄弟姊妹,也是第一代要獨自照顧長輩」的三明治世代。交叉引用 `narratives/domestic/low_birthrate_discourse.md`,他們對低生育率的恐懼,本質上是對「自己老後無人照顧」與「子女未來階級滑落」的雙重恐懼。這使得他們對教育分流、私校退場、學貸政策等議題極度敏感。
### 議題三:兩岸與經濟(生存底線)
對中年世代而言,兩岸議題就是經濟議題。他們不在乎「九二共識」的內涵,只在乎「ECFA能不能續簽」、「台積電會不會被炸」、「股市會不會崩盤」。他們是「經濟理性」的信仰者,相信兩岸穩定是經濟發展的前提,但也深知過度依賴北京的風險。
---
## 認知脆弱點與韌性
### 認知脆弱點:生存焦慮的劫持
1. **「戰爭與和平」的二元框架**:中年世代極易被「選擇戰爭還是選擇和平」的簡化框架劫持。因為他們有太多可以失去的東西,當被置於「失去一切」的想像中時,他們會傾向於選擇任何承諾和平的選項,即使該選項隱含主權妥協。
2. **「經濟崩潰」的滑坡謬誤**:他們容易相信「兩岸關係不好→經濟一定變差→我會失業斷貸」的直線邏輯,忽略了台灣經濟結構的韌性與全球供應鏈的重組。
3. **「青年世代的背叛」的代際焦慮**:他們對年輕世代投給TPP感到不解與焦慮,容易接受「年輕人被洗腦/不懂事」的論述,從而加深代際對立,忽略年輕人分配剝奪的實質訴求。
### 認知韌性:經驗與制度的防波堤
1. **對極端路線的免疫力**:經歷過台灣民主轉型與政黨輪替的震盪,他們對「激進變革」抱有深層的不信任。無論是急統還是急獨,只要被感知為「破壞現狀」,都會被此群體的經驗濾鏡擋下。
2. **對北京承諾的現實認知**:與年長世代對「中國崛起」的浪漫想像不同,中年世代(尤其是36-45歲)對北京的銳實力與統戰手段有較清醒的認識。他們可能支持兩岸交流,但絕不相信北京的讓利是無條件的。
3. **資訊交叉比對能力**:相較於年長世代對單一媒體的盲從,中年世代具有較強的資訊交叉比對習慣。他們會看電視,也會上網查證;會聽Line群組的傳言,也會看PTT的闢謠。
---
## 兩岸與國際態度:避險的走鋼索者
### 兩岸態度:維持現狀的實質內涵
中年世代支持的「維持現狀」,不是靜態的凍結,而是「動態的避險」:
1. **對北京**:他們反對一國兩制,反對軍事脅迫,但希望維持經貿往來。他們將北京視為「危險的客戶」——不能不往來,但不能太親近。
2. **對主權**:他們支持台灣的實質獨立運作,但反對挑釁北京的法理底線。他們心中的紅線是「不要因為意識形態而讓我的生活毀於一旦」。
### 國際態度:疑美但不反美,親日但防日
1. **對美方**:中年世代是「疑美論」的易感人群,但不是「反美論」的支持者。他們質疑美國的承諾是否可靠(尤其是美國優先政策與棄台論的影響),但他們深知台灣沒有本錢失去美國的安全保障。他們的態度是「付保護費,但要求開收據」。
2. **對日方**:相較於年長世代的反日情結與年輕世代的哈日文化,中年世代對日本的態度是「務實的親近」。他們欣賞日本的秩序與品質,支持台日安全合作,但在漁權與經貿競爭上保持警惕。
**分析含義**:2028年,若美國出現要求台灣增加軍費或承擔更多風險的壓力,中年世代將是最反彈的群體。他們願意買武器,但不願意被推上第一線。任何被視為「將台灣推向火線」的美國政策,都可能在中年世代中催生強烈的避戰情緒,進而轉化為對執政黨的懲罰。
---
## 近年變化趨勢:5-10年的結構性演變
1. **從「經濟受益者」到「經濟焦慮者」的質變**:過去十年,全球化的紅利集中於科技業與資本家,傳統產業與中小企業主的中年世代實質所得停滯。加上通膨與升息,他們的經濟安全感大幅下降,這使得他們對執政黨的容忍度變低。
2. **「抗中保台」的邊際效應遞減**:2020年是高峰,2024年已顯疲態。中年世代不再接受「為了主權可以犧牲經濟」的論述,他們要求「主權與經濟雙贏」,若做不到,他們會優先保經濟。
3. **代際財產移轉的焦慮**:46-55歲群體開始面臨將財產傳給子女的問題,但他們發現,即使自己有房,子女在當前房價下依然買不起。這種「我的資產無法保障我的下一代」的焦慮,正在侵蝕他們對現有體制的信心。
4. **健康與長照的隱性壓力**:隨著父母進入高齡,中年世代成為長照的主要承擔者。長照資源的不足與政策的碎片化,讓他們在私領域疲於奔命,降低了對公共事務的參與熱情,但也增加了對政府無能的憤怒。
---
## 關聯分析
### 關聯一:世代投票差異的動態模型
- **關聯文件**: `narratives/domestic/generational_divide_voting.md`
- **功能**: 提供分析框架與交叉驗證。該檔案中「中年世代:分裂與搖擺的勝負手」段落,為本檔案提供了宏觀的選舉數據支撐。本檔案進一步深化了其內部分裂的機制(資產vs.負債),驗證了「經濟與資產焦慮」是解釋中年世代擺盪的核心變量。
### 關聯二:世代政治概念的理論基礎
- **關聯文件**: `primitives/concepts/generation_politics.md`
- **功能**: 提供脈絡層。該檔案中的「年齡效應vs.世代效應」理論,解釋了野百合世代為何從「衝撞體制」轉向「防禦體制」。這證明了中年世代的保守化不是因為他們背叛了年輕時的自己,而是生命階段的利益結構發生了根本翻轉。
### 關聯三:台灣人認同論述的防禦性本質
- **關聯文件**: `narratives/cross_strait/taiwanese_identity_narrative.md`
- **功能**: 提供因果驗證。該檔案解釋了「中華民國台灣化」的認同機制,這正是中年世代在統獨光譜上的精確定位。他們將中華民國作為防禦性的工具,而非法統的象徵,這驗證了認同論述在實用主義下的變形。
### 關聯四:居住危機論述的階級分裂
- **關聯文件**: `narratives/domestic/housing_crisis_discourse.md`
- **功能**: 提供事實層與對照。驗證了中年世代內部在居住議題上的零和博弈。46-55歲的「資產保值」與36-45歲的「上車焦慮」,構成了居住論述在中年世代無法形成共識的結構性原因。
### 關聯五:少子化論述的未來焦慮
- **關聯文件**: `narratives/domestic/low_birthrate_discourse.md`
- **功能**: 提供事實層。解釋中年世代為何對「國家未來」感到悲觀。少子化對他們而言不是統計數字,而是「自己老後無人照顧」的具體威脅,這塑造了他們對福利政策與兩岸和平的強烈剛需。
### 關聯六:北京對台脅迫的經濟效應
- **功能**: 提供因果驗證。北京的灰色地帶脅迫(如ECFA中止、軍演常態化)直接打擊中年世代的資產預期與經濟安全感。這解釋了為何中年世代對北京的反感不如年輕世代強烈(因恐懼戰爭),但對兩岸關係惡化的經濟痛感卻比年輕世代更敏銳。
---
## 常見的分析陷阱
### 陷阱一:將中年世代視為「鐵板一塊的經濟保守派」
這是最常見的謬誤。中年世代內部因「房產狀態」的差異,在分配議題上存在根本對立。36-45歲的房貸族在經濟立場上可能比年輕世代更激進(因為他們背負實質債務),而46-55歲的收租族則可能比年長世代更保守(因為他們要保護既得利益)。忽略此內部分裂,將導致政策效應的嚴重誤判。
### 陷阱二:假設「野百合世代=DPP鐵票」
野百合世代確實在青年期支持DPP,但他們中年後的利益結構已改變。他們對DPP的認同是「有條件的」——條件是DPP能維持經濟穩定與避戰。當DPP被視為「挑釁者」或「經濟破壞者」時,野百合世代會毫不留情地轉向。將他們視為DPP鐵票,是錯將世代效應當作永久忠誠。
### 陷阱三:認為「老了就會變藍」的線性外推
這是從年長世代經驗錯誤推導的結論。年長世代變藍是因為他們的社會化基礎是「大中國法統」;中年世代的社會化基礎是「台灣本土化與民主化」。他們老後只會變成「避戰的台灣人」,絕不會倒退為「親中的中國人」。KMT若期待中年世代老化後會回流,將面臨殘酷的失望。
### 陷阱四:高估「主權危機」對中年世代的動員力
2020年的經驗不適用於2028。中年世代對主權危機的容忍度已下降,因為他們的經濟壓力更大。若北京只進行低強度脅迫(如常態化軍演),中年世代會將其視為「新常態」而非「生存危機」,進而將注意力轉回內政分配。假設北京每次軍演都能幫DPP在中年世代催票,是嚴重的路徑依賴。
### 陷阱五:忽略「家庭群組」作為認知戰破口的作用
分析者常關注網路社群(如PTT、Threads),卻忽略Line家庭群組對中年世代的影響。中年世代在家庭群組中面對長輩的壓力,常被迫接受或妥協於「疑美論」或「和平台獨論」。這種私領域的社會控制,比公領域的認知戰更難防禦,也更具殺傷力。
### 陷阱六:用「統獨光譜」定義中年世代的兩岸態度
中年世代不在乎統獨光譜,在乎的是「風險光譜」。他們不是在「統一與獨立」間選擇,而是在「安全與毀滅」間選擇。將他們的「維持現狀」解讀為「偏向統一」或「偏向獨立」,都是錯置了分析框架。
---
## 仍不確定的部分
1. **全球供應鏈重組對中年就業的衝擊**:台商回流與半導體擴張,是否會創造足夠的中年就業機會,還是只圖利科技新貴?若傳統產業中年世代大規模失業,其政治立場是否會從「避險」轉向「激進求變」?目前缺乏針對中年就業結構的細部數據。
2. **AI技術對中年白領的替代效應**:2028年前,AI是否會大規模取代中年行政與中階管理職務?若中年白領面臨降薪或失業危機,其對執政黨的懲罰力度將遠超過去,這可能徹底改變中年世代的投票邏輯。
3. **長照崩潰的臨界點**:中年世代承擔的長照壓力何時會達到社會臨界點?若長照體系在2028前發生重大事故或資源排擠,是否會催生以中年世代為主體的「照顧者正義」運動,進而重塑政治議題優先級?
4. **「新青安貸款」等房市政策的後座力**:政府補貼青年購屋貸款,短期內安撫了36-45歲群體,但長期是否會推高房價,導致未購屋者的憤怒反撲?此政策在2028年前是「買票利器」還是「未爆彈」,仍待觀察。
5. **北京對台經濟脅迫的精準度**:北京是否會在2028前針對中年世代的資產(如特定產業關稅、金融制裁)進行精準打擊,而非全面軍演?若北京成功將經濟痛感集中於中年世代,是否會迫使此群體向「和平協議」方向擺盪?此為最大外部變數。
---
## 與其他文件的關係
- `narratives/domestic/generational_divide_voting.md` —— 關聯功能:提供中年世代在整體選民結構中作為「勝負手」的宏觀定位,驗證其擺盪特性對選舉結果的決定性影響。
- `primitives/concepts/generation_politics.md` —— 關聯功能:提供「年齡效應壓過世代效應」的理論基礎,解釋野百合世代從理想主義向實用主義的轉向機制。
- `narratives/cross_strait/taiwanese_identity_narrative.md` —— 關聯功能:解釋「中華民國台灣化」的認同機制,支撐本檔案中中年世代將認同作為避險工具的論斷。
- `narratives/domestic/housing_crisis_discourse.md` —— 關聯功能:提供居住議題的階級分析視角,驗證中年世代內部因資產狀態不同而產生的零和博弈結構。
- `narratives/domestic/low_birthrate_discourse.md` —— 關聯功能:提供少子化焦慮的具體論述,解釋中年世代對未來悲觀的深層心理動因及其對政策剛需的影響。
---
## 一句話濃縮
中年世代是雙重約束下的避險者,野百合的理想已被資產與家庭責任馴化為對「中華民國台灣」的實用主義依戀,他們在戰爭恐懼與破產恐懼間擺盪,內部因房產狀態而分裂為零和博弈的兩個階級;分析者若忽略其內部的階級斷裂,或誤判其避險底線為藍綠鐵票,將徹底失準於2028年經濟分配與兩岸風險的交叉施力點。
---
last_updated: 2025-05-19
@@ -1,269 +0,0 @@
# 中老年世代受眾(56 歲以上)
---
**類型**: narrative / 受眾分析
**當前角色**: 政治受眾 / 選民結構核心盤 / 資訊傳播節點
**出生時間**: 1969年及以前(以2025年為基準)
**何時調用此檔案**: 當分析涉及台灣選舉的藍營基本盤結構與物理凋零效應、傳統媒體與LINE群組的資訊傳播路徑與說服機制、退休族群的議題偏好(年金、長照、避戰)與高動員率來源、世代衝突中的年長者視角、威權社會化對當前兩岸論述的投射效應、以及任何需要將「年齡/退休狀態」作為解釋變量的政治行為模型時。
---
## 一句話濃縮
中老年世代受眾的核心不是「數位落伍的深藍鐵票」,而是**威權社會化烙印、恩庇網路依存與高時間投入資本交織下的高動員率實體**——他們內部橫跨了「仍在職場掙扎的戰後嬰兒潮後期」到「完全依賴年金與實體社交的威權固化期」,其資訊行為以電視政論為錨點、LINE群組為社交貨幣加速器,在兩岸議題上呈現出「反共法統」與「避戰親和」的內在矛盾;分析者若將56歲以上群體視為同質的保守板塊,或將其LINE群組行為簡化為「假訊息易感者」,將嚴重誤判此群體在2028年作為「主權避戰牌」與「組織動員樁腳」的雙重決定性力量。
---
## 群體定義與邊界:不可化約的內部斷層
將56歲以上群體視為單一「老年板塊」是常見的分析謬誤。此群體橫跨近三十年的出生世代,經歷了台灣從農業社會、威權體制、經濟起飛到民主轉型的全部過程,其內部異質性遠大於同質性。必須以「政治社會化關鍵期」與「當前生命階段」雙重維度進行切割。
### 事實層:人口規模與三層次結構
依據內政部人口統計與選舉人名冊推估,2028年時56歲以上選民約佔總選民的38%-42%,仍是決定選舉勝負的最大物理板塊。但其內部可分為三個截然不同的次群體:
1. **準退休世代(56-65歲,約1963-1972年出生)**:
- **邊界特徵**:多數仍在職,面臨延後退休與房貸尾聲的雙重壓力。其政治社會化關鍵期橫跨了威權末期與解嚴初期(李登輝時期),是「第一代民主選民」。
- **資訊行為**:具備基本的智慧型手機與網路使用能力,同時使用LINE與Facebook。對電視政論的依賴度低於年長者,對YouTube長影音有一定接觸。
- **政治傾向**:並非KMT的鐵票,是此年齡段中最具搖擺可能的群體。2024年部分此群體選民因經濟壓力轉向TPP,或因反感韓國瑜風格而曾投給DPP。
2. **核心退休世代(66-75歲,約1953-1962年出生)**:
- **邊界特徵**:戰後嬰兒潮主力,多數已退休或屆退,享有相對優渥的勞保或軍公教年金。其社會化關鍵期處於經濟起飛與威權穩定期(蔣經國時期),是「經濟奇蹟的受益者」與「黨國體制的共構者」。
- **資訊行為**:LINE群組的重度使用者,每日使用時間超過2小時。電視政論節目是日常背景音與權威資訊來源。對報紙(特別是聯合報、中國時報)仍有慣性信任。
- **政治傾向**:KMT的物理鐵票與恩庇網路核心。此群體的投票行為高度受地方派系、農會、水利會與軍公教網路的節制。
3. **威權固化世代(76歲以上,1952年及以前出生)**:
- **邊界特徵**:多數經歷過二戰末期或國民政府遷台初期,外省族群比例在此區間顯著偏高。完全依賴年金與子女奉養,健康衰退導致行動受限。
- **資訊行為**:以傳統電視與實體社交(公園、廟口、眷村活動中心)為主。對智慧型手機操作困難,LINE群組多為被動接收者(晚輩或樁腳發送,僅轉發不回覆)。
- **政治傾向**:深藍法統的堅定捍衛者,對「中華民國」有強烈的情感寄託,對DPP有本能的排斥(視為「社會動亂之源」)。
### 脈絡層:年齡作為資源分配的隱形軸線
這三個層次的差異,本質上是「資源擁有量」與「風險承受度」的差異。56-65歲仍在累積或保衛資產,對「分配正義」與「通貨膨脹」敏感;66-75歲已進入資產消耗期,最恐懼「年金改革」與「兩岸開戰導致資產歸零」;76歲以上則關注「長照資源」與「實體安全」。這種生命階段的利益差異,直接決定了不同政治論述對他們的穿透力。
### 分析含義
分析2028年選情時,不可將「銀髮族政策」或「長照政策」一體適用。對56-65歲談「居住正義與房貸補貼」才有感;對66-75歲談「年金不倒與兩岸和平」才能動員;對76歲以上則需依賴「實體接送與便當」的組織戰。錯置訴求,將導致選舉資源的無效投放。
---
## 政治傾向與投票模式:高動員率的物理基礎
中老年世代構成了KMT的傳統支持基礎,這不僅是意識形態的契合,更是物質利益與社會網路深度綁定的結果。
### 事實層:投票率與政黨偏好的數據特徵
歷次選舉出口民調顯示,60歲以上選民的投票率穩定維持在75%-85%之間,遠高於20-39歲的50%-65%。在政黨偏好上,2020年韓國瑜在60歲以上區間領先蔡英文約15個百分點;2024年侯友宜在此區間仍維持穩定領先,是KMT唯一佔據絕對優勢的年齡層。DPP在此區間的支持度長期徘徊在30%-35%的低位。
### 脈絡層:高投票率的雙重驅動——義務感與時間資本
為何中老年世代的投票率居高不下?這並非單純的「政治熱情」,而是由兩個結構性因素支撐:
1. **公民義務的威權遺產**:此世代在威權時期被灌輸「投票是國民義務」的觀念(儘管當時是投票給唯一的候選人)。解嚴後,這種觀念轉化為「不投票是對不起國家/先烈」的道德焦慮。與年輕世代將投票視為「契約式授權」不同,長者將投票視為「身分證明的儀式」。
2. **時間資本的絕對過剩**:退休族脫離了職場的時間紀律,擁有極高的時間自主權。投票對他們而言沒有「請假扣薪」的機會成本,反而是一種具有社交意義的日常活動(與鄰居相約投票、順便喝咖啡)。
### 脈絡層:恩庇網路的最後堡壘
KMT在此群體的優勢,深植於台灣特殊的「恩庇侍從主義」(Clientelism)。66歲以上的選民,特別是中南部與外島,其生命歷程中的就業、福利、糾紛調解,高度依賴KMT的地方派系網路(農會、漁會、宗親會)。這種依存關係並未因民主化而消失,而是轉化為選舉時的「固樁」機制。樁腳提供的不是政治理念,而是實質的服務與人情。
### 分析含義
分析者必須區分「意識形態支持者」與「恩庇網路依存者」。對前者,KMT的「親和中」論述是加分項;對後者,只要樁腳沒有倒戈,候選人提出什麼論述並不重要。DPP在地方選舉中難以突破,正是因為無法瓦解這種實體的恩庇網路。但2028年,隨著核心樁腳世代的老去,此網路正面臨物理斷層。
---
## 戒嚴時期經驗對政治認知的塑造:反共與法統的雙重變奏
要理解中老年世代對兩岸議題的態度,必須回到其政治社會化的關鍵期——戒嚴時代。交叉引用 `events/timelines/democratization_timeline.md`,可見此時期的官方論述如何深植於此群體的潛意識。
### 事實層:威權教育的烙印
66歲以上的世代,在30歲之前完全生活在戒嚴體制下。他們接受的是「反共復國」、「殺朱拔毛」、「共產黨是萬惡共匪」的黨國教育。同時,他們也被教導「中華民國是正統,大陸是淪陷區」。這兩者構成了他們政治認知的底層操作系統。
### 脈絡層:從「反共」到「避戰」的內在置換
**破除常見簡化**:將中老年世代等同於「親中」或「賣台」是嚴重的誤判。他們的底層邏輯是「反共」,但這個「反共」與年輕世代的「抗中」有本質區別:
1. **年輕世代的「抗中」**:基於「台灣是獨立主權體,反對PRC的侵略與併吞」,是主權防線的防禦。
2. **中老年世代的「反共」**:基於「共產黨是邪惡的,會剝奪私有財產與自由」,是生存財產的防禦。同時,他們對「中華民國」的法統情感,使他們無法接受PRC取代ROC。
然而,隨著年齡增長與體力衰退,此群體的「風險趨避」本能壓過了「意識形態堅持」。北京近年來的對台論述,成功地將「統獨之爭」置換為「戰爭與和平之爭」。對中老年世代而言,他們恐懼的不是「被統一」(因為他們成長於兩岸都主張統一的時代,差別只在誰統誰),而是「戰爭摧毀他們積累一生的資產與平靜」。因此,KMT的「親美和中」、「重啟交流」論述,精準地回應了他們「避戰保產」的心理需求,即使這在客觀上可能削弱台灣的主權防線。
### 分析含義
當DPP指責KMT「親中賣台」時,在中老年世代心中不僅無效,反而會引發反彈。因為在他們的認知框架裡,他們是在「追求和平」,而非「出賣主權」。分析者必須理解,此群體的「避戰邏輯」與年輕世代的「主權邏輯」是兩條平行的認知軌道,雙方無法用對方的語言溝通。這也是台灣國內在兩岸議題上出現世代對話斷裂的根源。
---
## 資訊行為與傳播載體:電視的權威與LINE的社交貨幣
中老年世代的資訊行為,是理解其政治認知如何被塑造的關鍵。此部分必須客觀描述,避免將其簡化為「數位文盲」或「假訊息傳播者」。
### 事實層:雙軌並行的資訊接收模式
1. **電視作為權威錨點**:交叉引用 `entities/media/political_talk_shows_overview.md`,電視新聞與政論節目是66歲以上世代最主要的資訊來源。電視具有「陪伴」功能,是退休生活中填補時間的必需品。政論節目的名嘴,對長者而言具有「專家」與「權威」的光環,其言論往往被視為事實而非評論。
2. **LINE群組作為社交加速器**:交叉引用 `entities/media/line_groups_political.md`,LINE是此群體最主要的私人通訊與社群工具。與年輕世代的「公共廣場」(如Threads、Dcard)不同,LINE群組是封閉的「客廳」。長者在群組中轉發訊息,往往不是基於對訊息真偽的嚴格查核,而是基於社交動機。
### 脈絡層:LINE群組的「情感理性」與「社交貨幣」
**中等模型自己想不到的洞察**:分析者常將長者在LINE群組轉發假訊息歸因於「數位素養不足」或「認知能力退化」。這是從「工具理性」出發的誤判。從長者的「情感理性」出發,轉發訊息的行為邏輯是:
1. **社交貨幣**:在群組中發言是證明「我還在」、「我關心群體」的方式。轉發一則「注意!某某食物致癌」或「某某黨又要賣台」,本質上是在發送「我關心你的健康/我與你立場一致」的社交信號,而非發布新聞。
2. **情感共鳴優先於事實查核**:當一則訊息符合其既有信念(如「民進黨亂搞」或「大陸打過來怎麼辦」),轉發的衝動會遠高於查證的意願。查證需要跳出群組、使用搜尋引擎、閱讀長文,這對長者而言是高昂的認知成本;而轉發只需一鍵,且能立即獲得群友的「讚」與「感謝」作為正向回饋。
3. **「寧可信其有」的避險邏輯**:面對健康或戰爭威脅的訊息,長者的心理機制是「轉發無成本,不轉發若成真後悔莫及」。這是威權社會化留下的生存直覺——在資訊不透明的時代,寧可過度反應也不要錯失警告。
### 分析含義
打擊LINE群組假訊息的最差策略是「由外群體進行事實查核」。當年輕晚輩或查核中心指出長者轉發的訊息是假的,長者感受到的不是「被糾正」,而是「社交貨幣被否定」與「權威被挑戰」,從而產生防禦性反彈(「你們年輕人懂什麼」、「這是政府打壓的陰謀」)。最有效的策略是「由內群體意見領袖(如醫生、里長)以關心健康的框架進行柔性導正」,或提供具有相同情感共鳴但符合事實的替代訊息。
---
## 核心議題敏感點與兩岸/國際態度
中老年世代的議題偏好,圍繞著「生存穩定」與「資產保值」兩個核心旋轉。
### 核心議題敏感點
1. **年金與福利(生存底線)**:軍公教年金改革是此群體(特別是66-75歲)最深的痛。這不僅是金錢損失,更是「國家違約」與「尊嚴踐踏」。此議題是DPP在此群體中難以癒合的傷口,也是KMT最穩定的動員籌碼。
2. **兩岸避戰(資產防線)**:如前述,他們恐懼戰爭摧毀一切。任何升高衝突的論述(如「抗中保台」、「備戰」)都會觸發其避險本能。
3. **社會秩序與傳統價值(認知防線)**:對同婚、廢死、毒品除罪化等進步價值極度排斥。他們懷念過去「夜不閉戶、社會單純」的威權時期(一種被美化的集體記憶),將當前的社會亂象歸咎於「解嚴後的放縱」與「民進黨的毀憲亂政」。
### 兩岸議題態度:懷舊的ECFA與矛盾的中國觀
- **對ECFA的依戀**:此群體是兩岸經貿整合期的直接受益者(或見證者)。他們將ECFA視為「經濟活水」,將其終止視為「政治意氣用事傷害民生」。
- **對北京的矛盾心態**:他們恐懼PRC的武力,但對「中國」(作為文化與歷史概念)有親切感。他們相信「只要不搞台獨,大陸就不會打」的KMT論述,這是一種心理防禦機制,將戰爭的責任歸咎於「挑釁者」而非「侵略者」,從而獲得虛假的安全感。
### 國際關係態度:疑美論的溫床與對日的複雜情結
- **疑美論的基底**:此群體的「疑美」源於三個層次:1. 歷史記憶(美國曾拋棄中華民國,與PRC建交);2. 現實利益(美國賣昂貴武器又要求台灣吃萊豬);3. 認知戰的滲透(LINE群組中大量流傳的「美國棄台論」、「美國削弱台灣半導體論」)。他們傾向相信「美國會把台灣當棋子用完就丟」,因此更傾向KMT的「親美和中」,認為這比單邊押注美國更保險。
- **對日的複雜情結**:76歲以上的本省族群可能有日治時期的美好記憶(作為反國民黨的心理投射),但整體中老年世代仍受抗戰教育的影響,對日本軍國主義有警惕。然而,在現實層面,他們高度信賴日本的產品與長照制度,對「台日友好」在安全上的意義有一定認知,但不如年輕世代那樣將其視為天然盟友。
### 分析含義
2028年,若北京在選前再次大規模軍演,對年輕世代是「主權防線被碰觸」的憤怒,對中老年世代則是「資產即將歸零」的極度恐懼。這種恐懼可能產生兩種截然不同的效應:一是「退縮效應」,迫使他們更緊抱KMT的避戰論述;二是「覺醒效應」,若恐懼強度超過臨界點,使他們意識到KMT的「和中」根本無法避戰,可能出現恐慌性的政治重組。目前數據顯示,前者發生的機率遠高於後者。
---
## 認知脆弱點與認知韌性:硬幣的兩面
中老年世代在資訊戰中,同時展現了極度的脆弱與意外的韌性。
### 認知脆弱點
1. **數位素養的結構性落差**:缺乏對網路資訊溯源的能力,難以辨識深偽技術(Deepfake)或AI生成的圖文。對「有圖有真相」的舊思維深信不疑。
2. **權威偏信**:威權社會化使其對「穿西裝的人」(教授、醫生、名嘴、將領)有本能的服從與信任。一旦這些權威人物(如退將)在政論節目或LINE群組中散布特定論述,其穿透力極強。
3. **健康焦慮的槓桿**:面對衰老與疾病的恐懼,使健康養生資訊成為最有效的釣餌。認知戰常將政治論述包裹在「某某中藥可防輻射(暗指抗中無用,需靠偏方)」或「戰爭導致缺藥」的養生勸導中。
### 認知韌性
1. **實體人際網路的驗證機制**:與年輕世代依賴網路查證不同,長者遇到疑慮時,傾向詢問「可信的實體人物」(里長、醫生、老朋友)。這種實體網路雖然傳播慢,但具有極高的信任錨定效應,一旦里長或醫生澄清了某個謠言,長者會比年輕人更堅定地拒絕該謠言。
2. **生命經驗帶來的「詐騙免疫力」**:威權時期的生存經驗,使此群體對「過於美好的承諾」有本能的懷疑。他們可能相信陰謀論,但對「加入某組織就能發大財」的直銷式政治話術,反而比年輕人有更強的抵抗力。
3. **法統情感的底線**:雖然容易被「避戰」論述引導,但若北京的論述明確觸碰了「消滅中華民國」的底線(如要求投降或設立軍政府),其深層的「反共/法統」記憶會被激活,產生強烈的排斥。
### 分析含義
針對中老年世代的認知戰,最有效的防禦不是數位工具,而是「實體意見領袖的武裝」。若能讓全台的里長、社區醫生具備辨識假訊息的能力,其防禦效果遠大於千百次的網路事實查核。
---
## 近年變化趨勢:物理凋零與數位遷移的賽局
中老年世代並非靜態存在,他們正在經歷結構性的演變。
### 趨勢一:Cohort Replacement的數學壓力
每四年,76歲以上的深藍鐵票正以每年十幾萬人的速度退出選民名冊。而新加入56歲俱樂部的「準退休世代」,其藍綠屬性遠不如他們的前輩固定。這意味著KMT的「老人基本盤」正在經歷不可逆的物理縮水。交叉引用 `narratives/domestic/generational_divide_voting.md`,這是KMT在2028年面臨的最嚴峻的數學危機。
### 趨勢二:LINE群組的「代際殖民」
隨著76歲以上群體逐漸退出網路活躍圈,LINE政治群組的主導權正在轉移到66-75歲,甚至56-65歲的「年輕老人」手中。這群人的數位素養較高,單純的「複製貼上」假訊息對他們已逐漸失效。認知戰的型態正從「粗糙的長輩圖」進化為「剪輯過的政論片段」與「似是而非的數據分析」。
### 趨勢三:世代衝突的家庭化與退縮效應
年輕世代的政治疏離與反建制情緒,常在家庭群組中與長輩的保守立場發生激烈碰撞。許多長者為了避免家庭失和,選擇在群組中「潛水」或退群。這導致長者的政治表達從「公共領域」退縮回「同溫層實體聚會」,使得他們的立場更加極化且不透明,增加了民調捕捉的難度。
### 分析含義
2028年,KMT無法再單純依賴「老人出來投票就能贏」的邏輯。他們必須面對老人數量減少、老人數位素質提升、以及老人因家庭壓力而隱藏真實意向的三重挑戰。DPP若能提出讓56-65歲「準退休世代」安心的年金與長照政策,有機會在這個KMT的傳統腹地撕開裂口。
---
## 關聯分析
### 關聯一:KMT選民基礎的物理與心理邊界
- **關聯文件**: `entities/parties/kmt_voter_base.md`
- **功能**: 提供事實層與脈絡層。驗證KMT的鐵票結構如何與中老年世代高度重疊。該檔案中的「恩庇網路」與「法統情感」分析,是理解此群體為何在面對KMT弱化時仍不願轉向的關鍵支撐。
### 關聯二:LINE群組的政治傳播機制
- **關聯文件**: `entities/media/line_groups_political.md`
- **功能**: 提供事實層與方法論。解釋LINE群組的封閉性與強連結特徵,如何使其成為長者「情感理性」的實踐場域。此關聯是破除「長輩群=假訊息」簡化思維的核心依據。
### 關聯三:政論節目的權威建構
- **關聯文件**: `entities/media/political_talk_shows_overview.md`
- **功能**: 提供脈絡層。解釋電視政論如何透過名嘴的「表演」與「情緒動員」,填補長者退休生活的空虛,並成為其政治認知的權威錨點。
### 關聯四:世代投票差異的動態模型
- **關聯文件**: `narratives/domestic/generational_divide_voting.md`
- **功能**: 提供分析框架與交叉驗證。該檔案提出的「主權-分配-建制」三維博弈模型,解釋了為何中老年世代在「主權(避戰)」與「建制(恩庇)」兩軸上與年輕世代徹底對立。
### 關聯五:民主化時間線的烙印效應
- **關聯文件**: `events/timelines/democratization_timeline.md`
- **功能**: 提供因果驗證。解釋威權體制下的教育與社會控制,如何在此群體的認知底層埋下「反共」、「服從權威」與「恐懼動亂」的種子,並在當前兩岸局勢下被重新激活。
---
## 常見的分析陷阱
### 陷阱一:將56歲以上群體視為同質的「深藍鐵票」
這是最危險的陷阱。56-65歲的「準退休世代」與76歲以上的「威權固化世代」在經濟利益、資訊行為與統獨立場上存在巨大鴻溝。將他們打包分析,會嚴重高估KMT的鐵票厚度,並忽略DPP在56-65歲區間的突破機會。
### 陷阱二:污名化LINE群組行為,將其等同於「假訊息易感者」
將長者在LINE群組的轉發行為簡化為「無知」或「數位文盲」,是從年輕世代的「工具理性」出發的傲慢。忽略了長者轉發的「社交貨幣」與「情感理性」本質。這種污名化不僅無法阻斷假訊息,反而會將長者推向更封閉的同溫層。
### 陷阱三:忽略「反共法統」與「避戰親和」的內在矛盾
假設中老年世代因為「避戰」就會全盤接受北京的「統一」論述。事實上,他們的底線是「保衛中華民國的現狀」。若北京的脅迫明確威脅到ROC的法統地位(如逼迫接受一國兩制),其深層的反共記憶仍可能被喚醒,產生抵抗意志。
### 陷阱四:低估實體動員的威力與時間資本的優勢
在網路空戰為主的當代分析視角中,常低估了長者「高投票率」與「高時間資本」的物理殺傷力。長者不僅自己投票,還能透過實體網路(如遊覽車接送、便當發放)動員其他弱勢長者。在投票率極低的低顯影度選舉中,這股實體力量足以翻轉選局。
### 陷阱五:假設「經濟红利」能輕易轉化為「政治支持」
KMT常假設只要重提ECFA或兩岸紅利,就能鞏固長者選票。但對已退休的66歲以上群體而言,總體經濟成長的邊際效用極低,他們更關心的是「年金會不會被砍」與「會不會打仗」。若兩岸交流的代價是戰爭風險升高,他們寧可放棄紅利也要避戰。
### 陷阱六:認為「抗中保台」能在此群體產生動員力
DPP常誤以為北京的打壓能自動轉化為長者對DPP的支持。事實上,北京的打壓只會加深長者的恐懼,而恐懼會驅使他們轉向承諾「避戰」的KMT,而非承諾「抗戰」的DPP。這是DPP在長者群體中動員失效的根本原因。
---
## 仍不確定的部分
1. **AI深偽技術對76歲以上群體的實際穿透率**:隨著AI生成語音與影像技術的成熟,未來可能出現完美偽造政治人物宣布投降或開戰的深偽影片。76歲以上群體缺乏辨識能力,且習慣以「有影音有真相」為判準,此類攻擊是否會在2028選前造成系統性恐慌,目前缺乏實證數據。
2. **56-65歲「準退休世代」的長期政黨歸屬**:這群經歷解嚴與太陽花見證的「年輕老人」,是否會隨著年齡增長而「變老變藍」,還是會將其相對進步的價值觀帶入老年?這將決定KMT基本盤的衰退速度是線性還是加速。
3. **家庭世代衝突的退縮效應邊界**:長者為了家庭和諧而在LINE群組「潛水」,是否意味著他們在投票所也會改變立場?還是這會形成某種「隱性鐵票」,在民調中不可見,但在開票時爆發?此種「害羞老人效應」的規模仍無法精確估算。
4. **實體樁腳網路的代際傳承接班**:KMT的地方派系樁腳正面臨嚴重的老化,年輕一代不願接棒。當現有樁腳無法再提供實體服務時,依賴此網路的長者選票會流向何方?是轉向數位動員,還是轉向有能力提供實體服務的新興地方勢力(如無黨籍或TPP)?此斷層的後果仍不明朗。
---
## 與其他文件的關係
- `narratives/domestic/generational_divide_voting.md` —— 關聯功能:提供世代差異的宏觀框架,解釋中老年世代在「主權-分配-建制」三維博弈中的特殊定位。
- `entities/media/line_groups_political.md` —— 關聯功能:提供LINE群組作為政治傳播載體的機制分析,支撐本檔案中「情感理性」與「社交貨幣」的論斷。
- `entities/media/political_talk_shows_overview.md` —— 關聯功能:提供電視政論作為權威資訊來源的脈絡,解釋長者政治認知的塑造過程。
- `entities/parties/kmt_voter_base.md` —— 關聯功能:對照KMT選民基礎與中老年世代的高度重疊,驗證恩庇網路與法統情感的雙重綁定。
- `events/timelines/democratization_timeline.md` —— 關聯功能:提供威權時期的歷史背景,解釋戒嚴經驗如何成為此群體「反共/避戰/服從權威」心理結構的根源。
---
## 一句話濃縮(重申)
中老年世代受眾的本質是威權烙印與高時間資本交織的高動員實體,其內部斷層與「避戰保產」的底層邏輯,使得LINE群組成為社交貨幣而非單純的資訊管道;分析者若將其視為同質的深藍鐵票或數位文盲,將徹底錯失2028年「準退休世代」鬆動與實體樁腳斷層所帶來的板塊重組訊號。
---
last_updated: 2025-05-19
@@ -1,304 +0,0 @@
# 青年世代受众(18-35 岁)
---
**类型**: narrative
**当前角色**: 受众分析 / 政治叙事载体
**出生时间**: 1989-2006 年(含太阳花世代核心与 Z 世代)
**何时调用此档案**: 当分析涉及台湾选举的年轻选票流向、议题设定对特定年龄层的动员效力、网路资讯战与认知作战的渗透路径、第三势力(特别是 TPP)的基本盘结构、居住与薪资等分配议题的政治化、以及 2028 年各阵营对首投族与青年族的弃保与催票策略时。
---
## 一句话浓缩
18-35 岁青年世代不是铁板一块的「天然独铁票」,而是受制于「主权防线(底线)+分配底线(刚需)+反建制审美(偏好)」三重约束的流动惩罚者;其内部存在从「幻灭的理想主义者」到「务实的虚无主义者」的世代断裂,在演算法驱动的信息茧房中,他们对建制权威极度排斥却对反建制叙事缺乏免疫力,分析者若将青年选票视为任何政党的固定资产,将彻底误判 2028 年高波动性的政治重组。
---
## 群体定义与边界:人口规模与识别标准
### 事实层:人口结构与世代划界
18-35 岁青年世代在台湾 2028 年选举时,涵盖出生于 1989 至 2006 年的群体,预估合格选民规模约 500 万人,占全体选民约 25%。此群体并非同质化整体,依据政治社会化关键期的重大事件差异,必须切割为两个次群体:
1. **太阳花世代核心(约 29-35 岁,1989-1995 年出生)**:其政治意识觉醒于 2008-2014 年的社会运动高峰期(野草莓、反媒体垄断、太阳花)。他们曾对体制内改革抱有期望,经历 2016 年 DPP 完全执政后的期待落空,当前多处于职场中阶或家庭初创期,直接承受高房价与低薪的结构性挤压,是典型的「幻灭的理想主义者」。
2. **Z 世代(约 18-28 岁,1996-2006 年出生)**:其政治意识形成于 2018 年后的网路碎片化与疫情时期,缺乏实体社会运动的集体记忆,政治资讯完全依赖社群媒体与短影音。他们从未信任过传统体制,对统独意识形态的执着低于太阳花世代,但对生存压力与阶级固化的焦虑更甚,是典型的「务实的虚无主义者」。
### 脉络层:识别标准的关键维度
识别青年世代的政治倾向,不能仅用传统的「蓝绿统独」光谱,必须引入三个交叉维度:
- **资产状态**:有无房产(或家庭奥援)是切割青年政治态度的最锐利刀锋。无产者对分配议题极度敏感且倾向惩罚执政党;有产或预期继承者,则可能因利益趋避向中间或保守移动。
- **教育职业属性**:高教白领与文组受薪者倾向关注转型正义、性别平权等后物质议题;理组科技业受薪者则呈现「经济务实+政治冷感/反建制」的倾向,对效能低下的代议民主极度不耐。
- **信息载体偏好**:资讯主要来源是长影音/文字(偏重逻辑与脉络)还是短影音/迷因(偏重情绪与标签),直接决定了其对认知战的脆弱度与政治审美的偏好。
### 分析含义
分析 2028 选情时,不可将「青年政策」视为单一配方。针对太阳花世代需提供「分配正义的实质兑现」以修复信任;针对 Z 世代则需提供「反建制的情绪共鸣」与「短影音的精准投放」。混淆两者的诉求,将导致政策无效与资源错置。
---
## 内部分层:18-25 学生族 vs 26-35 新进职场族
青年世代内部的异质性,最集中地体现在「校园学生」与「职场青年」的利益分歧与认知落差上。
### 事实层:利益基底的断裂
- **18-25 岁学生族**:多处于半依附状态,经济压力尚未完全实体化。其政治关注点受同侪与网路同温层高度影响,对主权、性别平权、环境等后物质议题的敏感度极高。投票行为呈现高波动性,易受单一事件(如香港反送中)或反建制载体(如柯文哲)的激情动员,但也极易因无感而拒绝投票。
- **26-35 岁新进职场族**:全面进入社会,直接面对薪资停滞、房价飙升与生育成本的三重暴击。其政治关注点急剧向物质分配倾斜,对 GDP 成长无感,对「居住正义」有切肤之痛。投票行为从激情转向愤世嫉俗,若非具有极强惩罚动机,投票率通常低于高动员期。
### 脉络层:从「认同政治」到「生存政治」的转场
这不仅是年龄的增长,更是政治逻辑的质变。18-25 岁的「天然独」是认同的预设值,带有浪漫主义色彩;26-35 岁的「天然独」则被生存现实包裹,变成防御性底线——他们依然排斥北京,但更痛恨无法让他们安居立命的执政党。交叉引用 `narratives/domestic/wage_stagnation_narrative.md` 与 `narratives/domestic/housing_crisis_discourse.md`,可见薪资与居住议题如何将 26-35 岁群体从 DPP 的认同基本盘中剥离。
### 分析含义
此分层揭示了 DPP 青年票流失的结构性机制:DPP 擅长用主权议题动员 18-25 岁学生族,但无法用主权议题喂饱 26-35 岁职场族。2024 年 DPP 在 30-39 岁年龄段的支持度崩塌,正是这批太阳花世代进入职场后「生存政治」压倒「认同政治」的必然结果。2028 年,这批人将处于 30-39 岁,若分配困境无解,他们将成为最坚定的反绿惩罚者。
---
## 信息行为:载体迁移与演算法喂养
青年世代的信息行为彻底脱离了传统大众媒体模型,进入演算法主导的碎片化场域。
### 事实层:从 PTT 到 Threads 的载体转场
- **文字时代的遗绪(PTT/Dcard)**:PTT 是太阳花世代的政治社会化场域,强调论辩(与酸民文化),仍保留一定的公共论坛性质;Dcard 则偏向生活经验与情感共鸣,政治讨论较软性。
- **影音时代的降临**:YouTube 政论频道与直播,取代了电视新闻,KOL 建立了与观众的拟似社会关系,成为信任替代品。
- **情绪时代的极化**:TikTok/Reels/Threads 成为 Z 世代的主要信息入口。政治论述被压缩为 15 秒的短影音或情绪性发文,复杂脉络消失,取而代之的是迷因、猎巫与嘲讽。
### 脉络层:演算法作为「同温层极化器」与「论述降维机」
**结构性命题**:演算法不是中性的信息传递工具,而是偏好极端情绪的放大器。在追求停留时间的逻辑下,愤怒、嘲讽与对立最易获得推荐。这导致青年世代的信息行为呈现两个特征:
1. **对建制信息的自动屏蔽**:政府官方声明、传统媒体报导在青年世代的资讯流中近乎隐形,或被预设为「官方洗脑」而遭排斥。
2. **对反建制情绪的成瘾性依赖**:任何以「揭发黑幕」、「蓝绿一样烂」、「体制迫害」为包装的论述,能轻易穿透同温层,获得病毒式传播。
### 分析含义
分析 2028 认知战效应时,必须认知到:北京或特定阵营对青年世代的认知操作,绝不能采用官方媒体的直接投放,而必须透过网路 KOL、迷因梗图与「反建制」的叙事包装。青年世代对北京官方叙事有极高韧性,但对「反建制外壳」包裹的疑美论或失败主义,则极度脆弱。交叉引用 `narratives/domestic/youth_political_alienation.md`,可进一步理解此信息行为如何放大疏离感。
---
## 政治倾向与投票模式:从 DPP 独占到 TPP 分流
### 事实层:历次选举的轨迹与数据
- **2014-2016(认同动员高峰)**:太阳花运动后,青年选票(当时 18-29 岁)高度倾绿,DPP 完全收割「抗中保台」与「分配正义」红利,蔡英文在 2016 年获得青年压倒性支持。
- **2018-2020(防御性归队)**:2018 年因同婚与公投争议,青年票一度松动;但 2020 年因香港反送中事件,主权显影度拉至最高,青年票基于生存恐惧再次归队 DPP,蔡英文获史上最高青年支持度。
- **2022-2024(惩罚性出走)**:DPP 执政八年房价飙涨、薪资停滞,主权危机常态化导致边际效应递减。2024 年柯文哲在 20-29 岁取得领先,30-39 岁与赖平分秋色,DPP 失去青年世代绝对优势。
### 脉络层:天然独的稳定性与政党脱钩
根据政大选研中心长期数据,18-35 岁的「台湾人认同」比例长期稳定在 85% 以上,且「偏向独立」或「永远维持现状」的比例远高于其他年龄段。这证明了「天然独」认同的稳定性——它不会随年龄增长而转向统一或认同 PRC。
**破除常见简化**:把「天然独」等同于「DPP 铁票」是致命的错误。天然独是底线约束,而非正面动机。青年世代在主权上绝不妥协(这封死了 KMT 的青年票),但在内政上绝不盲从(这打破了 DPP 的青年垄断)。当主权危机降温,分配剥夺感就会主导投票行为。交叉引用 `primitives/concepts/generational_political_identity.md`,可确认世代认同的防御性本质,解释了为何认同无法自动兑换为政党忠诚。
### 分析含义
2028 年,青年选票将呈现「主权防绿不亲蓝,分配惩绿不保白」的流动状态。DPP 无法仅靠「抗中」催出青年票,KMT 无法洗清「亲中」标签,TPP 则必须证明自己不只是抗议工具。青年选票不再是任何政党的资产,而是随时准备惩罚违约者的流动负债。
---
## 核心议题敏感点:居住、薪资与生育的刚性约束
青年世代对特定议题的敏感度,已从「政策偏好」升级为「生存刚需」,任何无法回应这些刚需的政党,都将遭到惩罚。
### 居住正义:阶级固化的物理象征
- **事实层**:DPP 执政八年(2016-2024),全台房价所得比从约 9 倍飙升至超过 15 倍,台北市更超过 16 倍。青年购屋痛苦指数达历史新高。
- **脉络层**:高房价对青年而言不是单纯的「买不起商品」,而是「阶级固化无法翻身」的物理证明。他们看到的是:资本利得远高于劳动所得,持有房产的上一代轻松增值,无产的青年世代终身为房东与建商打工。交叉引用 `narratives/domestic/housing_crisis_discourse.md`,居住议题已成为青年世代判定政党是否「背叛」的最核心指标。
- **分析含义**:任何政党若提出只增加贷款成数而不打压囤房的「假居住正义」政策,将引发青年更强烈的反噬。青年要的是房价下修与持有成本增加,而非更容易负债。
### 薪资停滞:劳动尊严的剥夺
- **事实层**:虽然名义经常性薪资缓步上升,但扣除物价后的实质经常性薪资成长停滞,甚至倒退。青年起薪长期在 3 万台币上下徘徊,与物价涨幅脱钩。
- **脉络层**:青年对 GDP 成长与股市万点完全无感,因为这些经济红利流向了资本家与科技新贵。交叉引用 `narratives/domestic/wage_stagnation_narrative.md`,薪资停滞构成了青年世代「相对剥夺感」的核心,他们认为自己被整体经济结构抛弃。
- **分析含义**:用总体经济数据向青年宣传政绩,不仅无效,甚至具有刺激性。青年关注的是「我的薪水能买什么」,而非「国家的 GDP 有多高」。
### 生育困境:绝望感的终极体现
- **事实层**:台湾总生育率于 2023 年跌至约 0.87,全球垫底。18-35 岁青年将「不生」视为理性选择。
- **脉络层**:少子化对青年而言不是「国安危机」,而是「个人生存策略」。高房价与低薪让青年连养活自己都困难,遑论养育下一代。政府的催生政策(如育儿津贴)被青年视为杯水车薪的施舍,无法解决结构性压迫。
- **分析含义**:生育议题是青年绝望感的终极体现。任何不触及居住与薪资结构的催生论述,都会被青年视为「何不食肉糜」的傲慢。
---
## 两岸议题态度:防御性主权与交流的矛盾
### 事实层:对北京与统独的立场
- **对 PRC 的认知**:18-35 岁世代将北京视为「敌对者」与「并吞者」,这是其政治认知的预设值。北京的对台军演与打压,持续强化此一认知。
- **对「维持现状」的定义**:青年世代高度支持「维持现状」,但其内涵与年长世代不同。年长世代的维持现状是「不统不独,保持中华民国体制」;青年世代的维持现状是「维持台湾事实上独立的现状,拒绝被 PRC 统治」。
- **对 ECFA 与两岸交流的态度**:呈现矛盾结构。在安全层面,他们高度警惕经济过度依赖北京(服贸协议的阴影);在利益层面,部分职场青年(特别是传产与内需型产业)又渴望两岸和平带来的经济红利。
### 脉络层:主权作为一票否决权
**结构性命题**:两岸议题对青年世代而言,是「一票否决权」而非「加分项」。DPP 无法仅靠抗中保台获得青年选票(因为抗中是理所当然的地板),但 KMT 只要触碰「一中原则」或「深化两岸融合」的红线,就会立刻失去所有青年选票(因为亲中是绝对不可接受的天花板)。交叉引用 `events/key_events/event_sunflower_2014.md`,太阳花运动已将「拒绝经济整合等同于主权防卫」的烙印深植于此群体。
### 分析含义
分析 2028 两岸议题对青年投票的影响时,必须区分「高显影度」(北京大规模军演)与「低显影度」(两岸关系平稳)情境。高显影度下,青年的主权防线被激活,DPP 受益;低显影度下,主权议题退位,分配议题主导,DPP 受到惩罚。KMT 在任何情境下都无法从青年世代的主权态度中获益。
---
## 国际关系态度:疑美但更惧中的被迫依赖
### 事实层:对美方与日方的立场
- **对美方**:青年世代对美国的态度呈现「疑美但更惧中」的矛盾结构。他们不满美国的强权逻辑、对台军的延迟交货与「疑美论」的渗透,但在安全上深知失去美国保护将导致台湾立刻陷落。他们不是亲美,而是「被迫依赖美」。
- **对日方**:青年世代对日本有高度文化亲近感(动漫、旅游、流行文化),但在渔业争议、核废水等议题上也会展现民族主义反弹。总体而言,对日信任度高于对美,但低于对自身主体性的坚持。
### 脉络层:被遮蔽的全球视野与防御性民族主义
台湾青年在国际议题上的态度,与西方左翼青年的世界主义截然不同。他们不支持巴勒斯坦的声援运动,对国际难民缺乏同情,对环保议题的支持也往往让位于经济生存。他们的民族主义是防御性的,仅在面对北京威胁时被激活;在无直接威胁的国际议题上,他们呈现务实的利己主义。
### 分析含义
分析者不可用西方「进步青年」的框架套用台湾青年。台湾青年在以巴冲突中可能倾向支持以色列(因为投射了小国面对威胁的自卫逻辑),这与西方校园的挺巴浪潮完全相反。错用国际框架,将严重误判台湾青年对国际事件的反应。
---
## TPP 的高支持率与原因:反建制容器的爆发
### 事实层:2024 年的板块移动
2024 年柯文哲拿下约 369 万票,其中 20-29 岁领先,30-39 岁与赖清德平分。TPP 成为青年选票的最主要载体。
### 脉络层:惩罚性投票与反建制审美
TPP 的高支持率,并非源于青年对 TPP 政策纲领的认同(事实上 TPP 在统独上刻意模糊,在政策上常自相矛盾),而是源于以下结构:
1. **DPP 的分配违约**:青年要惩罚 DPP 的居住与薪资失败。
2. **KMT 的主权违约**:青年拒绝 KMT 的亲中路线。
3. **反建制审美**:柯文哲的「直白、不按牌理出牌、打破蓝绿垄断」,完美契合青年对传统政治人物「虚伪、算计、打高空」的厌恶。
交叉引用 `entities/parties/tpp_voter_base.md`,TPP 的青年支持者是一群「主权偏绿、分配偏左、审美反建制」的负面联盟。他们不是浅蓝,而是「对绿失望、对蓝绝望」的流动选民。
### 分析含义
TPP 的青年票是高度脆弱的。若 2028 年前,TPP 在立法院的表现被证明是「蓝白分赃」,或柯文哲因素消退后无人能承载反建制情绪,这批青年票将迅速溃散,转向消极不投票,而非回流 DPP 或流向 KMT。交叉引用 `narratives/domestic/generational_divide_voting.md`,可确认世代投票差异已从认同驱动转为惩罚机制。
---
## 认知脆弱点与认知韧性
### 认知脆弱点:反建制外壳下的易感性
1. **对建制信息的逆反心理**:政府或传统媒体发布的澄清与事实查核,在青年同温层中常被视为「护航」或「洗白」,产生逆反效应。
2. **对阴谋论的低免疫力**:当阴谋论以「揭发权贵黑幕」、「对抗体制迫害」的叙事呈现时,青年极易买帐。例如司法案件被解读为政治迫害,即能引发强烈共鸣。
3. **短影音的降维打击**:复杂政策无法在 15 秒内解释,但仇恨言论与简化标签可以。青年在短影音喂养下,对议题的判断趋向二元对立与情绪反射。
### 认知韧性:主权防线的过滤机制
1. **对北京叙事的嗅觉敏锐**:尽管对内政极度不满,但青年世代对 PRC 的官方叙事与大外宣具有极高的辨识力与排斥力。任何被察觉带有「为北京洗白」或「统一红利」色彩的论述,都会立刻触发防御机制。
2. **数位素养的工具性运用**:青年擅长使用网路工具查证资讯、反查图片与溯源,这种能力虽常用于猎巫,但也构成了对低阶假讯息的防线。
### 分析含义
青年世代的认知结构呈现「内弱外强」的矛盾:对内部民主体制的信任崩塌导致脆弱,对外部并吞威胁的警觉构成韧性。2028 年的认知战,最危险的并非北京直接宣传,而是本土势力利用青年对建制的不满,将疑美论或失败主义包装在反建制的迷因中渗透。
---
## 近年变化趋势:5-10 年内的结构性演变
### 趋势一:从「含泪投票」到「惩罚性投票」的常态化
2012-2020,青年选票在关键时刻(如 2012 蔡英文败选、2020 香港反送中)仍会「含泪归队」DPP。2024 证明,这股力量已解构。青年不再愿意为了阻挡 KMT 而无条件支持 DPP,他们宁愿投给可能不会当选的 TPP,也要惩罚 DPP。这种惩罚机制将成为 2028 及未来的常态。
### 趋势二:后物质议题的退潮与物质分配的硬约束
性别平权、婚姻平权、转型正义等后物质议题,在 2018 年前后曾是青年政治的核心。但随着这批青年进入职场,面对高房价与低薪的硬约束,后物质议题的优先级大幅下降。生存问题压倒了价值问题。DPP 若仍以为可以用「民主价值」或「进步价值」来掩盖分配失败,将严重脱离青年现实。
### 趋势三:信息载体的碎片化与政治审美的极化
从 PTT 到 Threads,信息载体越来越碎片化,政治论述越来越情绪化。这导致青年对政治人物的偏好,从「政策能力」转向「情绪提供」与「迷因制造力」。能提供最多情绪价值与迷因素材的政治人物(如柯文哲),将占据流量优势;稳重但无梗的传统政治人物,将难以触及青年。
### 分析含义
这三个趋势共同指向一个结论:2028 年的青年选票,将比以往任何时候都更难预测、更易波动、更难被传统组织战动员。政党必须将资源从传统的实体造势转向网路情绪经营,并将政策论述从宏观愿景转向微观的生存痛点。
---
## 关联分析
### 关联一:政治疏离论述的底层逻辑
- **关联文件**: `narratives/domestic/youth_political_alienation.md`
- **功能**: 提供脉络层。解释青年受众为何从「激情参与」转向「消极惩罚」。该档案中的「双重违约」与「反建制容器」概念,是理解本档案中青年投票模式与 TPP 支持率的核心框架。
### 关联二:世代投票差异的动态模型
- **关联文件**: `narratives/domestic/generational_divide_voting.md`
- **功能**: 提供分析框架与交叉验证。该档案提出的「主权-分配-建制」三维博弈模型,精准解释了本档案中青年受众在两岸与内政议题上的矛盾态度,以及「主权防线一票否决」的机制。
### 关联三:世代政治认同的防御性本质
- **关联文件**: `primitives/concepts/generational_political_identity.md`
- **功能**: 提供因果验证。解释为何青年受众的「天然独」不会随年龄增长而转向,以及为何这种认同无法自动转化为对 DPP 的忠诚。验证了本档案中「天然独=底线而非铁票」的判断。
### 关联四:TPP 选民基础的世代特异性
- **关联文件**: `entities/parties/tpp_voter_base.md`
- **功能**: 提供事实层与交叉验证。验证 TPP 的青年支持者是「主权偏绿、分配偏左、审美反建制」的负面联盟,而非浅蓝。这解释了本档案中青年为何流向 TPP 而非 KMT。
### 关联五:太阳花运动的历史烙印
- **关联文件**: `events/key_events/event_sunflower_2014.md`
- **功能**: 提供脉络层。解释 26-35 岁群体的政治社会化起源,以及「拒绝两岸经贸深度整合」的集体记忆如何成为此群体的认知韧性,阻挡北京的经济统战叙事。
### 关联六:居住与薪资议题的刚性约束
- **关联文件**: `narratives/domestic/housing_crisis_discourse.md` 与 `narratives/domestic/wage_stagnation_narrative.md`
- **功能**: 提供事实层。量化青年受众的相对剥夺感,验证「分配底线」为何成为 DPP 青年票流失的致命伤,以及为何总体经济数据无法转化为青年对执政党的支持。
---
## 常见的分析陷阱
### 陷阱一:「老了就会变蓝」的线性外推
这是最危险的陷阱。将早期威权世代随年龄增长而变保守的经验,套用于太阳花世代。忽略了两者社会化的基础预设完全不同——前者变保守是回到其青年期的预设(大中国/威权稳定),后者变保守只会是强化其青年期的预设(台湾主体/避战保台)。青年老后,只会变成「避战的台湾人」,绝不会变成「亲中的中国人」。
### 陷阱二:将「天然独」等同于「DPP 铁票」
认为青年台湾认同高,就必然是 DPP 的铁票。忽略了认同仅是地板,分配与执政表现才是天花板。2024 选举已证明,当主权危机不显眼时,经济剥夺感足以让青年惩罚执政党,将票投给 TPP 或拒绝投票。
### 陷阱三:把 TPP 的青年票等同于「浅蓝票」
KMT 内部常误以为 TPP 的青年票是「可以争取的泛蓝票」。事实上,TPP 的青年支持者在主权立场上与 DPP 更近,与 KMT 更远。他们投给 TPP 是因为反建制,而非认同 KMT 的两岸路线。KMT 若试图吸纳这些选票,必须先洗清亲中标签,这在目前的 KMT 结构中是不可能的。
### 陷阱四:用总体经济数据说服青年
假设 GDP 成长、股市万点能让青年对执政党产生好感。实际上,这些数据反而加剧青年的相对剥夺感,证明经济红利全被资本家拿走。用总体经济数据向青年宣传政绩,不仅无效,反而具有刺激性。
### 陷阱五:忽略青年内部的阶级分化
将青年视为同质的无产者。实际上,部分青年(特别是理组科技业新贵)已进入高薪阶层,或预期继承家族房产。这群人的政治态度可能因利益趋避而向保守移动,与无产青年的分配诉求产生对立。忽略此阶级分化,将误判青年对加税与重分配政策的支持度。
### 陷阱六:高估青年对后物质议题的执着
以为青年仍如 2014 年般高度关注转型正义、环保与性别平权。实际上,面对生存压力,多数青年的关注点已急剧向物质分配倾斜。当环保(如三接藻礁)与供电(影响科技业就业)冲突时,青年可能选择务实供电;当性别平权与反建制的网路乡民文化冲突时,部分青年男性可能选择后者。
---
## 仍不确定的部分
1. **AI 与认知战对青年投票行为的干扰**:2028 前,AI 生成的假讯息与深伪技术,是否能精准利用青年的疏离感,植入「体制无望论」或「疑美论」?青年虽对北京叙事有韧性,但在同温层极化的演算法下,对「反建制包装的认知战」是否仍具辨识力,目前缺乏实证数据。
2. **后柯文哲时代的载体转移**:若柯文哲因司法案件无法参选,TPP 的青年支持者会流向何方?是转向黄国昌等激进路线,是回流 DPP 的务实派,还是彻底退出投票?此载体转移的路径依赖于未来数年的突发事件,目前无法推演。
3. **战争恐惧的临界点**:目前青年对北京的反感是稳定的,但若台海发生极高强度的封锁或有限冲突,且美国未如预期介入,青年是否会发生从「抗中」到「避战求和」的断裂性认同转移?此临界点无法从和平时期的调查中推知。
4. **新住民二代的投票归属**:新住民子女已占青年世代相当比例。他们是否会在「台湾人认同」的主流框架下,发展出更具流动性的双文化认同,从而在特定议题(如对外劳政策、对东南亚新南向)上与原生台湾人产生分歧,尚待长期观察。
5. **短影音与演算法对政治审美的重塑**:青年对政治人物的偏好,越来越受到短影音平台的演算法与迷因文化影响。这种审美偏好是否会在 2028 年催生出全新的政治人物类型,从而打破现有的政党提名逻辑,仍属未知领域。
---
## 与其他文件的关系
- `narratives/domestic/youth_political_alienation.md` —— 关联功能:提供青年疏离的机制分析,解释为何青年从认同动员转向惩罚性投票。
- `narratives/domestic/generational_divide_voting.md` —— 关联功能:提供世代投票差异的宏观框架,验证「主权-分配-建制」三维模型在青年受众上的适用性。
- `primitives/concepts/generational_political_identity.md` —— 关联功能:提供世代政治社会化理论,支撑「天然独不可逆但可脱钩」的论断。
- `entities/parties/tpp_voter_base.md` —— 关联功能:验证 TPP 如何吸纳青年的分配焦虑与反建制情绪,成为青年票的主要载体。
- `events/key_events/event_sunflower_2014.md` —— 关联功能:解释 26-35 岁群体的政治社会化烙印,以及反中与分配意识的起源。
- `narratives/domestic/housing_crisis_discourse.md` —— 关联功能:量化居住议题对青年的刚性约束,解释分配底线为何成为 DPP 的致命伤。
- `narratives/domestic/wage_stagnation_narrative.md` —— 关联功能:量化薪资停滞对青年的相对剥夺感,解释总体经济数据与青年投票意向的脱钩。
---
## 一句话浓缩
18-35 岁青年受众是受「主权防线+分配底线+反建制审美」三重约束的流动惩罚者,其内部的世代断裂与演算法喂养的信息行为,决定了他们不再是任何政党的固定资产;分析者若将青年的天然独等同于铁票,或用总体经济数据掩盖分配剥夺,将彻底错读 2028 年高波动性的政治重组。
---
last_updated: 2025-05-19
@@ -1,15 +0,0 @@
# audiences/political_identity/ 政治认同受众
按蓝绿白、中间选民和政治冷感等政治认同光谱划分的受众档案,用于分析叙事接受度、动员路径和选举转化空间。
## 文件清单
- `deep_blue.md` — 深蓝受众
- `light_blue.md` — 浅蓝受众
- `deep_green.md` — 深绿受众
- `light_green.md` — 浅绿受众
- `white_camp_audience.md` — 白营受众
- `swing_voters.md` — 摆荡选民
- `politically_apathetic.md` — 政治冷感族群
- `political_identity_overview.md` — 政治认同光谱总览
@@ -1,334 +0,0 @@
# 深藍受眾
---
**類型**: 政治敘事(受眾政治認同特化檔案)
**當前角色**: 台灣深藍選民的認同結構、資訊行為、敘事消費模式與預測基礎
**何時調用**: 分析疑美論、棄台論、北京交易論等敘事的傳播機制與落地效果;預測 KMT 內部路線鬥爭中深藍的施壓方向與逼宮效應;評估兩岸突發事件(如軍事衝突、北京發布促統文件)對深藍群體的認同衝擊與投票行為反轉;解讀特定媒體(中天、中時)與政治人物(趙少康、洪秀柱)的論述市場基盤;規劃針對深藍受眾的認知滲透或敘事防禦策略
---
## 一句話濃縮
> 深藍受眾是「中華民國國族主義」的歷史囚徒,其對台獨的恐懼與對美國的歷史怨懟,使其在結構上與北京的「反獨」敘事形成負面共生,但這種共生建立在對「中華民國」法統的執念之上——一旦北京觸碰此底線,深藍的親中傾向會瞬間轉化為被背叛的激烈反抗。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「深藍受眾」等同於「親共受眾」或「統一支持者」——預設他們無條件支持北京、毫無保留地接受中共統治、對民主價值完全漠視。這種簡化不僅背離事實,更會導致對其政治行為的嚴重誤判。
深藍受眾的內在核心不是「親共」,而是「中華民國法統的絕對捍衛」與「對台獨的零容忍」。這兩個核心特徵構成了深藍受眾的認知雙螺旋:
1. **歷史正統性焦慮**:深藍的國族認同鑲嵌於 1949 年的遷台歷史與冷戰結構中。他們堅信中華民國才是中國的唯一正統,北京政權是叛亂團體。這種認同使得他們在情感上無法接受台獨(因為台獨等同於承認中華民國是偽政權或僅限於台灣的地方政府),但也使得他們在面對北京打壓中華民國國際空間時,同樣感到切膚之痛。
2. **地緣生存的實用主義**:深藍多為外省一二代,經歷過戰爭流離,對「戰爭」與「亡國」有深層恐懼。他們傾向與北京對話甚至妥協,不是出於對共產主義的認同,而是基於「避免戰爭」與「保存中華民國命脈」的實用計算。
理解深藍受眾,就是理解台灣政治中「反台獨」力量的底層邏輯,以及為何疑美論、棄台論能在此群體中獲得極高的穿透力——因為這些論述精準地接合了他們的歷史創傷與生存焦慮。
---
## 群體定義與邊界:人口規模、識別標準與關鍵區分
### 事實層
深藍受眾約佔台灣選民的 8-12%(約 150 萬至 190 萬人)。其識別標準不是單一的政黨支持,而是基於三個重疊維度:
1. **國族認同**:堅定認同「我是台灣人,也是中國人」,且此處的「中國」指涉的是文化與歷史意義上的中國,或法理上的中華民國,而非當前的中華人民共和國。
2. **憲政立場**:反對台灣獨立,反對制憲正名,堅持中華民國憲法與兩岸人民關係條例的「一中原則」詮釋(即一中各表的「各表」權利)。
3. **歷史記憶**:對抗戰、遷台、冷戰反共歷史有強烈的情感連結,對民進黨的「去中國化」與「轉型正義」持高度排斥態度。
**關鍵區分**:深藍必須與 KMT 的「本省地方派系」及「經濟選民」嚴格切割。地方派系支持 KMT 是基於利益分配,經濟選民支持 KMT 是基於兩岸紅利與避戰;深藍支持 KMT 則是基於「理念保衛戰」。當 KMT 候選人無法滿足其理念需求時,地方派系會轉向或不投票,經濟選民會避險,而深藍則會「含淚不投票」或「轉投極端小黨」以進行懲罰。
| 維度 | 深藍受眾 | KMT 本省地方派系 | KMT 經濟選民 |
|---|---|---|---|
| 核心驅動力 | 中華民國法統認同 | 地方資源與利益分配 | 兩岸和平紅利與避戰 |
| 對台獨態度 | 零容忍,視為亡國 | 不偏好,但可妥協 | 反對,主要因引發戰爭風險 |
| 對北京態度 | 歷史親近但堅持正統 | 交易性依賴 | 實用主義合作 |
| 投票移動性 | 低(但具懲罰性) | 高(利益導向) | 中(避險導向) |
### 脈絡層
**破除常見簡化**:把深藍受眾視為「鐵板一塊的親共統派」是嚴重的分析錯誤。深藍內部存在「正統藍」與「紅統藍」的微妙光譜。
「正統藍」佔深藍的多數,他們堅持「三民主義統一中國」或「一中各表」,對北京政權仍有本質上的不信任甚至敵意,只是認為「兩岸同屬一中,未來可以統一」,且統一的前提是北京必須民主化。「紅統藍」則是少數但聲量大的極端,他們已放棄對中華民國法統的堅持,實質接受北京的「一國兩制」框架,甚至將北京視為「祖國」。這兩者在「反台獨」上高度一致,但在「中華民國」的存廢上存在根本矛盾。
### 分析含義
分析時如何用這個事實?
- **預測深藍的分裂臨界點**:當北京的政策僅止於「反獨」時,深藍內部團結;但當北京的政策推進到「消滅中華民國」(如要求在國際場合不得使用 ROC 名義、強推一國兩制台灣方案)時,「正統藍」會產生強烈的反彈,與「紅統藍」決裂。
- **評估 KMT 候選人的深藍動員力**:KMT 候選人若僅訴諸「避戰」與「經濟」,只能動員經濟選民;若要深藍歸隊,必須在論述上觸及「中華民國尊嚴」與「反台獨」的防線。
- 交叉引用:此群體與 `entities/parties/kmt_voter_base.md` 中的「理念深藍」高度重疊,但本檔案更側重其作為敘事受眾的認知特徵。
---
## 外省背景與眷村記憶對認同的塑造
### 事實層
深藍受眾的核心人口基礎是外省二代與三代。第一代外省族群經歷了戰亂流離與撤退來台,其「離散創傷」透過家庭社會化與眷村空間,深刻傳遞給二三代。
眷村作為一個封閉的物理與社會空間,提供了深藍認同的溫床:
1. **同質性網絡**:眷村居民多為軍公教,共享相同的政治信仰(反共抗俄、效忠領袖)、生活習慣與歷史記憶,形成高度的群體內聚力。
2. **大中國想像的保存**:眷村中的「家」是中國大陸的某個省份,而非台灣的某個鄉鎮。「回老家」的執念使得他們對「中國」的認同是具象的、血肉相連的,而非抽象的政治概念。
3. **相對剝奪感與防禦機制**:在早期本省族群佔多數的社會中,外省族群雖掌握政治權力,但在日常生活中常面臨語言與文化的隔閡。這種「少數統治者」的焦慮,使得他們更加依賴國民黨機器與中華民國體制作為保護傘。
### 脈絡層
**中等模型自己想不到的洞察**:眷村記憶的關鍵不在於「懷鄉」,而在於「失根的恐懼」。
深藍受眾對台獨的強烈排斥,不僅僅是意識形態的對立,更是生存層面的恐慌。對他們而言,中華民國體制是外省族群在台灣存在的唯一合法化基礎。如果台灣獨立,意味著中華民國滅亡,這不僅否定了他們父祖輩的犧牲,更在法理上將他們從「國家主人」降格為「外來移民」甚至「亡國餘孽」。因此,他們對台獨的抵抗,本質上是一種「身分保衛戰」。
然而,隨著眷村改建與外省三代的都市化散居,這種物理空間的同質性已經瓦解。深藍認同的維持,從「物理空間的群聚」轉移到了「虛擬空間的群聚」(如特定的 YouTube 頻道、LINE 群組、臉書社團)。
### 分析含義
分析時如何用這個事實?
- **解讀深藍對「轉型正義」的激烈反應**:DPP 推動的轉型正義(如促轉條例、不當黨產處理),在深藍眼中不是「民主深化」,而是「清算外省族群」與「消滅中華民國法統」。這種解讀直接觸發了他們的失根恐懼,使得他們對 DPP 的敵意幾乎無法調和。
- **預測深藍敘事的載體轉移**:實體眷村消失後,網路社群成為深藍認同的再造工廠。分析者應將焦點從傳統的里鄰動員,轉向網路同溫層的敘事傳播機制。
- 交叉引用:此群體的媒體消費行為與 `entities/media/cti_news_youtube.md` 高度相關,中天新聞的 YouTube 頻道實質上成為了「數位眷村」。
---
## 資訊行為:主要接觸渠道、平台偏好與信任來源
### 事實層
深藍受眾的資訊行為呈現「高黏著、低跨界、強信任」的特徵。
1. **傳統媒體時期(2010 年代前)**:主要依賴聯合報、中國時報等平面媒體,以及 TVBS、中天等有線電視新聞台。這些媒體提供了符合其國族認同的詮釋框架。
2. **數位轉型時期(2010 年代後)**:隨著傳統媒體影響力衰退與中天新聞台換照失敗(2020 年),深藍受眾大規模遷移至 YouTube 與 LINE 群組。中天新聞 YouTube 頻道成為其核心資訊樞紐,單支影片動輒數十萬觀看次數,留言區呈現極高的同質性與情感共鳴。
3. **當前生態**:形成以中天 YT、TVBS 新聞網、中時新聞網為主體,配合趙少康《少康戰情室》、王鴻薇、羅智強等深藍政治人物臉書專頁,以及大量 LINE 封閉群組的「三位一體」資訊繭房。
### 脈絡層
**結構性命題**:深藍受眾的資訊行為不是「被動接收」,而是「主動尋求認同再確認」。
他們選擇中天與中時,不是因為這些媒體的調查報導多麼深入,而是因為這些媒體提供了他們在主流社會(尤其是年輕世代與綠營網軍)中無法獲得的「尊嚴感」與「正當性」。在這些頻道中,「中華民國」是被頌揚的,「民進黨」是被譴責的,「兩岸統一」是未來的希望。這種資訊環境不僅提供新聞,更提供情感撫慰。
更關鍵的是,中天換照失敗事件,在深藍受眾的認知中並非「獨立機關的行政處分」,而是「綠營關閉異見媒體的威權復辟」。此事件極大地強化了深藍的「被迫害意識」,並使其對 YouTube 演算法推薦的同溫層內容產生了更深的依賴,因為他們認為主流平台(如公視、三立)都是「綠營打手」。
### 分析含義
分析時如何用這個事實?
- **評估敘事傳播的速度與廣度**:針對深藍的敘事(如疑美論),一旦進入中天 YT 與 LINE 群組的傳播網絡,其擴散速度極快,且幾乎不會受到外部事實查核的阻礙,因為受眾對外部查核機構(如 Cofacts、MyGoPen)同樣缺乏信任。
- **預測「紅媒論述」的防禦機制**:當深藍媒體被指控為「紅媒」時(交叉引用:`narratives/cross_strait/red_media_discourse.md`),深藍受眾會自動啟動防禦機制,將此指控解讀為「綠營打壓言論自由」與「扣紅帽子」,反而強化了對該媒體的忠誠。
- 交叉引用:媒體生態的具體運作,詳見 `entities/media/china_times.md` 與 `entities/media/cti_news_youtube.md`。
---
## 政治傾向與投票模式:忠誠、懲罰與人物投射
### 事實層
深藍受眾在投票行為上呈現「高投票率、高移動阻力、高懲罰性」的特徵。
1. **對代表人物的忠誠**:深藍對特定政治人物有強烈的情感投射,這種投射超越了對政黨機器的忠誠。馬英九曾是他們的共主(代表法統與親美和中的平衡),洪秀柱是他們的理念純度象徵(代表深藍正統與敢言),趙少康則是他們的媒體戰神與代言人(代表戰鬥藍與輿論反攻)。
2. **懲罰性不投票或轉投**:當 KMT 候選人背離深藍路線時,深藍不會轉投 DPP,但會選擇不投票或轉投新黨、親民黨等小黨。2016 年朱立倫取代洪秀柱參選總統,引發深藍強烈反彈,導致投票率低迷;2024 年侯友宜的本土形象與對九二共識的閃躲,也讓深藍選民熱情缺缺,直到趙少康擔任副手後才勉強歸隊。
3. **韓國瑜現象的深藍解讀**:2018-2020 的韓國瑜現象,深藍是核心推手。但他們支持韓國瑜,不僅是因為「發大財」的經濟承諾,更是因為韓國瑜的「庶民語言」與「戰鬥姿態」精準回應了深藍長期被主流社會鄙視的怨氣。韓國瑜是他們對抗「菁英綠」與「權貴藍」的民粹武器。
### 脈絡層
**破除常見簡化**:把深藍對 KMT 的支持視為「無條件鐵票」是錯誤的。深藍與 KMT 的關係是「契約式」的——KMT 必須守護中華民國法統與反台獨底線,深藍才會提供選票。
當 KMT 為了選舉勝利而向中間靠攏(如淡化九二共識、擁抱中華民國台灣化),深藍會感到被背叛。這種背叛感不僅是政治策略的分歧,更是情感上的拋棄。深藍認為自己為 KMT 打下了江山,KMT 卻為了選票而拋棄了他們的信仰。這也是為何 KMT 內部的路線鬥爭(交叉引用:`entities/parties/kmt_two_lines_struggle.md`)從未停歇——深藍永遠在逼宮,要求 KMT 回歸「正統」。
### 分析含義
分析時如何用這個事實?
- **預測 KMT 初期的選戰節奏**:KMT 總統候選人在初選階段,必然要向深藍靠攏以獲得提名(如提出深藍接受的兩岸論述、爭取趙少康或韓國瑜的支持);但在大選階段,若過度依賴深藍論述,將會失去中間選民。這種「初選向右看,大選向中間靠」的搖擺,是 KMT 每次大選的必經折磨,也是深藍不滿的根源。
- **評估第三勢力的吸納空間**:當 KMT 候選人無法滿足深藍時,新黨或無黨籍深藍人士(如 2024 的部分郭台銘支持者)可能瓜分選票。但深藍的「棄保效應」極強,在關鍵時刻為了防止 DPP 當選,多數深藍仍會含淚投票給 KMT。
---
## 核心議題敏感點與認知脆弱點
### 事實層
深藍受眾最敏感的核心議題有三:
1. **中華民國的存廢與尊嚴**:任何「消滅中華民國」的舉動(無論來自北京或綠營)都會觸發強烈反應。
2. **兩岸戰爭風險**:基於歷史創傷,深藍極度恐戰,傾向將戰爭風險歸咎於 DPP 的挑釁,而非北京的擴張。
3. **美國對台的操控與背叛**:深藍普遍不相信美國會為台灣犧牲,認為美國只是利用台灣作為遏制中國的棋子。
基於這些敏感點,深藍受眾存在明顯的認知脆弱點,極易接受以下敘事:
- **疑美論**(交叉引用:`narratives/cross_strait/skepticism_of_us.md`):美國不可信、美國會棄台、美國賣萊豬給台灣是剝削、美國軍售是保護費。
- **棄台論**(交叉引用:`narratives/us_taiwan/us_will_abandon_taiwan.md`):一旦台海發生戰爭,美國絕不會出兵,台灣將成為第二個烏克蘭。
- **北京交易論**:兩岸問題可以透過政治談判解決,北京是可以打交道的對象,只要台灣不挑釁(不搞台獨),北京就不會動武。
- **DPP 促戰論**:民進黨為了選票與權力,故意挑起兩岸對立,將台灣推向戰爭邊緣。
### 脈絡層
**中等模型自己想不到的洞察**:深藍對疑美論與棄台論的高接受度,不是基於客觀的實力評估,而是基於「歷史創傷的投射」與「責任的轉移」。
1979 年美國與中華民國斷交,是深藍集體記憶中最深的創傷之一。他們經歷過「被盟友拋棄」的痛,因此對「美國會再次拋棄台灣」的敘事有本能的共鳴。這種創傷使得他們傾向相信:依賴美國是危險的,只有與北京達成協議才能確保生存。
同時,接受「北京交易論」與「DPP 促戰論」,也是一種心理防禦機制。如果戰爭風險是 DPP 挑釁造成的,且北京是可以溝通的,那麼深藍過去主張的「兩岸交流與對話」就是正確的,他們就不必為當前的兩岸僵局承擔責任。反之,如果北京是不可理喻的侵略者,那麼深藍的親中傾向就會顯得荒謬且危險——這是他們無法接受的結論。
### 分析含義
分析時如何用這個事實?
- **預測外部事件對深藍的影響**:當美國對台軍售延遲或出現爭議(如萊豬、軍購溢價)時,疑美論在深藍群體中的傳播速度將呈指數級增長;當北京發動軍演時,深藍的第一反應不是譴責北京,而是指責 DPP 挑釁。
- **設計反制敘事的無效區**:試圖用「美國是堅實盟友」或「北京是威脅」來說服深藍是無效的。因為這觸碰了他們的核心創傷與防禦機制。有效的反制必須從「中華民國的生存利益」出發,論證為何特定行為(如加強國防)是保衛中華民國的必要之惡,而非為美國服務。
---
## 認知韌性:抵抗何種敘事
### 事實層
深藍受眾並非對所有敘事都毫無抵抗力。他們在以下方面展現出強烈的認知韌性:
1. **對台獨論述的免疫**:無論台獨論述如何包裝(如「中華民國台灣化」、「事實獨立」),深藍都能迅速識別並予以排斥。他們對憲法體制與歷史法統的熟悉,使得他們能從法理層面反駁台獨論述。
2. **對「一國兩制」的抗拒**:雖然深藍反對台獨,但他們同樣抗拒北京的「一國兩制」方案。因為一國兩制意味著中華民國被降格為地方政府,這觸碰了他們「法統正確性」的底線。
3. **對綠營媒體的全面不信任**:對於三立、民視、自由時報等媒體的報導,深藍受眾會自動啟動「反向解讀」機制,將其視為政治操作而非事實。
### 脈絡層
深藍的認知韌性來源於其「封閉的詮釋體系」。他們擁有一套自洽的歷史觀與法理觀,任何外部資訊都必須通過這套體系的過濾。這使得他們極難被綠營敘事說服,但也使得他們在面對北京壓力時,不會輕易放棄對中華民國的堅持。
這種韌性在 2019 年香港反送中事件期間得到了驗證。當時多數台灣民眾對香港抗爭者表示同情,但深藍媒體與受眾傾向將其解讀為「外力介入的暴亂」或「分離主義的惡果」,從而避免了「反送中效應」對其親中立場的衝擊。他們用「反對暴力」與「反對分裂」的框架,成功消化了可能動搖其認知的事件。
### 分析含義
分析時如何用這個事實?
- **識別北京對台統戰的極限**:北京若以「一國兩制」或「消滅中華民國」為前提推動統一,將在深藍群體中遭遇比在淺藍或中間選民中更激烈的反彈。因為深藍對中華民國的執念是信仰層級的,背叛信仰的憤怒遠大於利益受損的不滿。
- **評估認知作戰的穿透邊界**:來自綠營或美方的認知作戰,對深藍幾乎無效;但來自北京或紅媒的認知作戰,若能巧妙地包裹在「反台獨」與「護中華民國」的外衣下,則具有極高的穿透力。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 事實層
深藍受眾在過去 5-10 年經歷了三個主要的結構性演變:
1. **物理性凋零**:外省第一代已大規模凋零,第二代也逐漸步入老年。深藍的自然減員是不可逆的數學事實,這導致深藍選票的絕對數量持續下降。
2. **極端化與數位化**:中天換照事件與社群媒體的演算法,使得深藍的資訊環境更加封閉與極端。為了在網路上獲得關注,深藍意見領袖的言論趨向聳動與激進,這使得深藍的公共形象與主流社會更加脫節。
3. **從「反共」到「反獨」的質變**:傳統深藍是「反共復國」,但隨著反共復國的現實可能性歸零,新一代深藍的訴求已質變為「反台獨」。在「敵人的敵人是朋友」的邏輯下,對北京的敵意顯著下降,甚至產生「北京反台獨,所以北京是盟友」的錯覺。
### 脈絡層
**結構性命題**:深藍的極端化,是其在台灣社會邊緣化過程中的防禦性反應。
當深藍的國族認同在台灣社會逐漸失去市場,且其支持的政黨 KMT 屢戰屢敗時,深藍受眾面臨嚴重的認同危機。為了維持認同的完整性,他們必須尋找更極端的論述來證明自己的正確性。這就是為何疑美論、棄台論在近年的深藍圈中大行其道——這些論述提供了一種「你們都錯了,只有我們看透真相」的心理補償。
同時,深藍與北京的關係也發生了微妙的逆轉。過去,深藍是帶著優越感(三民主義優越性)與北京對話;現在,深藍是在弱勢與焦慮中尋求北京的善意。這種心態的轉變,使得深藍更容易被北京的統戰論述吸納。
### 分析含義
分析時如何用這個事實?
- **預測深藍對 KMT 的持續施壓**:深藍的極端化意味著他們對 KMT 的妥協空間越來越小。KMT 若試圖向中間靠攏,將面臨深藍更激烈的反撲。這使得 KMT 在 2028 年很難推出能同時滿足深藍與中間選民的候選人。
- **評估北京對深藍的吸納效應**:北京未來可能更直接地繞過 KMT 機器,透過媒體與社群(如小紅書、抖音)直接對深藍受眾進行認知滲透,將深藍從「KMT 的鐵票」轉化為「北京的在地協力者」。
---
## 兩岸與國際議題態度:統獨、北京、美方與交流
### 事實層
深藍受眾在兩岸與國際議題上的態度呈現高度的一致性:
1. **對統獨的立場**:反對法理台獨,支持終極統一(但前提是民主均富),多數實質傾向「永遠維持現狀」(即中華民國體制下的現狀)。
2. **對北京的態度**:承認北京是中國大陸的統治當局,支持兩岸對話與交流,反對北京對台動武,但傾向將動武原因歸咎於台獨挑釁。對 ECFA 與兩岸經貿依賴持正面態度,認為這是避免戰爭的黏著劑。
3. **對美方的態度**:高度懷疑美國的承諾,反對「倚美謀獨」,認為過度依賴美國會將台灣推向代理人戰爭的火坑。對美國軍售持矛盾態度(既認為需要防衛,又認為是被美國勒索)。
4. **對日方的態度**:深藍的抗日歷史記憶使其對日本抱持深厚的不信任,釣魚台議題與慰安婦議題是深藍反日的情感錨點。
### 脈絡層
深藍的國際觀是「以中國為中心的大歷史觀」與「冷戰遺留的棄兒意識」的混合體。
他們相信兩岸問題是中國內戰的延續,外部勢力(尤其是美日)的介入是為了自身的地緣利益,而非台灣的福祉。因此,深藍傾向將台灣的出路寄託於「兩岸中國人的自行解決」,而非「依賴國際盟友」。這種思維在當前中美對抗的格局下,使得深藍的立場客觀上與北京「反對外部勢力干涉」的論述高度重合。
然而,深藍的盲點在於:他們預設了北京會尊重「中華民國」的尊嚴與體制,且兩岸可以對等協商。但北京的戰略目標是消滅中華民國,這使得深藍的「對話夢」在現實中缺乏可行的政治基礎。
### 分析含義
分析時如何用這個事實?
- **預測深藍對國際危機的反應**:若中美髮生衝突,深藍傾向將責任歸咎於美國的挑釁,而非北京的擴張。這種反應可能削弱台灣在面對外部威脅時的內部團結。
- **評估「抗中保台」論述對深藍的無效性**:對深藍而言,「抗中」不是目的,「保台(保中華民國)」才是。若「抗中」被定義為「倚美謀獨」,深藍將堅決反對;若「抗中」被定義為「保衛中華民國主權」,深藍可能產生共鳴,但他們會質疑為何不透過對話來保衛。
---
## 關聯分析
### 關聯一:與 KMT 選民基礎的連結(提供脈絡層)
- `entities/parties/kmt_voter_base.md`:深藍是 KMT 選民基礎中的「理念錨」,但也是「路線枷鎖」。理解深藍的不可妥協性,是解讀 KMT 為何無法輕易拋棄九二共識的關鍵。
### 關聯二:與 KMT 路線鬥爭的連結(提供事實層)
- `entities/parties/kmt_two_lines_struggle.md`:KMT 內部的「親美 vs. 親中」、「本土 vs. 深藍」鬥爭,實質上是深藍受眾的意志與 KMT 選戰利益之間的拉扯。每次路線鬥爭,都是深藍逼宮的具體展現。
### 關聯三:與疑美論的連結(提供脈絡層)
- `narratives/cross_strait/skepticism_of_us.md`:疑美論在深藍群體中具有最高的接受度與轉發率。深藍的歷史創傷(斷交)與現實焦慮(避戰),是疑美論生根的最佳土壤。
### 關聯四:與棄台論的連結(提供脈絡層)
- `narratives/us_taiwan/us_will_abandon_taiwan.md`:棄台論精準地接合了深藍的「棄兒意識」。分析棄台論的傳播路徑,必須從深藍的媒體圈(中天、LINE 群組)切入。
### 關聯五:與紅媒論述的連結(用於對照)
- `narratives/cross_strait/red_media_discourse.md`:深藍是「紅媒」指控的受害者,也是該論述的反向消費者。他們將「紅媒」標籤視為綠營的政治迫害,這種認知反過來強化了他們對被標籤媒體的忠誠。
### 關聯六:與中天 YT 及中國時報的連結(提供事實層)
- `entities/media/cti_news_youtube.md` 與 `entities/media/china_times.md`:這兩個媒體是深藍受眾的「數位眷村」與「認同工廠」。分析深藍的輿論風向,必須每日監測這兩個媒體的議題設定與留言區情緒。
---
## 常見的分析陷阱
### 陷阱一:將深藍等同於「親共賣台」
這是最嚴重的錯誤。深藍的核心是「中華民國法統」,他們對北京有歷史親近感與對話意願,但絕不接受北京消滅中華民國。將深藍的「避戰求和」簡化為「賣台」,會嚴重誤判他們在關鍵時刻(如北京武力犯台)的行為——多數深藍會選擇保衛中華民國,而非迎接解放軍。
### 陷阱二:假設深藍會無條件支持 KMT
深藍對 KMT 的支持是「契約式」的。當 KMT 候選人(如侯友宜)被視為「藍皮綠骨」或「背離法統」時,深藍的懲罰性不投票會直接導致 KMT 的敗選。忽略深藍的「逼宮」能力,會高估 KMT 候選人向中間靠攏的空間。
### 陷阱三:將深藍視為同質性整體
深藍內部有「正統藍」(堅持一中各表)與「紅統藍」(接受一國兩制)的差異。在分析北京政策對深藍的影響時,必須區分這兩種次群體的不同反應,否則會得出「深藍全面倒向北京」的錯誤結論。
### 陷阱四:低估深藍的數位動員力
認為深藍只是「不會上網的老人」是過時的偏見。透過中天 YT 與 LINE 群組,深藍已建立起高效的數位動員網絡。他們在網路上的聲量遠大於其人口比例,且具有極高的黏著度與戰鬥力。
### 陷阱五:認為疑美論對深藍只是「論述」
對深藍而言,疑美論不是一種可辯論的觀點,而是根植於歷史創傷的「事實」。試圖用理性數據或美國官方聲明來反駁疑美論,對深藍受眾完全無效,甚至會因為「美國官方 = 不可信」的預設而產生反效果。
### 陷阱六:忽略深藍的「代際斷裂」
外省一代的凋零,帶走的是最堅定的反共與法統記憶。二代三代的深藍,其認同更多是「反對民進黨」與「反對台獨」,而非「堅持反共復國」。這種代際差異意味著,未來的深藍可能更容易接受北京的「民族主義」召喚,而更少堅持「民主統一」的前提。
---
## 仍不確定的部分
### 北京「一國兩制台灣方案」對深藍的撕裂效應
若北京在 2028 前強推「一國兩制台灣方案」或要求 KMT 明確表態支持統一,深藍內部的「正統藍」與「紅統藍」將面臨攤牌。正統藍是否會為了保衛中華民國而與北京決裂?還是會為了避免戰爭而妥協?此群體在極端壓力下的行為模式,目前缺乏歷史先例,難以精確預測。
### 深藍第三代的認同走向
外省第三代在完全台灣化的教育與社會環境下長大,多數已脫離深藍認同。但仍有少數第三代透過網路同溫層繼承了深藍意識。這群「數位深藍青年」的規模、影響力與行為模式,目前仍缺乏足夠的量化數據支撐分析。他們是否會成為紅色統戰的在地樁腳,值得長期監測。
### 中美對抗加劇下深藍的「避險選擇」
當中美對抗升級至準戰爭狀態時,深藍的「避戰」邏輯是否會從「與北京對話」轉向「接受北京條件以換取和平」?還是會因為美國的壓力(如要求台灣展現防衛決心)而被迫選邊站?深藍在極端地緣危機下的避險策略,目前仍存在高度不確定性。
### KMT 路線重置的可能性
KMT 是否有可能在 2028 前完成路線重置,提出一套既能滿足深藍「反獨保華」底線,又能被中間選民接受的兩岸論述?若 KMT 成功重置,深藍的邊緣化將加速;若 KMT 重置失敗,深藍將繼續綁架 KMT,但 KMT 的選舉天花板也將被鎖死。此一政黨與受眾的博弈結果,目前仍無法確定。
---
## 與其他文件的關係
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照深藍作為 KMT 理念錨的結構性位置)
- KMT 路線鬥爭:`entities/parties/kmt_two_lines_struggle.md`(對照深藍意志如何轉化為 KMT 內部的路線衝突)
- 疑美論:`narratives/cross_strait/skepticism_of_us.md`(對照深藍作為疑美論核心受眾的認知機制)
- 棄台論:`narratives/us_taiwan/us_will_abandon_taiwan.md`(對照深藍歷史創傷如何被棄台論接合)
- 紅媒論述:`narratives/cross_strait/red_media_discourse.md`(對照深藍媒體被標籤後的防禦性反應)
- 中國時報:`entities/media/china_times.md`(對照深藍平面媒體的議題設定功能)
- 中天 YT:`entities/media/cti_news_youtube.md`(對照深藍數位化後的資訊繭房與動員機制)
---
## 一句話濃縮
> 深藍受眾是中華民國法統的歷史囚徒,其對台獨的零容忍與對美國的棄兒創傷,使其在結構上成為疑美論與北京交易論的易感體;但只要北京觸碰消滅中華民國的底線,這種基於避戰的親近便會瞬間逆轉為基於信仰的反抗。
---
last_updated: 2024-10-26
@@ -1,318 +0,0 @@
# 深綠受眾
---
**類型**: 政治敘事與受眾特化檔案
**當前角色**: 台灣政治光譜中主權理念極大化群體的行為模式、認知結構與動員邏輯
**何時調用**: 評估 DPP 兩岸政策向中間靠攏時的深綠反彈風險;分析獨派小黨(基進黨等)的票源轉移與棄保效應;預測轉型正義、正名制憲議題對選情的邊際影響;解讀北京對台施壓升級時深綠群體的輿論反應與政策訴求;規劃針對深綠受眾的認知作戰或反向動員策略;分析 2028 大選中主權議題的激化門檻
---
## 一句話濃縮
> 深綠受眾是台灣主權論述的「錨」與 DPP 執政的「枷」——他們以「台灣獨立」為不可妥協的信仰,對「中華民國」體制抱持結構性疏離,其政治功能不在於開疆拓土,而在於為本土政權設定不可跨越的紅線,並在 DPP 偏離時施展懲罰性制裁。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「深綠」視為 DPP 的「忠誠縮影」或「更激烈的 DPP 支持者」。這種簡化會導致對 DPP 內部路線鬥爭的誤判,也無法解釋為何深綠常常是 DPP 執政時最嚴厲的內部批判者。
深綠受眾與 DPP 的關係,本質上是「信仰共同體」與「選舉機器」的脆弱聯盟。深綠的核心命題是「台灣主權獨立於中華人民共和國之外,且必須在法理與體制上徹底與中國脫鉤」;而 DPP 的核心命題是「取得並維持執政權」。這兩者在「反對 KMT」與「抗中」時高度重合,但在「執政策略」上存在無法調和的內在矛盾:
1. **體制認同的矛盾**:深綠將「中華民國」視為外來殖民體制的遺緒,必須被推翻或正名;DPP 則將「中華民國台灣化」作為維持現狀的執政工具。
2. **路線優先序的矛盾**:深綠認為「主權正名」是生存的前提,內政改革必須服從於主權建構;DPP 則認為「選票極大化」是推動任何理念的前提,主權正名必須讓位於中間選民的避險心理。
3. **對北京壓力的反應矛盾**:深綠將北京的打壓視為「加速正名建國」的動力;DPP 則將其視為必須「穩健應對」以避免戰爭的風險。
理解深綠受眾,就是理解 DPP 政權的「左翼壓力」與「理念天花板」。分析者必須認知:深綠對 DPP 的支持從來不是無條件的,而是基於「沒有更好選擇」的含淚妥協;一旦出現具備執政可能性且更貼近獨派理念的替代選項,深綠將是 DPP 選民基礎中最先叛逃的板塊。
---
## 群體定義與邊界:誰是深綠?
### 事實層
深綠受眾約佔台灣選民的 10-15%,是 DPP 基本盤(約 35-40%)中最核心的理念驅動群體。其識別標準不是「支持 DPP」,而是以下三個維度的交集:
1. **主權立場**:明確支持台灣獨立(法理獨立或正名制憲),反對「九二共識」與任何形式的「一中原則」,對「維持現狀」抱持懷疑,認為現狀即是「中華民國體制下的不正常狀態」。
2. **體制態度**:對「中華民國」國號、憲法、象徵(國旗、國歌)抱持高度疏離或排斥,支持轉型正義與去威權化,認為不處理威權遺緒就無法建立本土國家。
3. **敵我辨識**:將北京視為唯一且絕對的外部敵人,將國民黨及其附隨組織視為內部殖民結構的殘餘,對「兩岸交流」持本能性的排斥,認為任何非官方交流都是統戰滲透的溫床。
在人口特徵上,深綠群體呈現「雙峰分布」:
- **第一峰:黨外與解嚴世代的理念獨派**(年齡 65 歲以上,約佔深綠 40%)。多為本省籍,經歷過白色恐怖或黨外運動,其台獨理念帶有強烈的民族主義與反殖民色彩。
- **第二峰:太陽花世代的進步獨派**(年齡 30-45 歲,約佔深綠 35%)。多具備高等教育背景,其台獨理念與進步價值(轉型正義、性別平權、環境正義)深度綁定,是「抗中保台」論述的核心載體。
| 維度 | 理念獨派(老深綠) | 進步獨派(新深綠) |
|---|---|---|
| 核心驅動 | 民族主義、反殖民、歷史正義 | 公民民族主義、民主防衛、進步價值 |
| 對中華民國態度 | 徹底排斥,視為外來政權 | 疏離,視為待改革的威權遺緒 |
| 動員觸發點 | 正名制憲、轉型正義、去蔣化 | 抗中保台、香港局勢、中國人權 |
| 對 DPP 的態度 | 批評其妥協,但恐懼 KMT 執政 | 批評其保守與利益分贓,期待第三勢力 |
| 資訊來源 | 傳統獨派媒體、 LINE 封閉群組 | 獨立媒體、社群網路、國際新聞 |
### 脈絡層
**破除常見簡化**:將深綠等同於「極端 DPP 支持者」是嚴重的分析錯誤。深綠與 DPP 的關係是「監督者與代理人」,而非「粉絲與偶像」。
深綠群體的邊界,實際上是由「華獨」(`narratives/cross_strait/hua_du_discourse.md`)的對立面所定義的。華獨主張「中華民國在台灣已經是主權獨立國家,無需再宣布獨立」,這正是深綠所強烈反對的。深綠認為,接受中華民國體制,就是接受「一中原則」的憲法框架,就是自我放棄法理建國的正當性。因此,深綠在政治光譜上的首要鬥爭對象,往往不是遙遠的北京,而是近在咫尺的「華獨路線」——這正是 DPP 內部主流(如英系、部分新潮流)的執政路線。
**結構性命題**:深綠的邊界是由「純度」來維持的,這導致其內部存在永恆的「路線審查」壓力。
因為深綠的規模有限(10-15%),無法單獨取得執政權,必須依賴與 DPP 結盟。但為了防止被 DPP 的選舉邏輯吞噬,深綠群體內部會自發性地產生「路線審查」機制——任何與「華獨」妥協的言論、任何對北京展現善意的姿態,都會被迅速標籤為「背叛」。這種純度審查使得深綠在輿論場上顯得極度強勢,但其代價是群體的持續碎裂化(如建國黨、台聯、基進黨、喜樂島聯盟的分裂歷史)。
### 分析含義
分析時如何用這個事實?
- **計算 DPP 的左翼流失率**:在無外部危機的選舉中,DPP 的政黨票通常會有 5-10% 流向獨派小黨(如 2020 台灣基進、2024 小黨碎片化)。這不是因為選民不支持 DPP,而是深綠在履行「路線審查」與「政策施壓」的職能。
- **識別「深綠逼宮」的觸發閾值**:當 DPP 在國會未過半,或總統選情膠著需要深綠歸隊時,深綠會趁機提出體制改革(如護照正名、特赦陳水扁、憲改)作為交換條件。分析者必須評估 DPP 吸收深綠訴求後,流失中間選票的代價是否大於深綠歸隊的收益。
---
## 對中華民國國號的複雜態度:生存工具與理念枷鎖
### 事實層
深綠對「中華民國」的態度,是理解其政治行為的關鍵密碼。他們在理念上排斥這個國號,但在現實中又必須依賴這個體制生存。
1. **理念層**:中華民國憲法仍保有「大陸地區」的領土框架,這使得台灣在法理上無法擺脫「中國內戰遺緒」的定位。深綠認為,只要使用中華民國國號,北京就有法理正當性宣稱「台灣是中國的一部分」,國際社會也無法區別「中華民國」與「中華人民共和國」。
2. **現實層**:深綠選民依然持有中華民國護照、使用新台幣、在中華民國法院訴訟。他們並非無政府主義者,而是將中華民國體制視為「事實上的治理機器」,但拒絕賦予其「正當的國家認同」。
3. **選舉層**:深綠候選人(如基進黨)依然參與中華民國的選舉,這常被批評為「邏輯不一致」。但深綠的內部邏輯是「佔領體制以改變體制」——參選是為了取得發聲平台,而非承認體制的正當性。
### 脈絡層
**中等模型自己想不到的洞察**:深綠對中華民國的態度,在 2016 年後經歷了「實用主義的轉向」,但這個轉向反而加深了其內在焦慮。
蔡英文政府提出「中華民國台灣」論述,成功將中華民國體制「台灣化」,使其成為防禦北京併吞的擋箭牌。部分深綠(特別是進步獨派)在面對北京極限施壓時,不得不承認「中華民國台灣」在國際防禦上的實用價值——這是一種「用敵人的盾牌抵擋敵人」的無奈。
然而,這種實用主義的妥協,引發了老深綠的強烈焦慮。他們擔心,一旦深綠接受了「中華民國台灣」的論述,台獨運動就會被徹底「閹割」,年輕世代將失去追求正名制憲的動力,台灣將永遠被困在「中華民國」的框架內。這種焦慮,是深綠對 DPP「維持現狀」路線最深刻的批判來源。
### 分析含義
分析時如何用這個事實?
- **解讀深綠對 DPP 領袖的忠誠度波動**:當 DPP 領袖(如蔡英文)訴諸「中華民國台灣」時,深綠會在行動上配合(因為要抗中),但在言論上猛烈批判(因為背叛理念)。當 DPP 領袖(如賴清德)訴諸「務實的台獨工作者」時,深綠會在言論上支持,但在行動上仍保持觀望(因為知道執政後必然妥協)。
- **預測正名議題的爆發時機**:當北京在國際上進一步打壓「中華民國」空間(如強迫外國企業將台灣改名為「中國台灣」),深綠的「正名」訴求將獲得強大的現實支撐——既然國際不接受中華民國,不如正名為台灣。此時,深綠對中華民國體制的排斥將從理念層面上升為戰略層面。
---
## 核心議題敏感點:轉型正義與抗中保台的雙引擎
### 事實層
深綠受眾的政治動員,主要由兩大引擎驅動:一是「轉型正義」(對內清除威權遺緒),二是「抗中保台」(對外防禦北京併吞)。這兩者在深綠的認知中是不可分割的:沒有轉型正義,就無法建立本土國家認同;沒有本土國家認同,就無法抵抗北京的統戰。
1. **轉型正義的絕對性**:深綠對「不當黨產處理」、「威權象徵清除(如中正紀念堂)」、「政治檔案公開」具有極高敏感度。他們無法接受 DPP 以「社會和諧」或「選舉考量」為由放緩轉型正義步伐。任何對威權象徵的妥協,都會被解讀為「本土政權對威權勢力的投降」。
2. **抗中保台的底線性**:深綠是「抗中保台」論述的最堅定支持者(`narratives/cross_strait/taiwan_independence_discourse.md`)。他們反對任何形式的兩岸政治談判,反對重啟服貿貨貿,對兩岸經貿依賴持高度警惕。太陽花學運(`events/key_events/event_sunflower_2014.md`)正是深綠理念與青年反中情緒匯流的歷史高峰。
### 脈絡層
**破除常見簡化**:將「轉型正義」與「抗中保台」視為兩個獨立議題是錯誤的。在深綠的認知結構中,兩者是「清創」與「防禦」的關係。
深綠認為,國民黨的威權遺緒(黨產、侍從網絡、大中國意識形態)是北京能夠滲透台灣社會的內部破口。如果不徹底清算國民黨的威權遺產,台灣社會就永遠存在一股「認同中國、依賴中國」的內應力量。因此,深綠推動轉型正義,不僅是為了歷史正義,更是為了國家安全——這是一場「內部的去殖民化」戰爭。
這也解釋了為何深綠對 DPP 的「和解共生」路線極度反感。當 DPP 為了國會過半而與地方派系或淺藍勢力妥協時,深綠視之為「與敵人共枕」,因為這等於保留了北京滲透的破口。
### 分析含義
分析時如何用這個事實?
- **評估 DPP 推動轉型正義的極限**:DPP 推動轉型正義的最大阻力不是 KMT,而是中間選民的冷漠與反感。當轉型正義觸及社會撕裂的紅線時,DPP 會選擇踩剎車,此時深綠的反彈將極為劇烈。分析者可藉此預測 DPP 內部的路線衝突節點。
- **預測「抗中保台」的疲勞效應**:深綠不會對「抗中」疲勞,但中間選民會。當 DPP 因中間選民疲勞而調整論述時,深綠會感到被拋棄,進而轉向支持更激進的獨派組織或基進黨,作為抗議。
---
## 信息行為與認知特徵:高牆內的迴音室與選擇性韌性
### 事實層
深綠受眾的信息行為呈現「高信任、高過濾、高動員」的特徵:
1. **主要接觸渠道**:
- 老深綠:自由時報、民視、政論節目(如「政經看民視」殘留受眾)、綠色和平電台、LINE 封閉群組。
- 新深綠:獨立媒體(報導者、歪腳姬)、特定親綠網路媒體(菱傳媒、鏡週刊)、X(Twitter)上的台灣主權倡議圈、特定 YouTuber 與 Podcast。
2. **信任來源**:對「自己人」的信任度極高,只要訊息來源具有獨派色彩或長期抗中紀錄,即使內容未經嚴格查證也容易被接受。對主流媒體(如聯合報、中國時報)抱持本能性的不信任,視為「統派媒體」或「中共傳聲筒」。
3. **內容偏好**:偏好「北京對台打壓的鐵證」、「KMT 親中賣台的言行」、「DPP 妥協退讓的批判」、「台灣國際空間突破的消息」。
### 脈絡層
**結構性命題**:深綠的認知結構具有「選擇性韌性」——對外部攻擊免疫,但對內部欺騙脆弱。
深綠對北京的認知作戰具有極高的免疫力。任何來自北京或親中媒體的訊息,無論包裝得多麼溫和或具有吸引力,都會被深綠的「敵我意識」過濾掉。北京試圖用「兩岸一家親」或「經濟紅利」來軟化深綠,幾乎毫無效果。
然而,深綠對「內部欺騙」極度脆弱。當訊息來源是「本土陣營」時,深綠的防備心會降至最低。這使得深綠的資訊圈極易被特定政治人物或派系操縱,用來打擊黨內異己。例如,2019 年初的「蔡英文論文門」與「私菸案」在深綠圈內被瘋狂轉傳,背後有明顯的黨內初選派系鬥爭痕跡。深綠在這類內部鬥爭中,往往成為被動員的打手而不自知。
### 分析含義
分析時如何用這個事實?
- **評估認知作戰的穿透率**:針對深綠的認知作戰,最有效的路徑不是「統戰」,而是「反間」——偽裝成比 DPP 更獨的立場,批評 DPP 與北京暗盤交易,藉此激化深綠對 DPP 的不信任,壓低深綠的投票意願。
- **預測黨內初選的激烈程度**:DPP 黨內初選時,深綠的輿論場往往是主戰場。候選人會競相向深綠靠攏,甚至散布對手「不夠獨」或「密使穿梭」的暗示。分析者可透過觀察深綠媒體的風向,預判黨內權力重組的趨勢。
---
## 政治傾向與投票模式:懲罰性制裁與邊緣突圍
### 事實層
深綠的投票行為,是「含淚投票」、「懲罰性不投票」與「分裂投票」的複合體:
1. **總統大選:含淚歸隊**:在總統大選中,深綠幾乎不可能轉向 KMT 或 TPP。即使對 DPP 候選人極度不滿(如 2020 年初選期間對蔡英文的抵制),在面對 KMT 候選人的威脅下,深綠最終仍會高比例歸隊。這是因為總統大選的「輸不起」效應太強。
2. **政黨票與地方選舉:懲罰性制裁**:在不涉及國家主權危機的選舉中,深綠傾向使用政黨票或地方選票來「教訓」DPP。2016 年時代力量崛起、2020 年台灣基進拿下政黨票,都是深綠制裁 DPP 的具體展現。
3. **公投與社會運動:高動員率**:深綠是各類主權公投(如東京奧運正名公投)、社會運動(如太陽花、反紅媒遊行)的核心動員人群。他們的參與度遠高於一般選民。
### 脈絡層
**中等模型自己想不到的洞察**:深綠的「懲罰性制裁」不是為了讓 DPP 落選,而是為了重新校準 DPP 的路線。
深綠非常清楚,自己的選票規模不足以單獨執政。他們將選票投給獨派小黨,目的不是讓小黨取代 DPP,而是向 DPP 發送信號:「你偏離了本土路線,我隨時可以抽走你的政黨票。」這是一種「邊緣突圍」的策略——透過在 DPP 外圍建立具威脅性的獨派板塊,迫使 DPP 在關鍵議題(如大法官提名、轉型正義法案)上向深綠妥協。
然而,這種策略存在致命的數學盲區:單一選區兩票制下,小黨往往無法跨越 5% 的門檻,導致深綠的選票成為「無效票」。2024 年的政黨票碎片化,使得深綠的制裁力量被大幅削弱,反而讓 DPP 在國會未過半時缺乏關鍵盟友。
### 分析含義
分析時如何用這個事實?
- **計算 DPP 的政黨票流失風險**:在 2028 年,若 DPP 繼續執政且未推動深綠期待的體制改革,深綠的政黨票流失率可能達到 10-15%。這將直接威脅 DPP 在國會的席次,甚至導致本土派在國會不過半。
- **評估「棄保效應」在政黨票的運作**:深綠選民在投票前夕會密切觀察民調。若獨派小黨(如基進黨)徘徊在 5% 門檻邊緣,深綠內部會爆發「堅持理念投小黨」與「集中選票護 DPP 過半」的激烈辯論。此時的輿論風向,將決定最終的票流走向。
---
## 两岸與國際態度:從反共到抗中的戰略依賴
### 事實層
1. **兩岸議題態度**:
- 統獨:堅定支持台灣獨立,反對統一,對「維持現狀」視為過渡階段。
- 交流:反對 ECFA 與任何制度化經貿協議,主張台灣經濟應脫離對中依賴(「抗中護台」優先於「經濟利益」)。
- 北京定位:將北京視為具敵意的帝國主義政權,否認「九二共識」的任何變體。
2. **國際關係態度**:
- 對美方:深綠是「親美路線」的堅定支持者,但這種親美是「戰略依賴」而非「價值認同」。老深綠早期有反美帝傳統,但面對北京威脅,已轉向務實親美。新深綠則完全擁抱美國的安全承諾。
- 對日方:具有較高的親日傾向,這與歷史上的台日連結及當前安保合作密切相關。
- 國際參與:強烈支持以「台灣」名義加入國際組織(如 WHO、UN),反對使用「中華台北」等屈辱性名稱。
### 脈絡層
**破除常見簡化**:將深綠的「親美」視為毫無保留的追隨是錯誤的。深綠的親美是「依賴中的焦慮」。
深綠極度恐懼美國的「棄台論」或「疑美論」。當美國展現對台安全承諾的動搖(如從阿富汗撤軍、對烏克蘭軍援遲滯)時,深綠的焦慮會瞬間爆發。這種焦慮有兩種表現形式:一是更加用力地擁抱美國,試圖證明台灣的戰略價值;二是轉向批判美國的「帝國主義不可靠」,呼籲台灣必須建立自主防衛能力(如發展不對稱作戰與全民防衛)。
這使得深綠在國際關係論述上,常常在「依賴美國保護」與「台灣自我覺醒」之間擺盪。他們支持向美國購買武器,但也對美國要求台灣延長兵役、增加國防預算的「施壓」感到不滿,認為這是將台灣視為次等盟友。
### 分析含義
分析時如何用這個事實?
- **預測深綠對美中博弈的反應**:當美中尋求緩和(如拜習會)時,深綠會極度不安,擔心台灣成為籌碼,進而對 DPP 政府的對美政策施加壓力,要求強化台美實質關係的法制化(如台美避免雙重課稅協議、軍事訓練常態化)。
- **評估「疑美論」在深綠圈的反向操作**:北京或 KMT 散布的「疑美論」在深綠圈無法直接生效,但若轉化為「美國不可靠,台灣必須靠自己建國防」的論述,則能在深綠圈引發共鳴,間接削弱對 DPP 親美路線的支持。
---
## 近年變化趨勢:太陽花世代的接班與路線重構
### 事實層
過去十年,深綠受眾經歷了結構性的世代交替:
1. **老深綠的凋零**:黨外世代的理念獨派正快速老化與凋零,其民族主義式的台獨論述對年輕世代缺乏吸引力。
2. **太陽花世代的接班**:2014 年太陽花學運(`events/key_events/event_sunflower_2014.md`)催生了一批新的深綠群體。他們的台獨理念不是基於血統或反殖民歷史,而是基於「公民社會防禦北京侵蝕」的民主價值。
3. **從「建國」到「防衛」的論述轉移**:老深綠的核心命題是「正名制憲建國」,新深綠的核心命題是「民主防衛與抗中保台」。新深綠可以接受「中華民國台灣」作為暫時的防禦工具,只要這個體制能有效抵抗北京。
### 脈絡層
**結構性命題**:太陽花世代的接班,使得深綠的議題關懷從「單一的主權正名」擴張為「主權加進步價值」的雙重綁定。
新深綠不僅要求抗中,還要求居住正義、勞工權益、性別平權與能源轉型。這對 DPP 構成了前所未有的壓力:過去 DPP 只要打出「抗中保台」,深綠就會含淚投票;現在,DPP 必須同時在主權與分配正義上交出成績,否則新深綠會轉向支持具備進步價值的第三勢力(如過去的時代力量、現在的社民黨或綠黨),甚至選擇不投票。
這也導致了深綠內部的「路線斷層」。老深綠認為新深綠「不夠純粹」,因為新深綠願意擱置正名議題;新深綠則認為老深綠「不知變通」,因為死守正名制憲只會讓台灣失去國際支持與內部共識。
### 分析含義
分析時如何用這個事實?
- **修正「深綠等於老一輩」的偏見**:深綠正在年輕化,但其動員邏輯已改變。2028 年,若 DPP 無法在居住正義與內政改革上回應新深綠的期待,單靠「抗中」已無法保證這群 30-45 歲選民的歸隊。
- **識別獨派小黨的生存空間**:未來能在深綠板塊存活的政黨,必須同時具備「堅定抗中」與「進步價值」的雙重標籤。僅靠傳統的建國訴求,已無法吸引新深綠的政黨票。
---
## 關聯分析
### 關聯一:與 DPP 選民基礎的連結(提供脈絡層)
- `entities/parties/dpp_voter_base.md`:深綠是 DPP 選民基礎中「理念獨派」的核心,佔 DPP 基本盤的 20-25%。理解深綠的行為模式,是預測 DPP 基本盤鬆動與政黨票流失的關鍵。深綠的「懲罰性制裁」直接決定了 DPP 在國會的席次天花板。
### 關聯二:與台獨論述的連結(提供事實層與脈絡層)
- `narratives/cross_strait/taiwan_independence_discourse.md`:深綠是台獨論述的忠實載體。台獨論述的版本演變(從建國到防衛),直接映射了深綠內部新老世代的交替。分析台獨論述的強弱週期,就是分析深綠的動員強度。
### 關聯三:與轉型正義論述的連結(提供事實層)
- `narratives/cross_strait/transitional_justice_discourse.md`:轉型正義是深綠的「內部清創」引擎。轉型正義論述的推進或停滯,直接影響深綠對 DPP 執政的信任度。當轉型正義被擱置,深綠會將其解讀為 DPP 與威權勢力妥協的鐵證。
### 關聯四:與太陽花事件的連結(提供脈絡層)
- `events/key_events/event_sunflower_2014.md`:太陽花學運是深綠群體「新陳代謝」的關鍵節點。它不僅注入了新深綠,更將深綠的論述從「民族主義建國」轉向「公民民族主義防衛」。理解太陽花的遺產,是理解當前深綠對 DPP 既合作又批判的矛盾心理。
### 關聯五:與華獨論述的連結(用於對照)
- `narratives/cross_strait/hua_du_discourse.md`:華獨(中華民國台灣已獨立)是深綠的對立面。深綠的邊界是由反對華獨來定義的。分析 DPP 內部的路線鬥爭,實質上是深綠(台獨)與英系/新潮流主流(華獨)的博弈。
### 關聯六:與 DPP 派系的連結(提供交叉驗證)
- `entities/parties/dpp_factions.md`:DPP 內部的獨派色彩派系(如正國會、部分新潮流邊緣)與深綠受眾存在共生關係。派系人物常利用深綠的輿論力量作為黨內鬥爭的籌碼,深綠也依賴特定派系在體制內推動其議程。
---
## 常見的分析陷阱
### 陷阱一:將深綠視為 DPP 的「鐵票」
深綠是「理念的鐵票」,不是「DPP 的鐵票」。當 DPP 的路線偏離其理念時,深綠的懲罰力度遠大於中間選民。中間選民只是不投票,深綠則會轉投小黨或發動輿論攻擊,對 DPP 的殺傷力更大。假設深綠會無條件含淚投票,會嚴重高估 DPP 的政黨票。
### 陷阱二:忽略深綠內部的世代斷層
將深綠視為一群懷舊的老一輩,是嚴重的認知盲區。太陽花世代的新深綠已經接班,他們的訴求是「主權加進步價值」。用老深綠的邏輯(正名制憲)去預測新深綠的行為,會完全失準。新深綠更可能因為居住正義或能源政策而背棄 DPP,而非因為正名議題。
### 陷阱三:認為「抗中保台」對深綠是萬靈丹
「抗中保台」是深綠的底線,但不是深綠的全部。當 DPP 執政表現不佳時,深綠不會因為 DPP 喊幾句「抗中」就無條件歸隊。他們會質疑:「你抗中是為了保台,還是為了保自己的政權?」若 DPP 無法在內政上符合進步價值,深綠的冷感將無法被外部危機完全扭轉。
### 陷阱四:將深綠對中華民國的態度簡化為「完全不認同」
深綠對中華民國的態度是複雜且實用的。在面對北京打壓時,深綠也會拿起「中華民國」的盾牌防禦(如捍衛邦交國不被挖走)。將深綠視為完全否定現實體制的無政府主義者,會忽略他們在戰略上的靈活性與妥協空間。
### 陷阱五:低估深綠輿論場的「反噬效應」
深綠的輿論場具有極高的同溫層效應與獵巫傾向。當 DPP 試圖向中間靠攏時,深綠的批判往往比藍營更為尖銳,且會迅速在綠營內部擴散,侵蝕 DPP 的基本盤信任度。分析者若只關注藍綠對決,而忽略深綠對 DPP 的反噬,將無法解釋 DPP 內部的路線震盪。
### 陷阱六:將深綠的「疑美」與藍營的「疑美」等同
深綠的「疑美」是基於「擔心被拋棄」的焦慮,藍營的「疑美」是基於「擔心被當棋子」的拒斥。兩者出發點完全不同。深綠疑美的結果是更努力證明台灣的價值,藍營疑美的結果是尋求與北京和解。將兩者混為一談,會導致對深綠國際觀的嚴重誤判。
---
## 仍不確定的部分
### 新深綠在極端危機下的行為模式
新深綠(太陽花世代)尚未經歷過如 1996 台海飛彈危機或真實戰爭邊緣的極限壓力測試。當面臨真正的生存威脅時,他們是會像老深綠一樣更加強硬,還是會因為對生活品質與個人自由的依戀而產生避戰心理?目前缺乏實證數據。
### 深綠對 DPP 提名大法官或司法改革的影響力邊界
深綠高度關注轉型正義與司法改革,但 DPP 在提名大法官或推動相關法案時,必須兼顧專業與中間選民觀感。深綠的施壓在多大程度上能實質改變 DPP 的人事佈局?當深綠的訴求與法學界主流意見衝突時,DPP 會如何取捨?此邊界仍不明確。
### 北京「精準分化」策略對深綠的潛在效果
北京是否可能調整策略,從全面打壓轉向「拉攏老深綠的民族主義,打擊新深綠的進步價值」?例如,利用老深綠對美日帝國主義的歷史反感,散布「民進黨已成美日附庸」的論述,試圖在深綠內部製造裂痕。這種精準分化在理論上可行,但實際效果仍不確定。
### 獨派小黨在 2028 的整合可能性
2024 年獨派小黨碎片化導致政黨票全軍覆沒。2028 年,基進黨、社民黨、綠黨等是否能整合出單一列表,以跨越 5% 門檻?這取決於深綠選民對 2024 教訓的解讀,以及各小黨領袖的權力計算。若整合失敗,深綠的制裁力量將持續虛耗。
---
## 與其他文件的關係
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照深綠作為 DPP 基本盤核心的錨定與枷鎖效應)
- 台獨論述:`narratives/cross_strait/taiwan_independence_discourse.md`(對照深綠受眾如何承載並推動台獨論述的演變)
- 轉型正義論述:`narratives/cross_strait/transitional_justice_discourse.md`(對照深綠將轉型正義視為建國前提的認知結構)
- 太陽花事件:`events/key_events/event_sunflower_2014.md`(對照太陽花如何重塑深綠的世代結構與議題偏好)
- 華獨論述:`narratives/cross_strait/hua_du_discourse.md`(對照深綠與華獨的路線鬥爭如何定義本土派的邊界)
- DPP 派系:`entities/parties/dpp_factions.md`(對照深綠受眾與 DPP 內部獨派色彩派系的共生與相互利用)
---
## 一句話濃縮
> 深綠受眾是台灣主權論述的守門人,他們以理念純度審查制約著 DPP 的向中間移動空間,在承平時期是執政者的嚴厲批判者,在危機時刻則是本土政權的最後防線;預測深綠的行為,就是預測 DPP 的路線底線與政黨票的流失臨界點。
---
last_updated: 2024-10-25
@@ -1,431 +0,0 @@
# 淺藍受眾
---
**類型**: 受眾(政治認同特化檔案)
**當前角色**: 台灣淺藍選民的結構特徵、敘事偏好、投票行為邏輯與 2024-2028 演變預測基礎
**何時調用**: 預測 2028 及後續全國性選舉中 KMT 與 TPP 的票源流動;分析「避戰」與「經濟務實」論述的市場穿透力;評估地方派系在中央大選的動員極限;解讀民調中「政黨認同偏藍但不支持 KMT 候選人」的深層意義;規劃針對淺藍的棄保操作或論述滲透策略
---
## 一句話濃縮
> 淺藍不是「比較不藍的深藍」,而是「以維持現狀為底線、以經濟務實為偏好、對北京戒慎但不敵視、對本土有認同但拒斥激進」的浮動聯盟;他們是 KMT 的浮動抵押品,當 KMT 向深藍妥協時他們流向 TPP,當兩岸危機升高時他們被迫歸隊,其流向決定了台灣選舉是藍綠五五波還是綠大於藍的常態。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「淺藍」視為深藍的弱化版——預設他們只是比較不熱衷的統派、比較不堅定的國民黨員、比較不排斥本土的保守者。這種簡化在預測時會導致嚴重失準,也無法解釋 2024 年 KMT 總統選票的流失與 TPP 的崛起。
淺藍的實質是三個異質性極高的次群體的「風險與利益共同體」:
1. **本省藍**:認同「中華民國台灣化」,恐懼戰爭破壞現有生活,對北京持務實交往態度。
2. **地方派系外圍**:依賴恩庇侍從網絡的資源分配,恐懼失去既得利益與生存空間,對統獨無理念執念。
3. **都會務實藍**:追求兩岸和平紅利與生活穩定,恐懼戰爭風險與經濟邊緣化,對深藍的意識形態感到疏離。
這三群人在「反對 DPP 執政」時與深藍高度重合,但在「KMT 路線調整」與「第三勢力選擇」時,其內在邏輯(避戰優先、利益導向、排斥極端)會與深藍的「理念優先」產生嚴重衝突。理解淺藍,就是理解 KMT 為何在總統大選中屢屢面臨「基本盤歸隊但擴張失敗」的困境,以及 TPP 如何成為淺藍的懲罰性出口。
---
## 群體定義與邊界:浮動抵押品的特徵
### 事實層
淺藍群體約佔台灣選民的 12-18%,是 KMT 選民基礎中位於外圍的關鍵少數。其識別標準不是「政黨認同強度」,而是「議題排序邏輯」:當主權危機升高時,他們的行為模式與深藍無異;當承平時期或內政議題主導時,他們的投票行為高度浮動,甚至可能跨陣營投票。
**關鍵區分:淺藍 vs 中間偏藍**
這是分析者必須精確掌握的界線:
- **淺藍**:具有明確的「偏藍」政黨認同底色,在心理上將 KMT 視為「自己的政黨」,但對 KMT 的現狀不滿。他們流向 TPP 是「家暴離家出走」,隨時可能因 DPP的失誤或 KMT 的改進而歸隊。
- **中間偏藍**:無穩定政黨認同,僅在特定候選人或特定議題(如經濟)上傾向藍營。他們流向 TPP 是「理性選擇」,缺乏情感羈絆,更難預測其回流節奏。
淺藍的人口特徵呈現雙峰分布:一端是中高齡(50歲以上)的本省籍與地方派系成員,另一端是 40-49 歲的都會中產階級。他們的共同點是「生活經驗中曾受惠於兩岸和平紅利或國民黨執政的穩定期」。
### 脈絡層
**結構性命題**:淺藍是 KMT 的「浮動抵押品」——KMT 越向深藍妥協以鞏固基本盤,淺藍的抵押品價值就越低,越容易被 TPP 或 DPP 的溫和論述收割;反之,KMT 若完全倒向淺藍的「避戰務實」路線,又會面臨深藍抽銀根的流動性危機。
淺藍的存在,使得 KMT 的選民基礎不是一個同心圓,而是一個雙星系統。深藍是質量大但距離遠的恆星,淺藍是質量小但距離近的行星。KMT 的路線搖擺,本質上是在這兩個引力源之間尋找平衡,而 2024 的棄保現象,證明了 KMT 目前的引力已不足以留住淺藍行星。
### 分析含義
分析時如何用這個事實?
- **修正「藍營基本盤」的計算方式**:KMT 的鐵票區只有深藍(約 20-25%),加上地方派系的核心(約 10%)。淺藍的 12-18% 是浮動的,在總統大選中必須獨立計算其歸隊率,不能直接加總為「藍營基本盤」。
- **識別 TPP 的「藍底」支持者**:2024 年柯文哲的選票中,約有 6-8% 是淺藍的懲罰性投票。這群人的行為模式不同於 TPP 的年輕鐵桿支持者,他們在 2028 的流向取決於 KMT 的候選人與路線,而非柯文哲的司法案件。
---
## 次群體結構:異質的風險聯盟
### 本省藍(中南部與本土務實派)
#### 事實層
此群體約佔淺藍的 35-40%,是 KMT 在中南部非都會區的存續基礎。年齡層以 50 歲以上為主,多為閩南語族群,職業多為中小企業主、農漁業相關從業人員、地方基層服務人員。其核心特徵是「認同中華民國台灣化」——他們對「中華民國」的認同是建立在台灣的現狀之上,而非對中國大陸的法統連結。
他們對北京的態度是「戒慎但不敵視」:他們深知北京的軍事威脅存在,但更在意的是兩岸經貿交流能否持續,因為這直接關乎其生計。他們對 DPP 的反感,主要來自「兩岸關係惡化導致生計受損」的相對剝奪感,而非意識形態上的統獨之爭。
#### 脈絡層
**破除常見簡化**:把本省藍視為「深藍的本土版」是嚴重的分析錯誤。本省藍與深藍在「反獨」的表象下,存在根本的內在衝突。
深藍反獨是為了「統一」或「維護法統」;本省藍反獨是為了「避戰」與「維持現狀」。當 KMT 的論述向「深化兩岸連結」移動時,深藍感到振奮,本省藍卻感到恐懼——因為他們比深藍更清楚,兩岸過度親近可能引發 DPP 更強烈的反撲,導致關係緊張,反而破壞了他們依賴的和平現狀。本省藍是「現狀的保守者」,而非「統一的推進者」。
#### 分析含義
分析時如何用這個事實?
- **評估 ECFA 終止的政治效應**:當北京對台施加經濟脅迫(如取消 ECFA 早收清單),本省藍的受害感最深。但他們的反應不是「歸咎北京」,而是「歸咎執政黨無能處理兩岸關係」。這使得北京的經濟脅迫在短期內可能產生反 DPP 效應,但長期若脅迫升級為全面封鎖,本省藍將因恐戰而轉向避險。
- 交叉引用:本省藍的利益邏輯與 `primitives/concepts/local_factions_and_black_gold.md` 中的恩庇侍從網絡高度重疊,但本省藍的範圍更廣,包含了非派系核心但利益同構的選民。
### 地方派系外圍(利益依賴型選民)
#### 事實層
此群體約佔淺藍的 25-30%,是地方派系恩庇侍從網絡的外圍成員。包含農漁會系統的基層成員、地方社團幹部、依賴派系樁腳服務的社區民眾。他們的政黨認同是「交易性」的:KMT 或地方派系能提供資源、服務與照顧,他們就支持;反之,他們會毫不猶豫地轉向或消極不投票。
在投票行為上,他們在地方選舉中具有極高的動員性,因為地方選舉的資源分配是具體且可見的;但在總統大選中,當國家認同議題覆蓋了利益分配議題時,他們的動員力會大幅衰退,因為總統大選的結果對其個人利益的影響是間接且模糊的。
#### 脈絡層
**中等模型自己想不到的洞察**:地方派系外圍是淺藍中最懂「棄保」的群體,但他們的棄保邏輯與都會選民完全不同。
都會選民的棄保是基於「勝算」的理性計算;地方派系外圍的棄保是基於「資源最大化」的交易計算。當他們判斷 KMT 候選人無法當選時,他們不是將票投給最有機會贏的對手,而是將票投給「能給地方最大回饋」的陣營,或者選擇「不投票」以保留與執政方談判的籌碼。2024 年部分地方派系外圍流向 TPP,不是因為他們認為柯文哲會贏,而是因為支持柯文哲可以向 DPP 與 KMT 同時展示其籌碼價值。
#### 分析含義
分析時如何用這個事實?
- **預測地方派系的倒戈時機**:地方派系外圍的投票行為,取決於派系核心的指令與資源供給。當 KMT 中央缺乏資源,且派系核心與 DPP 地方政府達成妥協時,外圍選民會出現大規模的消極不投票或轉向。
- 交叉引用:此群體的行為模式需結合 `entities/parties/kmt_voter_base.md` 中的「本省地方派系」段落交叉驗證,理解其從「鐵票」到「浮動票」的結構性轉變。
### 都會務實藍(軍公教外圍與中產階級)
#### 事實層
此群體約佔淺藍的 30-35%,是 KMT 在北部與都會區的支持者。包含軍公教的家屬與退休外圍、依賴兩岸經貿的中小企業主、傳統產業的白領階級。年齡層以 40-59 歲為主,教育程度多為大學。其核心特徵是「風險趨避與秩序偏好」——他們支持 KMT,是因為 KMT 代表了穩定、秩序與可預測性,他們恐懼 DPP 執政下的兩岸緊張與政策不確定性。
此群體對深藍的「意識形態鬥爭」缺乏興趣,對 KMT 內部的權力鬥爭感到厭倦。他們是「效能投票」的擁護者:誰能維持和平、誰能確保經濟穩定,他們就支持誰。
#### 脈絡層
都會務實藍是淺藍中最容易流向 TPP 的群體,因為他們對「政黨輪替」的渴望,高於對「KMT 執政」的執念。
當 KMT 提出的候選人無法滿足其「效能」期待(如 2024 的侯友宜被認為缺乏國際視野與論述能力),且 DPP 的執政又引發其強烈不滿時,他們會將 TPP 視為「懲罰兩大黨」的出口。他們支持柯文哲,不是認同其所有政策,而是認同其「打破藍綠僵局」的形象。這是一種「破壞性創新」的投票邏輯。
#### 分析含義
分析時如何用這個事實?
- **評估「藍白合」的市場基礎**:都會務實藍是「藍白合」論述的最大受眾。他們不在乎 KMT 與 TPP 的權力分配,只在乎「反 DPP 的力量能否極大化」。若 2028 藍白不合,此群體將面臨分裂投票的痛苦,部分可能選擇不投票。
- 交叉引用:此群體與 `narratives/cross_strait/avoid_war_frame.md` 高度關聯,避戰論述對此群體的穿透力最強。
---
## 政治傾向與投票模式:從含淚投票到懲罰性棄保
### 歷次選舉的行為軌跡
淺藍的投票行為在過去十年經歷了結構性演變:
- **2016**:深藍含淚投票,淺藍消極不投票或流向宋楚瑜。朱立倫的「換柱」風波嚴重挫傷淺藍對 KMT 制度的信任,導致 KMT 潰敗。
- **2018**:韓國瑜的「發大財」論述成功動員本省藍與地方派系外圍,淺藍短暫歸隊。但這是「利益動員」的極大化,而非理念認同。
- **2020**:香港反送中觸發避險邏輯,部分都會務實藍因恐戰而轉向支持蔡英文的「維持現狀」,本省藍則因經濟利益受損而勉強歸隊,淺藍內部出現分裂。
- **2024**:淺藍大規模棄保流向 TPP,成為決定選舉結果的關鍵變數。
### 2024 選戰中的棄保現象:數據與機制
#### 事實層
2024 年總統大選,侯友宜得票率 33.49%,柯文哲得票率 22.07%。相較於 2020 年韓國瑜的 38.61%,KMT 實質流失了約 5% 的選票,這些選票絕大多數流向了 TPP。
細部分析顯示,流失的選票主要集中在:
1. **40-49 歲大學學歷男性**:這是都會務實藍的核心,他們對侯友宜的草莽形象與論述能力極度不滿,大量轉向柯文哲。
2. **中南部非都會區的中小企業主**:這是本省藍的核心,他們對 KMT 的兩岸論述無法突破 DPP 封鎖感到絕望,將柯文哲視為「重啟兩岸對話」的另一種可能。
#### 脈絡層
**破除常見簡化**:2024 的淺藍棄保,不是「藍營分裂」,而是「淺藍對深藍綁架 KMT 的懲罰」。
淺藍流向 TPP 的驅動力,不是認同柯文哲的白色力量理念,而是對 KMT 提名機制與路線的深層不滿。侯友宜的候選人資格,被視為 KMT 地方派系與深藍黨中央妥協的產物,缺乏國家治理的格局。淺藍投票給柯文哲,本質上是「借殼上市」——借用 TPP 的外殼,表達對 KMT 無力制衡 DPP、無力提出可行兩岸論述的抗議。
這種「懲罰性棄保」與傳統的「策略性棄保」不同。策略性棄保是為了讓最有機會贏的人當選;懲罰性棄保是寧可讓對手贏,也要讓自己的政黨知道痛。這解釋了為何 2024 年後期,即使藍營操作「棄保保侯」,淺藍仍不願歸隊——因為他們的目標不是「侯友宜當選」,而是「KMT 必須改變」。
#### 分析含義
分析時如何用這個事實?
- **修正「棄保效應」的預測模型**:在 2028 選戰中,若 KMT 仍推出深藍色彩濃厚或論述能力不足的候選人,淺藍的懲罰性棄保將再次發生,且強度可能加劇。傳統的「藍營大團結」棄保操作,對淺藍已經失效。
- 交叉引用:2024 棄保的具體數據與選區分析,詳見 `events/key_events/event_2024_election_three_way.md`。
---
## 核心議題敏感點:避戰、經濟與現狀的優先排序
### 議題一:避戰(最高優先級)
#### 事實層
「避戰」是淺藍最核心的議題敏感點,也是其與深藍、深綠的根本差異。深藍的避戰常與「統一前提」掛鉤;深綠的避戰常與「備戰」掛鉤;淺藍的避戰則是純粹的「風險規避」——他們不在乎誰引發戰爭,只在乎如何避免戰爭,且不願以主權作為避戰的代價。
淺藍對 DPP「抗中保台」論述的排斥,本質上是因為他們將此論述視為「挑釁」而非「防禦」。他們認為 DPP 的兩岸政策將台灣推向戰爭邊緣,而 KMT 的傳統論述又無法提供令人安心的和平保證。
#### 脈絡層
**結構性命題**:淺藍的「避戰」不是「投降主義」,而是「風險管理的務實主義」。
他們比深藍更清楚北京的威脅是真實的,但比深綠更不願意將戰爭風險作為主權博弈的籌碼。他們追求的是一種「可控的風險」:維持足夠的防衛能力以嚇阻北京,同時維持足夠的對話渠道以降低誤判。任何偏向極端(無條件投降或無限制備戰)的論述,都會遭到淺藍的拒斥。
#### 分析含義
分析時如何用這個事實?
- **評估避戰論述的市場穿透力**:避戰論述對淺藍具有極高的吸引力,但前提是該論述必須包含「尊嚴」的元素。單純的「和平」訴求若被解讀為「屈從」,將被淺藍拒絕。交叉引用 `narratives/cross_strait/avoid_war_frame.md`,分析何種版本的避戰論述能同時滿足淺藍的「安全」與「尊嚴」需求。
### 議題二:經濟務實(生存依賴)
#### 事實層
淺藍對兩岸經貿的依賴,不是「紅利」的問題,而是「生存」的問題。對本省藍與地方派系外圍而言,ECFA 與兩岸交流直接關乎其農漁產品銷售、旅遊業生計與中小企業訂單。對都會務實藍而言,兩岸關係惡化意味著科技業供應鏈的重組與不確定性。
他們對 DPP「新南向政策」的成效高度懷疑,認為這是脫離現實的意識形態操作。他們渴望恢復兩岸經貿的正常化,但又不願被北京以經濟脅迫政治。
#### 脈絡層
淺藍的經濟務實,使其成為北京「經濟脅迫」策略的易感人群,但也構成了其認知韌性。
當北京對台施加經濟壓力時,淺藍的第一反應是歸咎 DPP 政府無能,這符合北京的預期;但若北京的壓力過大,導致其生存基礎徹底崩潰,淺藍會轉而恐懼北京的掌控,從而產生避險心理。這使得北京的經濟脅迫存在一個「臨界點」:壓力過小無效,壓力過大反噬。
#### 分析含義
分析時如何用這個事實?
- **預測北京經濟脅迫的政治效應**:分析北京對特定產業(如農漁業、觀光業)的制裁時,必須區分其對淺藍的「懲罰效應」與「歸隊效應」。若制裁僅造成不便,淺藍會轉向支持 KMT;若制裁造成生存危機,淺藍可能因恐懼而暫時向 DPP 的紓困政策靠攏。
### 議題三:維持現狀(動態平衡)
#### 事實層
淺藍是「維持現狀」最堅定的擁護者,但他們定義的「現狀」是「中華民國在台灣的事實獨立運作,但不追求法理獨立,也不接受統一」。這是一種「動態平衡」:他們接受當前不統不獨的狀態,但要求這種狀態是穩定且可預測的。
他們對 DPP「正名制憲」的疑慮,與對 KMT「深化九二共識」的排斥,本質上是同一件事:他們恐懼任何改變現狀的嘗試,因為改變意味著風險。
#### 分析含義
分析時如何用這個事實?
- **評估 KMT 兩岸論述的調整空間**:KMT 若在 2028 提出超越「維持現狀」的論述(如推向統一的和平協議),將立刻流失淺藍。KMT 的最優策略是將自己定位為「現狀的守護者」,將 DPP 描繪為「現狀的破壞者」。
---
## 兩岸與國際態度:戒慎、疑美與務實
### 對北京的態度:戒慎但不敵視
淺藍對北京的態度是複雜的。他們不信任北京的政治意圖,但承認北京的經濟實力與國際影響力。他們認為與北京對話是必要的,但對話必須建立在對等尊嚴的基礎上。他們反對 DPP 的「抗中」,是因為認為這關閉了對話的大門;他們也反對深藍的「親中」,是因為認為這喪失了對等的籌碼。
### 對美方的態度:疑美論的溫床
淺藍是「疑美論」的溫和版支持者。他們不反美,但對美國的承諾存疑。他們擔心美國將台灣作為遏制中國的棋子,在必要時會犧牲台灣的利益。他們對美國軍售的延遲、對美國要求台灣增加國防預算的壓力,都抱持怨言。
這種「疑美」心理,使得淺藍對 DPP「親美抗中」路線的信任度偏低。他們認為台灣應在美中之間保持等距,而非一面倒向美國。
### 對日方的態度:無強烈好惡
相較於深藍的「反日」與深綠的「親日」,淺藍對日本無強烈的好惡。他們對日本的態度取決於具體議題(如核處理水排放、漁權爭議),而非歷史情感。
#### 分析含義
分析時如何用這個事實?
- **評估「親美」論述的邊際效應**:在淺藍群體中,強調「台美關係堅若磐石」的論述效果有限,甚至可能引發反效果。對淺藍更具吸引力的論述是「台灣能在美中之間靈活避險,確保自身利益極大化」。
---
## 信息行為與認知特徵:分散渠道與選擇性信任
### 信息渠道:相對分散,非單一壟斷
#### 事實層
淺藍的信息渠道相對分散,不像深藍高度集中於特定媒體(如中天系、深藍政論節目)。淺藍的主要信息來源包括:
1. **LINE 群組**:地方社團、農漁會、同學會的 LINE 群組,是本省藍與地方派系外圍的主要信息來源。這些群組的信息品質參差不齊,但信任度極高,因為來自「自己人」。
2. **YouTube 演算法**:都會務實藍傾向透過 YouTube 接收政治信息,演算法會根據其觀看歷史,推送「避戰」、「經濟務實」、「批判 DPP」的內容,形成溫和的回音室。
3. **傳統媒體**:TVBS、聯合報等傳統藍營媒體,仍是淺藍的參考坐標,但其影響力正在下降。
4. **地方電台**:中南部的地方廣播電台,對本省藍仍有一定影響力。
#### 脈絡層
**中等模型自己想不到的洞察**:淺藍的信息環境不是「同溫層」,而是「多孔的過濾氣泡」。
淺藍不像深藍那樣主動屏蔽不同意見,他們的社交網絡中常包含綠營支持者(特別是在中南部)。他們會接觸到 DPP 的論述,但會透過「利益受損」的個人經驗來過濾這些論述。當 DPP 說「兩岸關係穩定」時,本省藍會以「我的農產品賣不出去」來反駁;當 DPP 說「國際支持堅強」時,都會務實藍會以「美國軍售延遲」來質疑。這種基於生活經驗的過濾機制,使得傳統的「事實查核」對淺藍幾乎無效。
### 認知脆弱點:避戰焦慮與經濟剝奪感
淺藍最易受影響的認知脆弱點有二:
1. **避戰焦慮**:任何暗示「戰爭即將發生」的信息,都能迅速穿透淺藍的防禦。這使得「兵役延長」、「美國撤僑」等議題,對淺藍具有極高的動員力。
2. **經濟剝奪感**:任何證明「DPP 執政導致經濟變差」的具體案例(如物價上漲、農產品滯銷),都能強化淺藍的反綠情緒。
### 認知韌性:生活經驗與對極端的排斥
淺藍的認知韌性來自於:
1. **生活經驗的反制**:他們對政治的判斷高度依賴個人經驗。若 DPP 地方政府確實提供了良好的服務,他們不會因為中央政策的不滿而完全否定地方首長。
2. **對極端論述的排斥**:深藍的「紅統」論述、DPP 的「激進抗中」論述,都會觸發淺藍的排斥反應。他們對「常識」的堅持,構成了抵抗極端化的防線。
#### 分析含義
分析時如何用這個事實?
- **設計針對淺藍的論述滲透策略**:對淺藍的論述滲透,不能依賴權威媒體的背書,而必須透過其信任的社交網絡(LINE 群組、地方意見領袖)傳播,且內容必須與其生活經驗(經濟利益、安全焦慮)產生共鳴。抽象的意識形態論述對淺藍無效。
---
## 代表人物支持模式:朱、盧、蔣的淺藍投射
### 朱立倫:算計但安全的維持現狀者
#### 事實層
朱立倫在淺藍心中的形象是「算計但安全」。他們不喜歡朱立倫的權謀性格(如換柱風波),但認為朱立倫的兩岸與經濟政策是可預測的、符合維持現狀邏輯的。朱立倫的「親美反共」路線,雖然觸怒深藍,但在淺藍看來是務實的避險策略。
#### 分析含義
分析時如何用這個事實?
- **評估朱立倫路線的淺藍天花板**:朱立倫路線能穩住淺藍,但無法激發淺藍的熱情。在總統大選中,朱立倫型的候選人能守住基本盤,但缺乏擴張性。
### 盧秀燕:淺藍的最大公約數
#### 事實層
盧秀燕是目前淺藍認同度最高的政治人物之一。她的「媽媽市長」形象,完美契合了淺藍「避戰、顧家庭、務實」的偏好。她對兩岸議題的溫和立場,對深藍的保持距離,以及對地方利益的照顧,使她成為本省藍與都會務實藍都能接受的共主。
#### 脈絡層
盧秀燕的崛起,反映了淺藍對「強勢領袖」的疲乏與對「照顧者」的渴望。在歷經韓國瑜的激情與侯友宜的草莽後,淺藍渴望一個不惹事、能溝通、顧生活的領導人。盧秀燕的溫和,在深藍看來是軟弱,在淺藍看來是智慧。
#### 分析含義
分析時如何用這個事實?
- **預測 2028 KMT 的候選人策略**:若 KMT 在 2028 推出盧秀燕型的候選人,淺藍的歸隊率將顯著提高,且有能力吸引部分中間選民。但盧秀燕的挑戰在於如何通過深藍的初選考驗,以及如何將地方執政的溫和形象轉化為國家領導的格局。交叉引用 `entities/people/lu_shiow_yen.md`。
### 蔣萬安:都會務實藍的期待
#### 事實層
蔣萬安在淺藍(特別是都會務實藍)中具有高人氣。他的年輕、形象清新、法律專業背景,符合都會選民對「現代化 KMT」的期待。他的「蔣家後代」身份,在深藍是加分,在淺藍則是中性(只要他不強調意識形態)。
#### 脈絡層
蔣萬安的挑戰在於「本土化」的深度。淺藍期待他是一個「超越藍綠的現代化領導人」,而非「蔣家正統的繼承人」。若蔣萬安能成功將自己定位為「中華民國台灣化」的代表,他將成為都會務實藍的最愛;若他陷入深藍的意識形態框架,他將失去淺藍的青睞。
#### 分析含義
分析時如何用這個事實?
- **評估 KMT 世代交替的淺藍效應**:KMT 的世代交替(如推出蔣萬安),能有效降低淺藍對 KMT「老邁保守」的負面觀感,但前提是新一代必須在論述上與深藍切割。交叉引用 `entities/people/chiang_wan_an.md`。
---
## 近年變化趨勢:5-10 年的結構性演變
### 趨勢一:從「含淚投票」到「懲罰性棄保」
過去十年,淺藍的投票行為從「含淚投票」(不滿 KMT 但更怕 DPP)轉向「懲罰性棄保」(寧可讓 DPP 贏也要教訓 KMT)。這反映了淺藍對 KMT 路線的失望已累積至臨界點,他們不再接受「兩害相權取其輕」的邏輯,而是要求 KMT 必須提出符合其「避戰務實」偏好的候選人與論述。
### 趨勢二:與深藍的脫鉤
淺藍與深藍的議題排序已出現根本分歧。深藍將「反獨」與「親中」置於首位;淺藍將「避戰」與「經濟」置於首位。這種脫鉤使得 KMT 在總統大選中面臨兩難:向深藍靠攏會流失淺藍,向淺藍靠攏會激怒深藍。2024 年的棄保現象,正是此脫鉤的具體展現。
### 趨勢三:信息渠道的去中心化
淺藍的信息渠道從傳統媒體(TVBS、聯合報)轉向社群媒體(LINE、YouTube)。這使得 KMT 中央對淺藍的論述影響力下降,地方意見領袖與網紅的影響力上升。KMT 不再能透過控制媒體來引導淺藍的投票行為,這增加了選情預測的難度。
### 趨勢四:地方派系動員力的衰退
地方派系外圍的動員力,在總統大選中持續衰退。年輕一代的派系成員不再聽從樁腳指令,而是根據自身偏好投票。這使得 KMT 在中南部的地方組織優勢逐漸流失,本省藍的投票行為更加個人化。
#### 分析含義
分析時如何用這個事實?
- **預測 2028 的選情基準線**:基於上述趨勢,2028 年 KMT 若無法提出具備全國性吸引力且符合淺藍偏好的候選人,淺藍的懲罰性棄保將成為常態,KMT 的總統得票率將被死鎖在 30-35% 的深藍基本盤區間。
---
## 關聯分析
### 關聯一:與 KMT 選民基礎的連結(提供脈絡層)
- `entities/parties/kmt_voter_base.md`:KMT 的整體選民結構中,淺藍是外圍的浮動層。理解淺藍的行為邏輯,是解讀 KMT 為何在總統大選中屢屢「基本盤歸隊但擴張失敗」的關鍵。KMT 的路線搖擺,本質上是深藍與淺藍的拉扯。
### 關聯二:與 2024 三腳督棄保事件的連結(提供事實層)
- `events/key_events/event_2024_election_three_way.md`:2024 年的棄保現象,是淺藍「懲罰性棄保」的具體案例。分析此事件中的淺藍流向與動機,是預測 2028 藍白互動的基礎。
### 關聯三:與避戰論述的連結(提供分析框架)
- `narratives/cross_strait/avoid_war_frame.md`:避戰論述是淺藍最敏感的議題。分析避戰論述的不同版本(弱版/強版/極端版)如何差異性地影響淺藍,是設計針對淺藍論述的關鍵。
### 關聯四:與地方派系概念的連結(提供脈絡層)
- `primitives/concepts/local_factions_and_black_gold.md`:地方派系外圍是淺藍的重要組成。理解恩庇侍從網絡的運作邏輯,是預測本省藍與地方派系外圍在總統大選中動員力的基礎。
### 關聯五:與盧秀燕檔案的連結(用於對照)
- `entities/people/lu_shiow_yen.md`:盧秀燕是淺藍偏好的代表人物。分析盧秀燕的形象與政策,可以反推淺藍的核心訴求(避戰、務實、照顧)。
### 關聯六:與蔣萬安檔案的連結(用於對照)
- `entities/people/chiang_wan_an.md`:蔣萬安是都會務實藍的期待。分析蔣萬安的挑戰,可以理解淺藍對 KMT 世代交替的期望與底線。
---
## 常見的分析陷阱
### 陷阱一:將「淺藍」等同於「中間選民」
這是最常見的錯誤。淺藍有明確的「偏藍」政黨認同底色,他們對 KMT 有情感連結與期待,這與無政黨認同的中間選民完全不同。將淺藍視為中間選民,會誤判其歸隊的可能性與條件。
### 陷阱二:假設淺藍必然歸隊
淺藍的歸隊不是必然的,而是有條件的。當 KMT 的候選人或論述偏離淺藍的「避戰務實」偏好時,淺藍會選擇棄保或不投票。2024 年的經驗證明,「下架 DPP」已不足以成為淺藍歸隊的充分條件。
### 陷阱三:將「避戰」等同於「親中」或「統派」
淺藍的避戰是風險規避,不是政治認同。他們反對戰爭,但同時拒絕統一。將淺藍的避戰訴求解讀為親中,是嚴重的誤判,會導致論述攻擊的方向錯誤(攻擊其親中無效,應攻擊其無法真正避戰)。
### 陷阱四:忽略淺藍內部的異質性
本省藍、地方派系外圍、都會務實藍的行為邏輯差異極大。用單一模型預測淺藍行為,會掩蓋其內部的分歧。例如,北京的經濟脅迫對本省藍是切身之痛,對都會務實藍可能只是遙遠的新聞。
### 陷阱五:認為淺藍流向 TPP 是長期的政黨認同轉移
淺藍流向 TPP 是「懲罰性」的,而非「認同轉移」。他們對 TPP 的支持是工具性的,缺乏深層的理念基礎。一旦 KMT 提出符合期待的候選人,或 TPP 的表現不如預期,淺藍會迅速回流。
### 陷阱六:過度依賴傳統媒體效應評估
淺藍的信息渠道已高度去中心化,傳統媒體的影響力有限。僅透過傳統媒體的聲量來評估淺藍的態度,會嚴重滯後於真實的輿論動態。
---
## 仍不確定的部分
### 盧秀燕的全國性天花板
盧秀燕在淺藍中具有高支持度,但她能否將此支持度轉化為總統大選的選票,仍不確定。她的溫和形象在地方選舉是優勢,但在總統大選的國家安全議題檢驗下,可能被視為缺乏魄力。她能否通過深藍的初選考驗,也是未知數。
### 2028 藍白合的淺藍接受度
若 2028 再次出現藍白合,淺藍是否會接受?這取決於合作的條件與候選人的搭配。若 KMT 禮讓 TPP 的條件被視為「喪失尊嚴」,淺藍可能再次選擇不投票。若雙方能提出符合淺藍「避戰務實」偏好的聯合政見,淺藍的歸隊率將顯著提高。
### 北京經濟脅迫的效應遞減
北京對台的經濟脅迫,在短期內能動員淺藍的不滿,但長期是否會導致淺藍「脫敏」或「轉向」?若淺藍逐漸接受兩岸經貿脫鉤的現實,並找到替代市場,北京經濟脅迫的政治效應將遞減。此轉變的速度與程度,目前難以量化。
### 淺藍對 DPP 候選人的接受底線
若 2028 DPP 推出形象溫和、強調兩岸對話的候選人(如類似蔡英文的路線),淺藍是否可能跨陣營投票?這取決於 DPP 候選人能否有效回應淺藍的「避戰」焦慮,以及 KMT 候選人的缺陷程度。此情境目前缺乏歷史先例,難以精確預測。
---
## 與其他文件的關係
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照淺藍作為 KMT 外圍浮動層的結構性位置)
- 2024 三腳督棄保:`events/key_events/event_2024_election_three_way.md`(提供淺藍懲罰性棄保的具體數據與機制分析)
- 避戰論述:`narratives/cross_strait/avoid_war_frame.md`(提供避戰論述如何穿透淺藍認知的分析框架)
- 地方派系概念:`primitives/concepts/local_factions_and_black_gold.md`(解釋地方派系外圍作為淺藍次群體的利益邏輯)
- 盧秀燕檔案:`entities/people/lu_shiow_yen.md`(對照淺藍偏好候選人的形象特徵與政策立場)
- 蔣萬安檔案:`entities/people/chiang_wan_an.md`(對照都會務實藍對 KMT 世代交替的期待與底線)
---
## 一句話濃縮
> 淺藍是「避戰務實」的浮動聯盟,他們以維持現狀為底線,對北京戒慎但不敵視,對本土認同但拒斥激進;當 KMT 向深藍妥協時他們借 TPP 之殼懲罰性出走,當兩岸危機升高時他們被迫歸隊,其流向不是理念轉移而是風險計算,決定了台灣選舉的槓桿支點。
---
last_updated: 2024-10-25
@@ -1,285 +0,0 @@
# 淺綠受眾
---
**類型**: 政治敘事(受眾政治身份特化檔案)
**當前角色**: 台灣選民結構中的關鍵流動板塊——DPP 的天花板與第三勢力的吸納池
**何時調用**: 預測 2028 及後續全國性選舉中 DPP 的得票上限與流失率;分析社會自由派議題(同婚、居住正義)對選票的動員或反噬效應;評估第三勢力(特別是 TPP)對綠營選票的侵蝕模型;解讀「抗中保台」論述在無外部極端危機時的邊際效應遞減;規劃針對年輕高學歷選民的跨陣營爭奪策略
---
## 一句話濃縮
> 淺綠受眾是「主權底線不可退讓」與「進步價值必須兌現」的雙重綁定者;他們是 DPP 執政的防禦底線,卻也是 DPP 建制化的首要懲罰者——當外部危機消退,他們便從「含淚歸隊的防波堤」轉身為「流向第三勢力的破口」。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「淺綠」視為「深綠的弱化版」——預設他們只是對獨立的渴望不那麼強烈,或者對 DPP 的忠誠度較低。這種簡化會導致在預測選舉時嚴重誤判淺綠的流動方向與觸發條件。
淺綠受眾的本質不是「程度較輕的深綠」,而是「價值排序不同的另一種選民」。深綠的底層邏輯是「主權優先於一切」,淺綠的底層邏輯是「主權是前提,但價值與分配是日常」。他們與 DPP 的關係是「條件式結盟」:
1. **主權避險**:他們恐懼北京併吞,因此不可能轉向 KMT 的傳統路線。
2. **進步價值**:他們追求性別平權、居住正義與民主深化,對 DPP 的傳統派系政治與利益分贓感到厭惡。
3. **反建制情緒**:當 DPP 成為執政黨,他們會將對國民黨的批判標準同等套用於 DPP,成為最嚴厲的監督者。
理解淺綠受眾,就是理解 DPP 為何在 2020 年能拿 817 萬票,卻在 2024 年流失百萬選票——因為淺綠是「可流失選民」,他們的歸隊需要外部危機,他們的流失只需內政失望。
---
## 群體定義與邊界:15-20% 的關鍵流動板塊
### 事實層
淺綠受眾約佔台灣總選民的 15-20%。其識別標準不是「政黨認同」中的「民進黨」,而是「統獨立場」中的「維持現狀偏獨」與「政黨偏好」中的「支持但有所保留」的交集。
關鍵區分維度在於他們與深綠及中間選民的邊界:
- **與深綠的邊界**:深綠將「台灣主權獨立」視為終極目標,對中華民國體制抱持疏離;淺綠將「中華民國台灣」視為務實的現狀載體,不追求法理變更,只拒絕被北京統治。深綠對 DPP 是「無條件護航」,淺綠是「有條件支持」。
- **與中間選民的邊界**:中間選民在主權議題上具有較高的妥協空間(如經濟利益可換取政治讓步);淺綠在主權上沒有妥協空間,這是他們被歸類為「綠」的根本原因,但他們在內政議題上的行為模式與中間選民高度重合。
### 脈絡層
**結構性命題**:淺綠不是一個靜態的板塊,而是一個「光譜上的擺錘」。
淺綠內部存在「穩固淺綠」與「可流失淺綠」的動態分化。穩固淺綠(約佔淺綠群體的 60%)多為 40 歲以上的都會自由派,他們經歷過國民黨威權時期,對 KMT 有深層的不信任,即使對 DPP 失望也會含淚投票。可流失淺綠(約佔 40%)多為 20-39 歲的年輕世代,他們沒有威權記憶,對政黨的忠誠度極低,當 DPP 無法回應其分配正義訴求時,他們會毫不猶豫地流向 TPP 或選擇不投票。
| 次群體 | 佔比(佔淺綠) | 核心特徵 | 投票行為 | 流動方向 |
|---|---|---|---|---|
| 穩固淺綠(英系/社會自由派) | ~60% | 價值導向、厭惡KMT、支持DPP但批判其保守面 | 含淚歸隊、懲罰性不投票 | 極少流向藍營,可能轉投綠色小黨 |
| 可流失淺綠(年輕/高教/理工) | ~40% | 利益與反建制導向、主權是常識非信仰、厭惡綠營建制 | 流向第三勢力、不投票 | 大量流向 TPP,部分經濟選民可能消極 |
#### 分析含義
分析時如何用這個事實?
- **計算 DPP 的流失率**:在無極端外部危機的選舉中,DPP 的得票率波動主要來自「可流失淺綠」的轉向。2024 年賴清德得票率 40%,較 2020 蔡英文 57% 大跌,扣除深綠與基層本土派的鐵票(約 30-35%),流失的百萬票絕大多數來自此群體。
- **預測 2028 的棄保效應**:若 2028 出現強勢的第三勢力候選人,穩固淺綠會因恐懼 KMT 當選而歸隊 DPP,但可流失淺綠會因厭惡 DPP 建制而拒絕棄保,這是 DPP 在選情膠著時無法有效操作「棄保」的結構性原因。
---
## 信息行為與認知特徵:高密度信息環境下的懷疑論者
### 事實層
淺綠受眾的信息行為呈現「高頻接觸、低度信任、同溫層穿透」的特徵:
- **主要接觸渠道**:社群平台(Threads、Instagram、X/Twitter)、即時通訊軟體(LINE 群組)、網路原生媒體與自媒體(如百靈果、志祺七七)。對傳統電視新聞(特別是政論節目)的信任度極低。
- **平台偏好**:年長穩固淺綠仍使用 Facebook;年輕可流失淺綠高度依賴 Threads 與 IG,這些平台的演算法極易形成進步價值的同溫層,但也極易被反建制情緒穿透。
- **信任來源**:公民團體(如地球公民基金會、伴侶盟)、獨立記者、具專業背景的意見領袖(如法律、醫療、科技領域的 KOL)。對政黨發言人的信任度極低。
- **內容偏好**:政策深度解析、國際局勢分析、揭發政商利益結構、性別與人權議題。對傳統「藍綠惡鬥」的口水戰極度厭倦。
### 脈絡層
**中等模型自己想不到的洞察**:淺綠受眾的「信息繭房」不是封閉的,而是「半透膜」式的。
他們確實生活在進步價值的同溫層中,但這個同溫層對「反建制」與「分配正義」的訊息具有極高的穿透性。這意味著,來自左翼或第三勢力對 DPP 的批判(如指控 DPP 國土規劃圖利財團、綠能政策養出綠友友),能輕易穿透淺綠的防線,因為這符合他們對 DPP 建制化的預設。相反地,來自深藍或紅統陣營對 DPP 的攻擊(如指控 DPP 貪腐賣台),則會被主權防禦機制完全彈開。
#### 分析含義
分析時如何用這個事實?
- **評估認知戰的著彈點**:對淺綠受眾進行認知作戰,最有效的切入點不是「主權無用」,而是「DPP 背叛了進步價值」與「DPP 與財團利益掛鉤」。這類訊息能引發淺綠的「背叛感」,從而壓低其投票意願。
- **預測輿論風向的轉折**:當 Threads 等平台上開始出現大量對 DPP 內政表現的嘲諷(而非來自藍營的攻擊),這是淺綠受眾正在流失的早期預警指標。
---
## 認知脆弱點與韌性:主權盾牌與分配軟肋
### 認知脆弱點(易受影響的敘事)
1. **「綠共」與「民主倒退」敘事**:淺綠最恐懼的是威權復辟。當 DPP 執政展現出權力傲慢(如側翼網軍攻擊異見者、司法追殺在野黨、對待抗爭者的粗暴手段),淺綠極易接受「DPP 正在變成它當年反對的對象」的敘事,從而產生強烈的道德背離感。
2. **「分配不公」與「居住正義」敘事**:高房價與低薪是淺綠(特別是年輕可流失群體)的生存焦慮。任何將 DPP 執政與「高房價幫兇」連結的論述,都能直接刺穿其防線。2024 年柯文哲成功吸納年輕淺綠,靠的不是兩岸論述,而是「居住正義」的承諾。
3. **「抗中保台」的邊際遞減敘事**:在無外部極端危機時,淺綠極易接受「抗中保台只是 DPP 選舉提款機」的敘事。他們開始區分「真實的安全威脅」與「執政黨的政治操作」,對後者產生強烈反感。
### 認知韌性(抵抗影響的機制)
1. **主權底線的絕對防禦**:面對北京實質的軍事威懾或統戰滲透,淺綠的主權共識會瞬間被激活。任何「疑美論」或「兩岸和平統一」的論述,在危機時刻會被淺綠完全排斥。這是他們與 DPP 之間最堅固的臍帶。
2. **社會自由派的價值護城河**:KMT 在性別平權、轉型正義等議題上的保守立場,構成了淺綠拒絕轉向藍營的天然護城河。即使淺綠對 DPP 再失望,他們也無法接受 KMT 的傳統價值觀。
3. **高教育程度的信息解讀能力**:淺綠普遍具備查證與交叉比對信息的能力,對低品質的假訊息(如粗糙的深偽影片、毫無邏輯的陰謀論)具有較高免疫力。
#### 分析含義
分析時如何用這個事實?
- **設計選戰論述**:要動員淺綠,必須同時提供「主權威脅的真實感」(激活韌性)與「內政改革的願景」(修復脆弱點)。單一維度的論述必然失效。
- **交叉引用**:淺綠對性別議題的堅持,詳見 `narratives/domestic/lgbtq_post_2019.md`,這是阻止其流向 KMT 的關鍵防線。
---
## 政治傾向與投票模式:從「含淚歸隊」到「懲罰性流失」
### 事實層
淺綠受眾在近十年的歷次選舉中,展現了極高的波動性,其投票模式是解讀台灣選舉結果的關鍵變數:
- **2014-2016**:太陽花學運後,淺綠(特別是年輕世代)與 DPP 高度重合,將 DPP 視為實現進步價值的載體,創造了綠營的空前大勝。
- **2018**:DPP 執政推動同婚引發傳統基層反彈,同時一例一休與產銷失衡損害利益。淺綠對 DPP 的「進步不足」與「分配不公」雙重失望,大量選擇不投票,導致 DPP 慘敗。
- **2020**:香港反送中事件觸發主權危機,淺綠的「主權韌性」被極大化激活,基於恐懼與避險全面歸隊,創造 817 萬票的極端值。
- **2022**:無外部危機,疫情與黑金爭議讓淺綠再次對 DPP 厭惡,年輕淺綠大量流向民眾黨或拒絕投票,DPP 地方選舉大敗。
- **2024**:常態化回歸。無極端外部危機下,年輕可流失淺綠大量流向柯文哲(TPP),賴清德僅獲 40% 選票。這證實了淺綠的「條件式支持」本質。
### 脈絡層
**破除常見簡化**:把 2024 年淺綠流向 TPP 解讀為「年輕人變藍」或「年輕人不再抗中」是嚴重的分析錯誤。
年輕淺綠流向 TPP,本質上是「反建制情緒」壓倒了「主權恐懼」。在無戰爭立即危險的認知下,他們將選票投給了最能代表「打破藍綠利益結構」的象徵(柯文哲),而非認同 TPP 的兩岸路線。這是一種「懲罰性流動」,而非「認同轉移」。一旦北京施加極端壓力,這群人的主權防禦機制會再次啟動,TPP 的吸納效應將迅速瓦解。
#### 分析含義
分析時如何用這個事實?
- **修正「政黨輪替」的預期**:淺綠的流動通常只導致 DPP 得票率下降,極少導致 KMT 漁翁得利。因為淺綠流向 TPP 是為了懲罰 DPP,而非支持 KMT 執政。在總統大選的贏者全拿制下,淺綠的分裂往往只會讓 DPP 低空掠過,除非 KMT 能同時吸納 TPP 的選票。
- **交叉引用**:年輕淺綠的流失機制,詳見 `narratives/domestic/youth_political_alienation.md`。
---
## 核心議題敏感點:主權是前提,分配是日常
### 事實層
淺綠受眾的議題敏感度呈現明顯的「階層式」結構:
1. **底線議題(主權與國家安全)**:這是淺綠之所以為「綠」的根本。反對一國兩制、反對九二共識、反對北京武力脅迫。此議題在危機時具有「一票否決權」。
2. **核心議題(社會自由與人權)**:性別平權(同婚、伴侶權益)、轉型正義、言論自由。這是淺綠區別於基層本土派的標誌。DPP 若在此退縮,會立刻失去淺綠的道德認可。
3. **動員議題(分配正義與居住正義)**:高房價、低薪、勞動權益、氣候正義。這是當前淺綠(特別是年輕群體)最迫切的生存焦慮,也是 2024 年 DPP 流失選票的關鍵破口。
### 脈絡層
**結構性命題**:淺綠的議題結構存在內在的「優先序衝突」。
在承平時期,核心議題與動員議題的權重高於底線議題,淺綠會因為 DPP 在居住正義上的無能而懲罰 DPP。但在危機時期,底線議題會瞬間壓倒一切,淺綠會因為 DPP 守住主權而原諒其內政缺失。這種「危機依賴型」的投票邏輯,使得 DPP 在選戰後期必然試圖將選戰主軸拉回主權,但若操作過於生硬(如將所有內政批評都打成中共同路人),反而會觸發淺綠的「反彈效應」,認為 DPP 在消費主權。
#### 分析含義
分析時如何用這個事實?
- **評估 DPP 的「抗中牌」效價**:在 2028 選戰中,若北京未採取極端行動,DPP 的「抗中保台」對淺綠將不再是動員令,而是「狼來了」的厭倦源。DPP 必須在分配正義上交出成績,才能穩住淺綠的基本盤。
---
## 兩岸與國際議題態度:現狀偏移的務實者
### 事實層
- **統獨立場**:堅定支持「維持現狀偏獨」。他們不追求法理台獨(認為太危險且無必要),但絕不接受統一。他們對「中華民國台灣」的符號有務實的接受度,認為這是當前最大公約數。
- **對北京態度**:高度反感北京政權的威權本質與對台打壓,但對中國人民不抱持本能敵意。支持兩岸在對等尊嚴下進行文化交流與民間對話,但反對以主權讓步換取經濟利益。
- **對 ECFA 與經貿依賴**:支持降低對中經貿依賴(呼應現狀偏移論述),認為這是國家安全問題。但對於台商與傳統產業的陣痛期,抱持同情但認為必須轉型的態度。
- **對美方態度**:將美國視為安全保障的必要夥伴,支持對美軍購與實質關係提升。但對美國的內政混亂(如國會暴亂、川普現象)抱持疑慮,不願將台灣安全完全寄託於美國的單一承諾。
- **對日方態度**:具高度親日傾向,對日本文化與民主體制有強烈認同,支持台日安保對話。
### 脈絡層
**破除常見簡化**:將淺綠的「親美」等同於「盲目親美」是錯誤的。
淺綠的親美是「避險工具主義」。他們支持與美國結盟,是因為這是當前抵抗北京壓迫的最優解,而非出於對美國霸權的崇拜。當美國展現出不可靠的跡象(如從阿富汗撤軍的混亂、對烏克蘭援助的黨派僵局),淺綠內部會迅速產生「疑美」的焦慮,但這種焦慮不會轉化為「反美」,而是轉化為「台灣必須更自主防衛」的訴求。
#### 分析含義
分析時如何用這個事實?
- **預測「疑美論」的穿透力**:北京或藍營操作的「疑美論」(如美國棄台論),對深藍有效,對淺綠無效。但基於美國內政不穩定而產生的「自主防衛論」,在淺綠中極具市場。DPP 若只以「親美」回應,會被淺綠視為缺乏主體性。
- **交叉引用**:淺綠對兩岸現狀的認知演變,詳見 `narratives/cross_strait/status_quo_drift.md`。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 趨勢一:從「天然獨」到「天然反」的質變
年輕淺綠的本土認同不再是需要被動員的信仰,而是如同呼吸般的常識。這意味著「主權議題」對他們的動員力大幅下降。他們的政治能量已轉移至「反對不公義」的日常鬥爭。當 DPP 成為執政的建制派,且表現出不公義的特徵時,他們的「天然反」會毫不猶豫地指向 DPP。
### 趨勢二:英系光環的消退與典範的真空
蔡英文成功將 DPP 的形象從「街頭抗爭的本土政黨」重塑為「理性務實的國際化政黨」,這完美契合了穩固淺綠的期待。但隨著蔡英文卸任,賴清德的傳統形象(深綠、建國信仰、較缺乏都會自由派氣質)難以填補此真空。淺綠對賴清德的支持是「防禦性」的,缺乏對蔡英文那種「認同性」的熱情。
### 趨勢三:性別與階級的交叉分化
淺綠內部的性別分化日益加劇。年輕女性因為 KMT 的保守價值與北京對女權的壓迫,更傾向作為「穩固淺綠」死守 DPP;年輕男性則因為兵役延長的實體負擔與高房價的經濟壓力,更傾向作為「可流失淺綠」流向 TPP。這種分化使得單一的選戰論述難以同時固守淺綠板塊。
#### 分析含義
分析時如何用這個事實?
- **評估 2028 DPP 的候選人策略**:DPP 若推出具強烈深綠色彩的候選人,將難以動員穩固淺綠;若推出具進步價值形象但主權立場模糊的候選人,又可能讓深綠拒投。DPP 必須找到能同時繼承「英系國際路線」與「分配正義」的論述,才能縫合淺綠的裂痕。
---
## 關聯分析
### 關聯一:與 DPP 選民基礎的連結(提供脈絡層)
- `entities/parties/dpp_voter_base.md`:淺綠是 DPP 選民基礎中的「都會自由派」主體。理解淺綠的流動,就是理解 DPP 得票率天花板為何在 40% 與 57% 之間劇烈擺盪。淺綠的流失直接導致 DPP 從絕對多數退化為相對多數。
### 關聯二:與 TPP 選民基礎的連結(用於交叉驗證)
- `entities/parties/tpp_voter_base.md`:TPP 的年輕高學歷男性選民,有極高比例來自「可流失淺綠」。分析 2024 年柯文哲的得票結構,實質上是在分析淺綠的流出量。2028 年這群體是否回流 DPP,取決於 DPP 的內政表現與 TPP 的存續狀態。
### 關聯三:與現狀偏移論述的連結(提供事實層)
- `narratives/cross_strait/status_quo_drift.md`:淺綠是「現狀偏移」論述的核心受眾。他們支持降低對中依賴,不是出於意識形態的仇中,而是出於國家安全的避險邏輯。此論述的有效性直接決定淺綠對 DPP 經貿路線的支持度。
### 關聯四:與性別議題論述的連結(提供脈絡層)
- `narratives/domestic/lgbtq_post_2019.md`:同婚合法化是淺綠與 DPP 之間最強的價值臍帶之一。即使淺綠對 DPP 內政不滿,KMT 對性別平權的保守態度仍是阻止淺綠流向藍營的絕緣體。此論述的演變(如跨國同婚、人工生殖法)持續牽動淺綠的投票意向。
### 關聯五:與年輕疏離論述的連結(提供分析框架)
- `narratives/domestic/youth_political_alienation.md`:解釋了「可流失淺綠」為何流向 TPP。年輕疏離的本質是反建制,當 DPP 成為建制,淺綠年輕人便將選票作為懲罰工具。理解此機制,才能預測 2028 年輕選票的歸屬。
### 關聯六:與北京反應類型學的連結(提供脈絡層)
- `primitives/frameworks/beijing_response_typology.md`:北京的低強度施壓(如經濟脅迫、認知戰)會強化淺綠的「現狀偏移」共識;但北京的高強度軍事威懾,則會強制激活淺綠的主權恐懼,使其從 TPP 流回 DPP。北京的施壓模式直接決定淺綠的流動方向。
---
## 常見的分析陷阱
### 陷阱一:將「淺綠」視為「深綠的弱化版」
淺綠不是深綠的稀釋,而是價值排序不同的群體。深綠的主權是信仰,淺綠的主權是底線;深綠可以為主權犧牲經濟,淺綠要求主權與分配兼顧。用深綠的邏輯預測淺綠的行為,會嚴重高估淺綠對 DPP 的忠誠度。
### 陷阱二:假設「抗中保台」對淺綠永遠有效
「抗中保台」對淺綠的有效性是「情境依賴」的。在無外部危機時,過度操作此論述會被淺綠視為掩蓋內政無能的遮羞布,產生反效果。2024 年的選舉結果已證實,常態化時期的抗中牌對年輕淺綠幾乎失效。
### 陷阱三:將流向 TPP 的淺綠視為「變藍」
年輕淺綠流向 TPP,是為了懲罰 DPP 的建制化與無能,而非認同 KMT 的價值。這群人對 KMT 的厭惡依然根深蒂固。若 2028 出現藍白合,這群人極可能因為拒絕 KMT 而轉回支持 DPP 或不投票,而非支持藍白陣營。
### 陷阱四:忽略淺綠內部的性別分化
將淺綠視為單一群體,會忽略年輕女性(穩固淺綠)與年輕男性(可流失淺綠)在風險偏好與議題關注上的巨大差異。對年輕男性談「戰爭風險」可能觸發逃避心理,對年輕女性談「戰爭風險」則能激發守護意志。同一套論述對兩者的效果完全相反。
### 陷阱五:認為淺綠對 DPP 的批判意味著他們支持在野黨
淺綠對 DPP 的批判是最嚴厲的,但這種批判基於「愛之深責之切」或「恨鐵不成鋼」。他們罵 DPP,是為了逼迫 DPP 變得更好(更符合進步價值),而不是為了推翻 DPP。將淺綠的批判言論解讀為在野黨的支持度上升,是嚴重的誤讀。
### 陷阱六:過度解讀 2020 年的淺綠版圖
2020 年的淺綠版圖是極端值。香港反送中將大量中間選民與淺綠強制縫合在 DPP 旗下。任何基於 2020 年數據所做的淺綠群體分析,都必須扣除「危機溢出效應」,才能看到淺綠在常態下的真實規模(約 15-20%,而非 2020 年展現的 30%+)。
---
## 仍不確定的部分
### 賴清德政府能否修復與「可流失淺綠」的關係
賴清德的上位,象徵著 DPP 內部英系路線的退潮與傳統本土路線的回歸。賴政府能否在居住正義與分配政策上提出讓年輕淺綠滿意的方案,目前仍不確定。若賴政府僅依賴「主權防禦」而無法解決高房價等生存焦慮,2028 年可流失淺綠的流失將從「懲罰性」轉為「永久性」。
### TPP 泡沫化後的選票歸屬
若柯文哲司法案件導致 TPP 結構性崩解,其吸納的年輕淺綠選票會流向何方?是回流 DPP(基於進步價值與主權底線),還是轉向其他新興第三勢力,或是徹底政治冷感?這群「反建制淺綠」的行為模式缺乏歷史先例,其對政黨的信任度已極低,是否還會被傳統政黨收編,目前無法確定。
### 北京「精準認知戰」對淺綠的穿透效果
北京是否會調整對台認知戰策略,從粗糙的「疑美論」轉向針對淺綠痛點的「精準打擊」(如放大 DPP 的政商利益結構、炒作綠能弊案、操作分配正義議題)?若北京成功模仿台灣內部的進步批判語境,淺綠的「半透膜」防線是否會被突破,目前仍有待觀察。
### 「穩固淺綠」的世代交替
目前的穩固淺綠多為 40 歲以上、經歷過威權時期的選民。當這群人逐漸老化,而新加入的年輕選民普遍帶有「天然反」特質時,淺綠群體中「穩固」與「可流失」的比例是否會發生結構性反轉?若反轉發生,DPP 的選舉模式將面臨根本性的重構。
---
## 與其他文件的關係
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照淺綠作為 DPP 都會自由派主體的結構性位置)
- TPP 選民基礎:`entities/parties/tpp_voter_base.md`(對照年輕淺綠流向 TPP 的機制與重疊性)
- 現狀偏移論述:`narratives/cross_strait/status_quo_drift.md`(提供淺綠支持降低對中依賴的認知基礎)
- 性別議題論述:`narratives/domestic/lgbtq_post_2019.md`(解釋性別平權作為淺綠與 DPP 價值臍帶的功能)
- 年輕疏離論述:`narratives/domestic/youth_political_alienation.md`(解釋可流失淺綠反建制情緒的根源)
---
## 一句話濃縮
> 淺綠受眾是主權底線與進步價值的雙重綁定者,他們在危機時是 DPP 最堅實的防波堤,在承平時卻是 DPP 建制化最嚴厲的懲罰者;預測淺綠的流動,就是預測台灣選舉中「恐懼動員」與「分配正義」兩股力量的對決結果。
---
last_updated: 2024-10-26
@@ -1,379 +0,0 @@
# 政治認同光譜總覽 [TW_POLITICAL_IDENTITY_SPECTRUM]
---
**類型**: 分析框架(解釋與預測工具)
**當前角色**: 台灣政治認同結構拆解與選民流動預測的多維模型
**何時調用此檔案**: 當需要判斷特定事件或政策對整體選民板塊的位移效應、分析跨黨派棄保流動的結構條件、評估2028大選各陣營的票倉天花板與滲透可能、或需要將單一民調數字還原為深層認同結構時
---
## 一句話濃縮
> 台灣七大政治認同類型不是靜態的抽樣切餅,而是受「統獨降溫與分配升溫」驅動的動態光譜;白營的崛起打破了淺藍與淺綠的流動壁壘,使2028成為多極博弈與跨陣營棄保的複雜系統——外部威脅的閾值決定了光譜是橫向流動還是縱向固化。
---
## 為何此檔案對分析者重要
許多分析將台灣選民結構簡化為「藍綠基本盤加中間選民」,這種一維簡化會導致三個嚴重錯誤:
1. **誤判白營的實質基礎**:把白營視為「淺藍與淺綠之間的溫和中間派」,忽略其實質是「淺藍與淺綠在經濟分配(Y軸)上的跨認同匯流」,從而低估其在Y軸主導時的擴張力,也誤判其在X軸(統獨)升溫時的脆弱性。
2. **誤判淺色陣營的忠誠度**:把「淺藍/淺綠」視為「深藍/深綠的弱化版」,忽略淺色陣營是「跨維度投票者」(經濟與形象權重高於認同),而深色陣營是「單維度投票者」(認同絕對優先)。這會導致無法解釋2024年淺綠大規模流向白營的現象。
3. **誤判冷感族群的政治效應**:把「冷感」視為「無意見」,忽略冷感是「系統性剝奪感的沉默表達」。他們的不投票,實質上改變了選舉的年齡與階級結構權重,對依賴高投票率的傳統動員模式(尤其是深藍與深綠)構成結構性稀釋。
本檔案提供七大類型的動態座標系,讓分析者能夠精確識別特定事件會觸發哪個類型的流動,以及流動的終點是另一個類型還是直接退出投票體系。
---
## 框架的目的:解釋流動,而非貼上標籤
這個框架不是為了把每一位選民塞進七個格子裡——多數選民的認同具有情境依賴性。這是一個**解釋與預測工具**:
> 給定當前的兩岸張力、經濟景氣與候選人特質組合,七大類型之間的流動閥門會如何開啟或關閉?這又如何轉化為最終的政黨得票率與政治版圖?
台灣的認同光譜具有高度的結構性,但被「藍綠對決」的表層敘事遮蔽。此框架旨在拆解表層,暴露出決定選舉結果的內部流動機制。當你看到一個民調顯示某黨支持度下降時,這個框架幫助你回答:這是深色陣營的背離(極難逆轉),還是淺色陣營的游離(高度可逆轉)?流失的選票是流向了競爭對手(橫向流動),還是流向了冷感(縱向退出)?
---
## 框架的核心結構:七大政治認同類型
七大類型不是憑空發明,而是基於「國家認同(X軸)× 經濟分配(Y軸)× 政治參與度」三個維度的聚類結果。必須牢記:**類型之間的邊界是滲透的,類型內部是異質的。**
### 類型一:深藍(Deep Blue)
詳見 `audiences/political_identity/deep_blue.md`。
#### 事實層
- **人口比例估算**:10% - 12%
- **核心特徵**:堅定的大中華認同或中華民國憲法堅守者;高度反對台獨;將北京視為「可對話的同胞」而非首要敵人;對威權時期有較高懷舊或合理化傾向。
- **資訊渠道**:重度依賴傳統媒體(TVBS、中天網路台)、特定社群媒體同溫層(Line群組)、軍公教退休網絡。
- **議題敏感點**:任何觸及「去中國化」或「抹殺中華民國」的符號議題;美國對台軍售的「凱子軍火」質疑;對北京政策的「挑釁」解讀。
#### 脈絡層
深藍的結構性位置是「被時代列車拋下的前統治階層」。其內在矛盾在於:**對中華民國的堅守與北京的一中原則存在不可調和的落差**。北京將深藍視為「可統戰對象」而非「平等的民國擁護者」,但深藍在情感上難以接受此落差,只能選擇性地忽視北京的壓迫,將矛頭轉向DPP的「挑釁」。
深藍是KMT的鐵票底盤,但也是KMT路線調整的最大絆腳石。他們的投票率極高,但在人口結構上正以每年約1.5個百分點的速度自然凋零。
#### 分析含義
- **分析時如何用**:深藍是KMT的「下限保證」與「天花板詛咒」。計算KMT基本盤時,深藍是唯一不需要動員就會投票的群體。但當KMT試圖向中間移動時,深藍的反彈(如拒投或含淚不投票)會直接侵蝕基本盤。2024年侯友宜初期的整合困難,就是深藍不信任的體現。
- **識別特徵**:在民調中,對「維持現狀」的定義偏向「現狀偏統」或「終極統一」,且對北京軍演的歸因高度傾向「民進黨政府挑釁」。
---
### 類型二:淺藍(Light Blue)
詳見 `audiences/political_identity/light_blue.md`。
#### 事實層
- **人口比例估算**:12% - 15%
- **核心特徵**:維持現狀偏統或經濟務實主義者;對台獨無好感但也不急於統一;重視兩岸和平與經濟紅利;對KMT的保守價值未必認同,但更恐懼DPP的執政風險。
- **資訊渠道**:混合型,傳統媒體與網路媒體並重;對財經新聞關注度高;社群媒體同溫層不如深藍緊密。
- **議題敏感點**:兩岸戰爭風險(避戰優先於抗中);經濟景氣與房市股市表現;執政黨的貪腐或黑金爭議。
#### 脈絡層
**破除常見簡化**:把淺藍視為「深藍的弱化版」是嚴重錯誤。淺藍是「跨維度投票者」,其認同基底雖偏藍,但決定其投票行為的往往是Y軸(經濟利益)與形象(治理能力)。
淺藍的內在矛盾是**「經濟利益依賴兩岸和平,但生活身分已高度本土化」**。他們不認為自己是中國人,但也不支持激進的本土化去中運動。他們是KMT的游離票,也是TPP極力挖角的對象。2024年柯文哲能獲得可觀的淺藍選票,正是因為柯在Y軸上提供了比KMT更強的「治理能力」想像,同時在X軸上提供了「不挑釁」的安全感。
#### 分析含義
- **分析時如何用**:淺藍是2028大選的關鍵搖擺力量。當KMT候選人弱勢時,淺藍極易流向TPP(棄保效應的發動機)。預測藍白合或藍白分裂的關鍵,在於淺藍對KMT候選人「勝選能力」的評估。若評估為不可勝,流向TPP的趨勢將不可逆。
- **識別特徵**:在民調中,自認為「中間選民」但政黨偏好偏藍;對北京軍演的歸因較為分散(各打五十大板);對居住正義與分配議題有高度共鳴。
---
### 類型三:深綠(Deep Green)
詳見 `audiences/political_identity/deep_green.md`。
#### 事實層
- **人口比例估算**:12% - 15%
- **核心特徵**:堅定的台灣主體意識與本土認同;高度防範北京併吞;支持轉型正義與去威權化;對KMT及其附隨組織有深層不信任甚至仇恨;將DPP視為本土政權的唯一守護者(即使有批評)。
- **資訊渠道**:三立、民視等本土媒體;綠營名嘴與側翼網路頻道;公民團體與獨派社群網絡。
- **議題敏感點**:任何涉及「出賣台灣主權」的行為(如密使、接受一中原則);對威權遺緒的眷戀或合理化;北京對台的任何施壓(會激發強烈的同仇敵愾)。
#### 脈絡層
深綠的結構性位置是「本土防線的守門員」。其內在矛盾在於**「理念純潔性與執政務實性的衝突」**。深綠期望DPP在法理台獨與轉型正義上大步邁進,但DPP執政後受制於國際現實與中間選民,只能維持現狀並在分配議題上向右傾斜。這導致深綠經常處於「含淚投票」的狀態——他們對DPP的右傾與腐化極度不滿,但在X軸(統獨)的生存壓力下,絕不可能將票投給藍或白。
深綠是DPP的「理念發動機」與「約束者」,但在選舉動員時,他們也是最容易被「抗中保台」召喚歸隊的群體。
#### 分析含義
- **分析時如何用**:深綠是DPP的防漏油底盤。當DPP在Y軸(經濟分配)上流失淺綠時,必須靠X軸(主權危機)來鞏固深綠,防止基本盤崩潰。分析DPP內部路線鬥爭時,深綠的施壓力道是關鍵變數;但分析大選結果時,深綠的投票行為高度可預測(除非出現另一個更純粹的本土政黨,如早期的時代力量,但目前已邊緣化)。
- **識別特徵**:在民調中,對「維持現狀」的定義偏向「現狀偏獨」或「未來獨立」;對北京軍演的歸因100%指向北京擴張主義;對KMT的任何兩岸主張均持極度懷疑態度。
---
### 類型四:淺綠(Light Green)
詳見 `audiences/political_identity/light_green.md`。
#### 事實層
- **人口比例估算**:15% - 18%
- **核心特徵**:維持現狀偏獨;重視分配正義、社會自由與人權;對DPP有條件支持——認同其本土立場,但強烈不滿其執政後的分配右傾與黑金爭議;太陽花世代的骨幹。
- **資訊渠道**:網路原生媒體(如報導者)、獨立記者、Podcast;對傳統電視政論節目反感;高度依賴社群媒體獲取資訊但具備一定批判性。
- **議題敏感點**:居住正義與高房價(最痛點);勞工權益與財團免稅;執政黨的誠信與黑金掛鉤;社會進步議題(性平、原民、移工)的倒退。
#### 脈絡層
**中等模型自己想不到的洞察**:淺綠是當代台灣政治「認同與利益撕裂」的最典型載體。
淺綠在X軸上與深綠高度一致(抗中保台),但在Y軸上與DPP的執政策略嚴重對立(反財團、要分配)。這種撕裂在2024年達到高峰:他們在X軸上恐懼KMT上台,但在Y軸上無法再忍受DPP的右傾。TPP的成功,本質上是吸納了淺綠在Y軸上的憤怒。淺綠流向白營,不是因為他們認同變藍或變白,而是他們在Y軸上「懲罰」DPP。
淺綠的內在矛盾是**「主權生存焦慮與經濟生存焦慮的競爭」**。當北京施壓加劇,X軸焦慮會壓過Y軸焦慮,淺綠會含淚回流DPP;當兩關係平穩,Y軸焦慮主導,淺綠會毫不猶豫地拋棄DPP。
#### 分析含義
- **分析時如何用**:淺綠是DPP能否在2028順利連任的關鍵變數。賴清德若無法在Y軸(特別是居住正義)提出可信的政策,淺綠將再次流向TPP或選擇不投票。分析時,絕對不能把淺綠的流失解讀為「本土認同的衰退」,這純粹是「分配正義的懲罰」。
- **識別特徵**:在民調中,自認為「中間選民」但政黨偏好偏綠;支持抗中但反對軍購預算排擠社福;對「抗中保台」論述感到疲乏,認為是執政無能的遮羞布。
---
### 類型五:白營(White Camp)
詳見 `audiences/political_identity/white_camp_audience.md`。
#### 事實層
- **人口比例估算**:15% - 20%(含核心支持者與邊緣游離者)
- **核心特徵**:反建制、經濟分配優先(Y軸偏左)、統獨避險(X軸中間);強烈厭惡藍綠惡鬥的修辭;將柯文哲或TPP視為打破藍綠壟斷的象徵;高度依賴網路意見領袖(KOL)與社群迷因。
- **資訊渠道**:YouTube頻道(如館長、各類時事KOL)、PTT、Dcard、TikTok;對傳統新聞媒體極度不信任;資訊來源碎片化與情緒化。
- **議題敏感點**:藍綠的「雙標」與偽善;任何涉及「分贓」或「密室協商」的證據;高房價與低薪的切身之痛;兵役延長與戰爭恐懼(但解方傾向避戰而非抗中)。
#### 脈絡層
**破除常見簡化**:把白營視為「藍綠光譜的中間點」是嚴重錯誤。白營不是溫和的中間派,而是「淺藍與淺綠在Y軸上的跨認同匯流」。
白營打破了傳統的流動壁壘。過去,淺藍流向淺綠或反之極為困難(因X軸對立),但TPP提供了一個X軸模糊(兩岸一家親/五個互相)、Y軸激進(居住正義/反貧富差距)的替代選項。這讓淺藍可以在不背叛統獨傾向的情況下,用Y軸理由支持TPP;也讓淺綠可以在不背叛本土認同的情況下,用Y軸理由懲罰DPP。
白營的內在矛盾是**「X軸的迴避與Y軸的激進」**。在平時,這是優勢(吸納跨認同的分配選民);在危機時,這是致命傷(當統獨升溫,X軸的模糊會被解讀為「中共同路人」或「不負責任」,導致淺綠回流DPP,淺藍回流KMT)。
#### 分析含義
- **分析時如何用**:分析白營時,必須區分其內部的淺藍底與淺綠底。當TPP在X軸上向北京靠攏時,會流失淺綠底;當TPP在Y軸上與KMT妥協(如藍白合)時,會流失淺綠底與部分反建制的淺藍底。2028年,白營的擴張極限取決於Y軸(經濟分配)議題的熱度。
- **識別特徵**:在民調中,政黨偏好明確為TPP;對「抗中保台」與「九二共識」均持負面或冷感態度;投票動機中「教訓藍綠」的權重極高。
---
### 類型六:擺盪(Swing Voters)
詳見 `audiences/political_identity/swing_voters.md`。
#### 事實層
- **人口比例估算**:10% - 12%
- **核心特徵**:無固定政黨認同;投票行為高度依賴候選人個人特質、當下社會氛圍與突發事件;對政治缺乏深入理解,易受周圍網絡與短期訊息影響;不具備穩定的意識形態座標。
- **資訊渠道**:被動接收資訊為主(社群媒體演算法推送、親友轉傳);對政治新聞關注度低,僅在選前密集暴露;極易受假訊息或簡化口號影響。
- **議題敏感點**:切身利益的直接衝擊(如稅收、補貼、疫情管控);候選人的「感覺」(是否親民、是否說謊被揭穿);社會重大安全事件(如治安惡化、食安風暴)。
#### 脈絡層
擺盪選民的結構性特徵是**「低度政治社會化與高情境依賴」**。他們不是理性的計算者,而是情緒的回應者。他們的投票意向在選前一個月內可能劇烈波動。
擺盪選民與淺色陣營的關鍵差異在於:淺藍/淺綠有穩定的X軸或Y軸偏好,只是強度較弱;擺盪選民則連偏好都不穩定,他們可能在一次辯論會後就翻轉意向。
#### 分析含義
- **分析時如何用**:擺盪選民是選舉最後衝刺階段的「邊際決定者」。預測擺盪選民的行為,不應分析其意識形態,而應分析其「資訊環境」與「情緒共振」。負面選戰(抹黑、揭瘡疤)對擺盪選民的效力遠大於政策論述,因為他們缺乏過濾資訊的意識形態抗體。
- **識別特徵**:在民調中,高比例的「未決定」或「看誰選得好就投誰」;對政治人物的名字熟悉但對其政策一無所知;投票率不穩定,受天氣與選前氣勢影響大。
---
### 類型七:冷感
詳見 `audiences/political_identity/politically_apathetic.md`。
#### 事實層
- **人口比例估算**:15% - 20%(佔合格選民比例,實際投票率極低)
- **核心特徵**:對政治極度疏離;認為「誰當選都一樣,生活不會改變」;長期不投票或僅在極端壓力下投票;多為經濟與社會邊緣群體,或被高強度勞動壓榨的底層受薪階級。
- **資訊渠道**:幾乎不主動接觸政治新聞;資訊來源為娛樂內容或生活必需資訊;對政治術語完全陌生。
- **議題敏感點**:幾乎無政治敏感點,除非政策直接且立即地威脅其生存(如取消補貼、強制徵兵)。
#### 脈絡層
**中等模型自己想不到的洞察**:冷感不是「無意見」,而是「系統性剝奪感的沉默表達」。
冷感族群的不投票,實質上是對都會精英政治的否決。他們不是沒有利益需求,而是認為政治體系完全無視他們的需求。這使得冷感族群成為選舉結構的「隱形變數」:當某種極端動員(如民粹主義或社會運動)成功觸及他們的生存焦慮時,他們的突然投票將打破基於歷史投票率的預測模型。2016年美國大選的鏽帶工人翻轉,就是冷感被動員的典型。
冷感族群的內在矛盾是**「最需要資源重分配的群體,卻是政治參與度最低的群體」**。這使得Y軸(經濟分配)的選戰很難打動他們,因為他們根本不在接收訊息的頻道上。
#### 分析含義
- **分析時如何用**:冷感族群改變的不是選票的流向,而是選舉的「基數」。當冷感族群的投票率因為某種原因(如兵役恐懼、通膨憤怒)提升1-2個百分點時,由於其多為低社經地位者,其投票傾向會壓倒性地反對執政黨(現狀的受害者)。分析2028時,必須評估是否有足以喚醒冷感族群的「生存級議題」。
- **識別特徵**:在民調中,通常被歸為「拒訪」或「不投票」;即使受訪,也多回答「不知道」或「沒意見」。
---
## 類型對照矩陣
| 類型 | 議題敏感點(觸發流動的開關) | 主要資訊渠道(接收動員的介面) | 認知脆弱點(易受攻擊的破綻) | 投票穩定性 |
|---|---|---|---|---|
| **深藍** | 去中化、抹殺民國、美國軍火 | Line群組、傳統藍媒 | 將北京壓迫歸因於DPP挑釁 | 極高(鐵板) |
| **淺藍** | 戰爭風險、經濟衰退、執政貪腐 | 財經媒體、混合網路 | 對避戰的渴望壓倒對主權的堅持 | 中高(受候選人形象影響) |
| **深綠** | 出賣主權、威權復辟 | 本土媒體、綠營側翼 | 對KMT的恐懼壓倒對DPP的不滿 | 極高(鐵板) |
| **淺綠** | 分配不公、高房價、進步價值倒退 | 獨立媒體、Podcast | Y軸憤怒壓倒X軸恐懼(平時) | 中(受Y軸政策表現影響) |
| **白營** | 藍綠分贓、雙標偽善、生存焦慮 | KOL、社群迷因、PTT | X軸模糊性在危機時被攻擊為投機 | 中低(受形象與情緒影響) |
| **擺盪** | 切身利益、候選人感覺 | 被動推送、親友轉傳 | 缺乏意識形態抗體,易受假訊息感染 | 極低(選前波動大) |
| **冷感** | 生存級威脅(極少觸發) | 幾乎無 | 不在資訊場域內,無法被動員 | 極低(多不投票) |
---
## 類型之間的流動關係:棄保與流向
### 結構性命題:流動的閥門機制
**「認同光譜的流動性受制於外部威脅的閾值:當生存焦慮低於分配焦慮時,光譜橫向流動加速(淺色跨營流動);當生存焦慮突破閾值,光譜縱向固化(深淺歸隊),橫向流動凍結。」**
這意味著,分析2028的棄保與流向,必須先判斷「北京與華府的互動」是否讓X軸的生存焦慮突破了閾值。
### 流動向量圖譜
1. **淺綠 ↔ 白營**:當前最活躍的流動軸線。淺綠因Y軸不滿流向白營,當X軸升溫時回流DPP。這是DPP在2028最必須防守的防線。
2. **淺藍 ↔ 白營**:第二活躍的軸線。淺藍因厭惡KMT保守體質流向白營,當白營X軸破防或KMT展現勝算時回流KMT。這是KMT能否保住第二大黨的關鍵。
3. **淺藍/淺綠 → 冷感**:當對所有選項都絕望時,淺色陣營會退出投票體系。這是執政黨最大的隱形失血。
4. **深藍/深綠 → 擺盪**:極少發生。深色陣營幾乎不可能變成無定見的擺盪者,若對所屬政黨極度不滿,更可能走向冷感(不投票)而非擺盪。
5. **白營 → 深藍/深綠**:極少發生。白營支持者的反建制特質,與深色陣營的意識形態狂熱本質互斥。
### 棄保效應的數學條件
棄保不是自動發生的,它需要三個條件同時成立:
1. **恐懼條件**:最不希望的候選人有當選風險(X軸或Y軸的極度恐懼)。
2. **無望條件**:偏好候選人的當選機率被認定為極低。
3. **替代條件**:次佳候選人在恐懼面向上提供了足夠的安全距離。
在2024年,淺綠對KMT的恐懼條件成立,但TPP的無望條件在年輕選民中不成立(他們相信柯能贏),因此淺綠未發生棄保回流DPP。2028年,若TPP無法維持「有機會贏」的想像,淺綠的棄保效應將取決於KMT候選人的恐懼係數。
---
## 2024 選戰的「分裂模式」觀察
2024大選是台灣政治認同光譜重組的關鍵節點,其分裂模式揭示了傳統藍綠模型的失效。
### 事實層
- **DPP的分裂**:賴清德僅獲得40.5%選票,較2020蔡英文的57.1%大幅下滑。流失的16.6%主要為淺綠與年輕選民,流向TPP與不投票。
- **KMT的分裂**:侯友宜獲得33.5%選票,深藍歸隊艱辛,淺藍大量流向TPP。KMT失去「藍營唯一共主」的地位。
- **TPP的匯流**:柯文哲獲得26.5%選票,成功吸納了淺藍的經濟務實票與淺綠的分配正義票,形成跨認同的第三極。
### 脈絡層
2024的分裂模式,本質上是**「Y軸(經濟分配)對X軸(統獨)的叛變」**。
在過去,X軸的高張力(如2020的香港反送中)會壓制Y軸的不滿,迫使淺色陣營歸隊。但2024年,X軸張力下降(無重大外部危機),加上DPP執政八年累積的Y軠右傾(高房價、貧富差距),使得Y軸的分配焦慮終於突破了X軸的防線。
**破除常見簡化**:將2024的結果解讀為「藍綠基本盤萎縮,中間選民擴大」是錯的。實質是「淺色陣營在Y軸上脫離了深色陣營的控制,進行了跨認同的匯流」。這不是溫和中間派的勝利,而是反建制民粹的突破。
### 分析含義
- **分析時如何用**:2024的分裂模式是2028的起點,不是終點。DPP若誤以為只需操作X軸(抗中)就能拿回流失的選票,將重蹈覆轍;KMT若誤以為只需與TPP合作(藍白合)就能吸收白營選票,將忽略淺綠底對KMT的排斥。2028的關鍵,在於誰能精準地修補Y軸的裂痕。
---
## 對 2028 選戰的可能動態
基於七大類型的流動機制,2028大選可能出現三種動態情境:
### 情境A:X軸霸權回歸(北京高施壓)
- **觸發條件**:北京大規模軍演、封鎖、或挖角關鍵邦交國;美中關係急劇惡化導致台灣成為前線。
- **流動模式**:光譜縱向固化。淺綠因生存焦慮回流DPP,淺藍因避戰需求回流KMT。白營的X軸模糊性成為致命傷,其淺綠底回流DPP,淺藍底回流KMT,白營萎縮至核心鐵桿(10%以下)。
- **選舉結果**:回歸藍綠對決,DPP因深綠+淺綠基數大於深藍+淺藍而勝出。
### 情境B:Y軸主導(經濟低氣壓)
- **觸發條件**:全球經濟衰退、台灣房價未解、AI產業紅利未能下滲、執政黨爆發重大貪腐或黑金醜聞。
- **流動模式**:光譜橫向流動加速。淺綠持續流向白營或冷感,淺藍也流向白營。白營成為最大在野勢力,DPP基本盤萎縮至深綠。
- **選舉結果**:三黨不過半,DPP可能因淺綠流失而敗選,或在野陣營因棄保不完全而讓DPP低空過關。
### 情境C:冷感爆發(系統性不信任)
- **觸發條件**:藍綠白皆爆發重大誠信危機、社會發生大規模災難且政府應失能、年輕世代對未來徹底絕望。
- **流動模式**:淺色陣營與擺盪者大規模流向冷感(不投票)。投票率跌破五成。
- **選舉結果**:深色陣營決定結果。由於深綠基數大於深藍,且DPP掌握行政資源可動員,DPP仍可能勝出,但正當性將受嚴重侵蝕。
---
## 國際比較——其他民主國家的政治認同分布
將台灣的七大類型置於國際比較中,能更清晰地看見其特殊性與普遍性。
### 美國:極化兩極與萎縮的中間
- **分布特徵**:深紅(MAGA)與深藍(Progressive)高度極化,淺紅與淺藍被擠壓,中間選民(擺盪)比例低且影響力下降。美國缺乏台灣的「白營」——能同時吸納淺紅與淺藍的跨認同第三極。
- **對台啟示**:美國的極化是因為X軸(文化戰爭)與Y軸(經濟分配)高度重合(左即進步+重分配,右即保守+親市場)。台灣的特殊性在於DPP的X軸偏獨與Y軸偏右產生了撕裂,這才給了白營在Y軸上匯流的空間。若未來台灣的X軸與Y軸也高度重合,白營將失去生存空間,台灣將走向美國式的極化。
### 法國:多黨光譜與極端勢力的崛起
- **分布特徵**:極左(LFI)、中左(PS)、中間(LREM)、極右(RN)等多極並存。法國的「極右」類似台灣的深藍(傳統價值+經濟保護),但法國的極右是反體制,台灣的深藍是懷舊體制。
- **對台啟示**:法國的棄保模式(兩輪投票制)強迫中間選民在第二輪歸隊。台灣是一輪制,這使得白營的游離票在第一輪就可能決定結果,增加了預測的難度與黑天鵝的機率。
### 南韓:地域主義的殘餘與世代斷裂
- **分布特徵**:過去由地域(嶺南對湖南)主導,現今轉為世代(進步青年對保守老年)主導。南韓的「中間選民」比例低,類似台灣的擺盪者少,多為深色陣營的對抗。
- **對台啟示**:南韓顯示了地域主義消退後,世代斷裂會迅速填補真空。台灣的省籍情結已消退,由統獨與分配取代。若統獨降溫,台灣也可能走向南韓式的純世代對抗(老年保守vs青年進步),這對KMT的長期生存極為不利。
---
## 本檔案與各類型檔案的使用關係
本檔案是「總覽地圖」,各類型檔案是「街區詳圖」。使用原則如下:
1. **先看總覽,再看類型**:當分析一個政治事件時,先用本檔案判斷事件會觸發哪幾個類型的流動,再調用具體類型檔案查看該類型內部的次群體差異。
2. **流動用總覽,動員用類型**:若要預測選票的跨黨流動,使用本檔案的流動向量圖譜;若要設計針對特定群體的動員訊息,調用具體類型檔案的資訊渠道與認知脆弱點。
3. **交叉驗證**:當某類型檔案的結論與本檔案的流動預測矛盾時(例如淺綠檔案顯示其對DPP忠誠度上升,但本檔案預測Y軸焦慮會導致流失),以當下的經濟數據與兩岸張力指標作為最終裁判,因為流動是情境依賴的。
---
## 關聯分析
### 關聯一:與政黨意識形態地圖的交叉驗證
- **功能**:提供結構層。驗證七大認同類型在X軸與Y軸上的座標,是否與政黨的實際政策產出位置對齊。例如,淺綠的Y軸偏左位置,與DPP的Y軸偏右政策產出之間的落差,解釋了流動的動力來源。對照 `entities/parties/parties_ideological_map.md`。
### 關聯二:與淺綠/淺藍類型檔案的對照
- **功能**:提供事實層與脈絡層。驗證白營的選票基礎是否真如本檔案所述,是淺藍與淺綠的匯流。對照 `audiences/political_identity/light_green.md` 與 `audiences/political_identity/light_blue.md`,檢視其Y軸訴求的一致性與X軸忠誠的脆弱性。
### 關聯三:與白營類型檔案的對照
- **功能**:用於對照。驗證白營的「反建制」特徵是否在X軸升溫時足以維持,還是會崩解回淺藍與淺綠。對照 `audiences/political_identity/white_camp_audience.md`。
### 關聯四:與深藍/深綠類型檔案的交叉驗證
- **功能**:提供結構解釋。解釋為何深色陣營在面對同陣營執政者的Y軸背叛時,仍無法流出(因X軸的恐懼鎖定)。對照 `audiences/political_identity/deep_blue.md` 與 `audiences/political_identity/deep_green.md`。
### 關聯五:與冷感族群的關聯
- **功能**:提供脈絡層。解釋冷感不是靜態的缺席,而是潛在的「系統性否決票」。當生存級議題出現時,冷感可能瞬間轉化為擺盪或極端反對者。對照 `audiences/political_identity/politically_apathetic.md`。
### 關聯六:與選民投票行為框架的關聯
- **功能**:用於交叉驗證。本檔案的七大類型是基於「認同」的聚類,而投票行為框架是基於「動機」的拆解。兩者結合使用:先用本檔案定位選民的認同類型,再用投票行為框架判斷該類型在當前情境下的投票動機權重。對照 `primitives/frameworks/voter_behavior_analysis.md`。
---
## 常見的分析陷阱
### 陷阱一:將白營視為「淺藍的延伸」
這是DPP側最常見的誤判。將白營等同於淺藍,會導致DPP誤以為只需操作X軸(抗中)就能讓白營崩解。事實上,白營中有大量淺綠底,他們在X軸上與DPP一致,是在Y軸上背離DPP。對他們打「抗中牌」無效,必須用Y軸政策(分配正義)來挽回。
### 陷阱二:假設「冷感」等於「無影響」
冷感族群不投票,不代表他們不影響選舉。他們的缺席,改變了選舉的基數結構。當高比例的淺綠因失望轉為冷感時,DPP的得票數不變,但得票率可能因總投票數下降而相對上升,這會造成「DPP支持度回升」的假象,實質上是執政正當性的流失。
### 陷阱三:把「淺色」當作「深色的弱化版」
淺藍不是「比較不恨DPP的深藍」,淺綠也不是「比較不恨KMT的深綠」。淺色陣營是跨維度投票者,他們的決策邏輯與深色陣營完全不同。用深色陣營的恐懼動員(如「不投DPP就是投中共」)來動員淺綠,在X軸低張力時期會適得其反,引發反感。
### 陷阱四:忽略流動的「非對稱性」
淺綠流向白營容易,但從白營回流DPP難;淺藍流向白營容易,從白營回流KMT也相對容易(因KMT有較強的組織樁腳)。這種非對稱性意味著,DPP要修補與淺綠的關係,需要實質的Y軸政策讓利,而KMT要拉回淺藍,只需展現勝選氣勢或操作棄保。
### 陷阱五:將「擺盪」與「淺色」混淆
擺盪選民無定見,淺色選民有定見但非單一維度。對擺盪選民,簡單口號與負面攻擊有效;對淺色選民,必須提出具體政策論述。混淆兩者會導致選戰策略的失焦。
### 陷阱六:假設國際比較可直接套用
台灣的「統獨X軸」是生存軸線,這與西方民主國家的「文化戰爭X軸」有本質差異。西方的X軸爭議不會導致國家滅亡,台灣的X軸會。因此,將西方的極化模型或中間選民理論直接套用於台灣,會忽略X軸閾值效應的特殊性。
---
## 仍不確定的部分
- **白營的固化程度**:白營的支持者中,有多少是「已形成反建制理念的核心票」,有多少是「隨時可因X軸升溫或候選人替換而流散的游離票」?這決定了TPP是長期的第三極,還是曇花一現的民粹浪潮。目前缺乏足夠的歷史縱深來確認。
- **Y軸焦慮的臨界點**:高房價與低薪要惡化到何種程度,才會讓淺綠寧願冒X軸(主權)的風險,也要在Y軸上懲罰DPP?此臨界點受到外部威脅強度的調節,目前無法精確量化。
- **冷感族群的喚醒條件**:何種「生存級議題」能讓長期不投票的冷感族群走進投票所?是極端的經濟崩潰,還是強制的兵役威脅?此變數在2028年可能因AI產業的貧富撕裂效應而首次面臨實戰檢驗。
- **深藍的自然凋零速率**:深藍族群每年自然減少多少百分點?這直接決定了KMT基本盤的下限。若凋零速度快於預期,KMT將被迫提前進行路線重組,否則將面臨生存危機。
---
## 與其他文件的關係
- 深藍:`audiences/political_identity/deep_blue.md` —— 提供深藍的次群體(軍公教、外省第二代)分析,解釋其對KMT的約束機制
- 淺藍:`audiences/political_identity/light_blue.md` —— 提供淺藍的經濟務實特徵,解釋其流向白營的條件
- 深綠:`audiences/political_identity/deep_green.md` —— 提供深綠的理念純潔性訴求,解釋其對DPP的壓力閥功能
- 淺綠:`audiences/political_identity/light_green.md` —— 提供淺綠的分配焦慮細節,解釋2024流失與2028回流的關鍵
- 白營:`audiences/political_identity/white_camp_audience.md` —— 提供白營的跨認同匯流結構,驗證其X軸脆弱性
- 擺盪:`audiences/political_identity/swing_voters.md` —— 提供擺盪選民的資訊環境分析,用於預測負面選戰效力
- 冷感:`audiences/political_identity/politically_apathetic.md` —— 提供冷感族群的剝奪感結構,用於評估投票率變動基數
- 政黨意識形態地圖:`entities/parties/parties_ideological_map.md` —— 提供政黨在X/Y/Z軸上的實際位置,作為認同流動的座標參照系
---
## 一句話濃縮
> 七大認同類型是受外部威脅閾值調控的動態流場——淺色陣營在Y軸的匯流打破了藍綠壁壘,白營的崛起不是中間派的勝利而是分配焦慮的叛變,2028的勝負取決於誰能精準修補Y軸的裂痕,而非誰能喊出最響亮的X軸口號。
---
last_updated: 2026-05-08
@@ -1,318 +0,0 @@
# 政治冷感族群
---
**類型**: narrative
**當前角色**: 政治敘事 / 選民結構分析框架
**源流起點**: 解嚴後代議民主常態化下的邊際退出 / 2018年公投與大選脫鉤後的制度性隔離 / 2024年抗議載體失效後的深層內捲
**何時調用此檔案**: 當分析涉及台灣選舉的未表態選民解讀、投票率波動的底層邏輯、非政治訊息渠道對政治態度的滲透、生存議題對非選民的動員效應、以及低政治參與度對民主體制韌性的潛在衝擊時。
---
## 一句話濃縮
政治冷感族群的本質不是「無知」或「不負責任」,而是對政治效用預期的系統性低估與退出——他們是台灣選舉版圖的「沉默基底」,平時隱形於民調與媒體之外,以「理性無知」降低參與成本,但在生存底線受衝擊的臨界點上,可能被恐慌敘事穿透,成為顛覆選舉格局或動搖體制合法性的非理性變數;分析者若將冷感等同於中間選民,或假設高曝光率能動員冷感,將徹底誤判2028年台灣政治板塊的隱性風險。
---
## 群體定義與邊界:冷感、疏離與棄絕的精確區分
### 事實層:人口規模與識別標準
政治冷感族群在台灣選民結構中佔據顯著比例,但因其隱蔽性,常被民調與選舉分析邊緣化。透過歷次選舉投票率倒推,可勾勒其大致輪廓:
- **人口估算**:2020年總統大選投票率為74.9%,2024年為71.86%。未投票的25-30%選民(約400-500萬人)中,扣除因工作、戶籍、身心因素無法投票的「技術性缺席者」(約佔5-8%),以及有強烈政治立場但拒絕投票的「抗議性缺席者」(約佔3-5%),核心的「政治冷感者」約佔全體選民的15-20%(約250-300萬人)。
- **識別標準**:此群體的特徵是「三低一高」——低政治訊息接觸頻率、低政黨認同度、低投票參與率,以及高生活壓力感知。他們不是「尚未決定投誰」的游離選民,而是「尚未決定是否要關心政治」的缺席者。
### 脈絡層:冷感 vs 疏離 vs 棄絕的結構性差異
分析政治冷感,必須首先與另外兩種政治退出狀態精確切割。這三者在外顯行為上可能相似(皆表現為不投票、不關心),但內在邏輯與潛在動能截然不同:
1. **疏離**:源於「期望落空後的憤怒」。疏離者有政治訴求,但認為現有體制或政黨無法回應,因此尋求體制外的抗議載體(如2024年的TPP支持者)。疏離是「積極的退出」,隨時可能因新載體出現而爆發性回歸。交叉引用 `narratives/domestic/youth_political_alienation.md`,可見年輕疏離者雖對藍綠失望,但對居住正義等議題有高度熱情。
2. **冷感**:源於「效用預期過低的漠然」。冷感者不認為政治改變能對其生活產生實質影響,或者認為參與政治的成本(時間、情緒)遠高於收益。冷感是「消極的休眠」,他們不憤怒,只是無感。
3. **棄絕**:源於「對體制合法性的根本否定」。棄絕者不僅不信任政黨,更否定整個代議民主或國家機器的正當性,可能轉向激進行動主義或無政府主義。棄絕是「破壞性的斷線」。
**結構性命題**:政治冷感是「理性無知」的變體。在資訊爆炸與生活高壓的結構下,當獲取政治知識的邊際成本高於其可能帶來的邊際收益時,選擇「不關心」是個體層面的理性計算。冷感不是道德缺陷,而是成本收益分析的結果。
### 分析含義
分析者在使用民調數據時,不可將「未表態」或「都不支持」全數歸為冷感。其中包含大量憤怒的疏離者與少數激進的棄絕者。冷感者通常根本不在民調的樣本框活躍區內——他們連拒訪的意願都低於一般水準,往往在抽樣第一關就被過濾。因此,任何基於民調推估冷感者政治傾向的嘗試,都存在嚴重的樣本偏誤風險。
---
## 冷感的多重根源:疲乏、不信任與生活壓力的結構性交織
政治冷感並非單一原因造成,而是三重結構性力量長期擠壓的結果。
### 事實層:三重擠壓的具體表現
1. **生活壓力的物理約束(時間與精力的貧困)**:台灣長期低薪高工時的結構,使大量受薪階級與非典型就業者陷入「時間貧困」。對他們而言,下班後的精力僅夠應付生活瑣事與基本娛樂,追蹤複雜的政治議題或排隊投票,是奢侈的時間支出。
2. **政治戲碼化帶來的訊息疲乏**:24小時新聞台與社群媒體將政治簡化為藍綠惡鬥、口水戰與失言風波。這種「政治娛樂化」雖然增加了曝光,但長期下來讓受眾產生嚴重的審美疲勞與認知疲乏。當政治新聞等同於八點檔,受眾的回應不是投入,而是轉台。
3. **代議失靈的長期經驗(不信任的固化)**:歷經多次政黨輪替,多數冷感者的生命經驗是「換誰做都一樣」。無論是地方基礎建設的牛步、貧富差距的擴大,或是政策跳票的常態化,都強化了他們「政治無用論」的認知。交叉引用 `narratives/domestic/dpp_long_rule_fatigue.md`,執政疲乏不僅製造了反對者,更製造了大量的冷感者——當執政者無法解決生存問題,而在野黨也提不出可信方案時,退出是唯一的自保。
### 脈絡層:從「無感」到「反感但不參與」的質變
過去十年,台灣政治冷感的內涵發生了微妙的質變。早期的冷感多屬於「傳統無感型」(多為中老年、低學歷、偏鄉居民,因資訊封閉而無感);如今的冷感越來越多屬於「現代反感型」(多為中年受挫者、高壓服務業勞工,因資訊過載與生活壓迫而選擇性遮蔽)。
**破除常見簡化**:把冷感歸因於「公民素養不足」是錯的。現代反感型冷感者往往具有相當的資訊解讀能力,他們的冷感恰恰是看透了政治運作邏輯後的防禦機制。他們不是不懂政治,而是認為「懂了又怎樣,改變不了什麼」。這種冷感帶有隱性的憤世嫉俗,比傳統無感型更具潛在的破壞性。
### 分析含義
分析2028年選情時,若執政黨試圖以「政績宣傳」或「民主價值」來動員冷感者,將完全無效。對生活壓力極大的人宣揚總體經濟成長,只會加深其相對剝奪感;對不信任體制的人訴求民主防衛,只會被視為政治話術。唯一能觸動冷感者的,是直接、立即、具體的生存利益承諾,或是對生存危機的極度恐懼。
---
## 信息行為:非政治性偏好與政治訊息的「滲透」
政治冷感者的信息行為特徵,決定了政治訊息如何(以及如何無法)觸達他們。
### 事實層:渠道偏好與內容消費
- **主要接觸渠道**:冷感者極少主動接觸政論節目、新聞台或政治粉專。他們的資訊環境主要由娛樂(綜藝、戲劇、遊戲實況)、生活實用(美食、旅遊、3C評測、理財)與社交(親友群組、Dcard非政黑板)構成。
- **平台偏好**:YouTube(長影音作為背景音或休閒)、TikTok/Reels(短影音填補碎片時間)、Instagram(追蹤生活風格KOL)。PTT政黑板對他們而言是異世界。
- **信任來源**:自身的生活經驗(物價、薪水、房價) > 親友的口耳相傳 > 長期追蹤的非政治KOL > 任何政治人物或政黨發言。
### 脈絡層:政治訊息的「滲透」機制
冷感者築起了高聳的「政治防火牆」,但在三種情況下,政治訊息仍能滲透:
1. **娛樂化滲透**:當政治事件被轉化為迷因、搞笑片段或八卦醜聞時,它不再被識別為「政治」,而是「娛樂」。冷感者可能不知道某法案的內容,但對某政治人物的失言梗圖瞭若指掌。這種滲透改變了政治人物的形象,但無法傳遞政策內涵。
2. **生存警報滲透**:當政策直接衝擊錢包或安全時(如兵役延長、稅制變更、健保調漲、兩岸開戰傳言),生存本能會強制啟動對相關訊息的接收。此時,冷感者會暫時成為高強度的政治訊息消費者,但他們尋求的是「如何自保」,而非「政治分析」。
3. **社交網絡滲透**:冷感者身處的親友群組,往往是假訊息與簡化敘事的溫床。交叉引用 `primitives/concepts/spiral_of_silence.md`,在非政治同溫層中,冷感者缺乏反駁政治謠言的動機與論據,往往以沉默或被動接受來維持社交和諧。
**中等模型自己想不到的洞察**:冷感者對政治宣傳的「免疫力」,並非來自批判性思維,而是來自「注意力屏蔽」。他們不是看穿了政治謊言,而是根本沒聽到謊言。然而,這種免疫力是脆弱的——一旦謊言被包裝成生存警報,並透過非政治KOL或親友群組傳遞,他們將毫無防備地全盤接收,因為他們缺乏對政治訊息的「抗體」(即基礎的政治事實查核習慣與脈絡理解)。
### 分析含義
評估認知戰對冷感群體的影響時,必須區分「穿透率」與「信任轉化率」。正統的政論頻道認知戰對冷感者穿透率為零;但透過理財博主發布「兩岸即將開戰,資金應如何撤離」的恐慌敘事,穿透率與信任轉化率皆極高。分析者必須監測非政治領域KOL的「政治化發言」,這才是冷感者接收政治訊息的真正破口。
---
## 政治傾向與投票模式:「非選民」的隱性板塊
### 事實層:歷次選舉的缺席數據
冷感者是台灣選舉中最大的「非選民」群體,其缺席直接壓低了投票率,並改變了選舉的基數結構。
- **2020年總統大選**:投票率74.9%,因香港反送中與主權危機,部分冷感者被「生存警報」動員,投票率推升。
- **2022年九合一選舉**:投票率降至61.22%,冷感者大規模回歸缺席狀態,無主權危機的日常政治無法觸發其參與動機。
- **2024年總統大選**:投票率71.86%,TPP吸納了大量疏離者,但核心冷感者仍多缺席。即使柯文哲的娛樂化政治吸引了他們的目光,仍不足以將其轉化為實際的投票行動,因為「出門投票」的物理成本對他們而言依然過高。
### 脈絡層:冷感者的隱性傾向
冷感者沒有穩定的政黨認同,但這不代表他們沒有政治直覺。若被迫在槍口下做出選擇,其傾向往往反映其所在的社會網絡與生存直覺:
1. **地方派系與家族網絡的牽引**:中南部與偏鄉的冷感者,其政治直覺往往繼承自家族或地方派系。他們不關心政策,但在被樁腳動員(提供交通與誘因)時,會依循慣性投票。
2. **都會區的「避險直覺」**:都會區的冷感者,其直覺是「誰讓我日子過得下去,誰不會帶來戰爭」。這是一種去意識形態的實用主義。
3. **對「改變」的低度期待**:冷感者對任何候選人的政見都抱持預設的不信任。他們不認為選誰會讓生活變好,只擔心選錯人會讓生活變糟。
**分析含義**:預測選舉時,冷感者的存在使得「民調支持度」與「實際得票率」之間永遠存在換算誤差。高投票率通常意味著冷感者被某種極度焦慮的情緒動員,這往往伴隨著對現狀的強烈否定。交叉引用 `primitives/frameworks/voter_behavior_analysis.md`,冷感者的投票行為不屬於理念、利益、議題或形象中的任何一種常規模式,而是屬於「恐慌避險」的例外模式。
---
## 核心議題敏感點與「被動員」的臨界條件
### 事實層:觸發關注的少數議題
冷感者對絕大多數政治議題無感,但有極少數議題能直接刺穿其防護網:
1. **戰爭與兵役(物理生存威脅)**:這是絕對的底線。當台海局勢被感知為「即將開戰」,或兵役延長直接衝擊自身與子女時,冷感會瞬間轉為極度焦慮。
2. **通貨膨脹與經濟崩盤(生計威脅)**:物價飛漲、失業率飆升、股市崩盤。這些是冷感者日常生活的痛點,當政治被歸咎為這些痛點的根源時,政治就不再是身外之事。
3. **重大公安與食安危機(日常安全崩潰)**:如大規模停電、嚴重食安風暴。這類事件直接摧毀了「維持現狀」的基本安全感。
### 脈絡層:動員冷感者的非理性邏輯
**破除常見簡化**:認為冷感者需要「被教育」或「被說服」才會參與政治是錯的。冷感者的動員不是「說服」,而是「驚嚇」。
他們不因「願景」投票,只因「恐懼」投票。當政治人物描繪美好的未來時,冷感者充耳不聞;但當政治人物警告「票投X黨,青年上戰場」或「X黨執政,經濟崩潰」時,生存焦慮會強制啟動他們的避險本能。
這種動員具有極高的不穩定性與非理性特徵。因為缺乏理念錨定,冷感者在恐慌中做出的選擇,完全取決於哪一方的「恐嚇敘事」更成功地穿透其信息防火牆。他們不會去查證戰爭風險的客觀數據,只會依賴直覺與社交網絡的恐慌情緒。
### 分析含義
分析2028年選情時,若台海無重大危機且經濟平穩,冷感者將繼續缺席,選舉結果由基本盤與疏離者的流向決定;但若北京在選前發動大規模軍演,或全球經濟陷入衰退,冷感者將被恐慌動員出場。此時,選舉將不再是政策辯論,而是「恐懼公投」。哪個政黨能成功將危機歸咎於對手,並將自己包裝為「避險安全牌」,就能收割冷感者的選票。
---
## 兩岸與國際態度:消極的維持現狀與本能的避戰
### 事實層:去意識形態的生存主義
冷感者對兩岸與國際關係的態度,呈現出高度的去意識形態化與實用主義傾向:
- **對統獨無感,對和平有執念**:他們不在乎「中華民國」的歷史法統,也不熱衷「台灣獨立」的建國理念。他們的核心訴求只有一個:「不要打仗」。任何可能打破和平現狀的舉動,無論來自北京或台北,都會引發他們的本能排斥。
- **對北京無好感但恐懼衝突**:冷感者對北京政權缺乏認同,甚至對其高壓統治有所耳聞,但他們不將此視為需要對抗的價值之爭,而是視為必須迴避的風險之源。他們傾向於「不要惹怒北京」,這不是出於親中,而是出於避戰的生存直覺。
- **對美方無特定認同但默認保護傘**:他們對美國的民主價值無感,對美國的雙重標準可能有不滿,但默認美國是維持台海和平的必要力量。然而,若美國的舉動被感知為「挑釁北京」或「將台灣推向戰爭」,他們的疑美情緒會迅速升高。
- **對ECFA與兩岸交流的務實看待**:ECFA對他們而言不是主權讓渡的爭議,而是「能不能賺錢、吃飯」的問題。只要兩岸交流能帶來經濟利益且不引發戰爭,他們就支持;若交流意味著被併吞或引來制裁,他們就反對。
### 脈絡層:消極維持現狀的脆弱性
冷感者的兩岸立場是「慣性維持現狀」,而非「理念維持現狀」。這使得他們的立場極易被極端事件扭轉。
當北京施加軍事壓力時,理念維持現狀者會強化抗中意志;但慣性維持現狀者(冷感者)則可能產生「投降避戰」的衝動。如果戰爭風險被感知為不可逆,且代價是生命財產的毀滅,冷感者可能願意接受任何形式的和平安排,即使這意味著主權的實質喪失。這是因為他們對主權沒有信仰,只有對生存的執念。
**分析含義**:北京的對台策略若要成功穿透冷感者,絕不能訴諸「民族大義」或「兩岸一家親」,而必須訴諸「戰爭的恐怖」與「和平的代價」。同樣地,DPP的防衛論述若要有效,不能只談「民主防衛」,必須將「抗中」連結到「避戰」——證明抵抗才是保命的唯一途徑,而非挑釁。若DPP無法說服冷感者「備戰才能避戰」,冷感者在危機中將成為妥協主義的溫床。
---
## 認知脆弱點與韌性:簡化敘事的獵物與日常生活的錨定
### 事實層:脆弱與韌性的並存
冷感者的認知結構呈現出極端脆弱與極端韌性並存的矛盾特徵:
- **脆弱點**:
1. 缺乏事實查核習慣與基礎政治知識,無法識別認知作戰的粗糙偽造。
2. 對複雜因果關係的理解不足,極易接受陰謀論與簡化敘事(如「一切都是美國的陰謀」、「政客都是為了選票」)。
3. 在生存恐慌下,理性思考能力進一步下降,易受情緒性訊息感染。
- **韌性點**:
1. 對政治訊息的「注意力屏蔽」,構成了第一道防線。不接觸就不受影響。
2. 日常生活的穩定性(有工作、有家庭、有社交),構成了對抗極端敘事的緩衝墊。只要生活過得去,他們沒有動機去相信顛覆性的陰謀論。
3. 對政治人物的普遍不信任,使他們對任何政治宣傳都抱持預設的懷疑,無論是來自藍綠或北京。
### 脈絡層:免疫力斷層
**中等模型自己想不到的洞察**:冷感者的認知韌性,本質上是「物理隔離」而非「抗體免疫」。
這就像一個從未接觸過病毒的人,因為住在深山而未被感染(韌性來自隔離);一旦病毒隨著洪水(生存危機)沖入深山,其缺乏抗體的免疫系統將瞬間崩潰(脆弱性爆發)。當社會平穩時,冷感者對認知戰免疫;當社會陷入危機時,他們將是認知戰最易攻陷的獵物。
交叉引用 `primitives/concepts/spiral_of_silence.md`,在危機時刻,冷感者所在的親友群組將成為恐慌敘事與假訊息的放大器。由於群組內缺乏具有政治素養的「糾錯者」,恐慌情緒將在封閉網絡中無節制地反饋增強。
### 分析含義
分析者不可用常規的「事實查核」或「媒體素養」邏輯來評估冷感者的認知防禦力。在平時,他們不需要防禦;在戰時,他們無法防禦。因此,防禦冷感者被認知戰攻陷的關鍵,不在於提升其媒體素養,而在於維持社會的日常穩定,以及在其信息防火牆被擊穿時,透過其信任的非政治KOL傳遞安撫性與澄清性的訊息。
---
## 近年變化趨勢:5-10年內的結構性演變
### 事實層:冷感群體的擴張與質變
過去十年,台灣的政治冷感群體經歷了兩個顯著的結構性演變:
1. **從「被動退出」到「主動屏蔽」**:過去的冷感者多因資訊匱乏或教育程度限制而被動退出政治;現在的冷感者更多是資訊過載下的主動屏蔽者。他們擁有智慧型手機與社群媒體,但選擇演算法餵養娛樂內容,而非政治新聞。
2. **「憤世嫉俗型冷感」的比例上升**:交叉引用 `narratives/domestic/dpp_long_rule_fatigue.md`,長期執政帶來的疲乏感,使得部分原本具有政黨認同的選民,在經歷多次期待落空後,從疏離退化為冷感。他們不再是憤怒的反抗者,而是心死的旁觀者。
### 脈絡層:演算法與經濟結構的共謀
這種演變是演算法邏輯與經濟結構共謀的結果。演算法偏好高互動率的娛樂與情緒內容,自動將枯燥的政治政策邊緣化;而高房價與低薪的經濟結構,則剝奪了年輕與中年世代的精力,迫使他們進入低耗能的娛樂資訊模式。兩者合力,將越來越多選民推入冷感的深淵。
同時,政黨的選舉策略也加速了這個過程。當政黨發現動員冷感者的成本極高,而鞏固基本盤的性價比更高時,他們會放棄爭取冷感者,轉而操作基本盤的對立與危機感。這使得政治論述更加極化與封閉,進一步推開了冷感者,形成惡性循環。
### 分析含義
2028年,冷感群體的規模可能進一步擴大,特別是若經濟持續停滯且政治惡鬥加劇。這意味著,選舉的決勝點將越來越取決於極少數的「疏離游離票」,而非多數選民的共識。同時,龐大的冷感基底將成為台灣社會的未爆彈——若發生系統性危機,這股平時不可見的力量可能以最不可預測的方式爆發。
---
## 對民主體制穩定性的潛在影響
### 事實層:低參與度下的合法性侵蝕
當25-30%的選民長期缺席,民選政府的代表性實質上受損。雖然民主制度在法律上不要求100%投票率,但長期的低參與度會緩慢侵蝕體制的合法性基礎。冷感者的不信任,從個體的「無感」累積為集體的「冷漠」,使得政府在推動重大改革時缺乏社會授權。
### 脈絡層:沉默基底的鬆動
冷感群體是民主體制的「沉默基底」。只要他們保持冷感,體制就能在低合法性下維持運轉——因為沒有人挑戰規則本身。但這個基底是脆弱的:
1. **威權敘事的溫床**:當冷感者對民主的無效率感到絕望,且生活陷入困境時,他們可能輕易接受「強人能解決問題」的威權敘事。他們對民主沒有信仰,因此放棄民主的心理成本極低。
2. **民粹主義的燃料**:冷感者一旦被恐慌動員,其投票行為缺乏理念制約,極易被民粹主義者操縱。他們可能支持任何承諾「立即解決問題」的極端政策,而不顧其對民主制度的破壞。
**分析含義**:評估台灣民主韌性時,不能只看積極參與者的態度,更要看冷感群體的「容忍閾值」。只要生活過得去,他們容忍任何體制;一旦生活過不下去,他們隨時可能擁抱任何承諾解決問題的強人。這意味著,維持經濟繁榮與社會安全網,不僅是經濟問題,更是保衛民主體制的第一道防線。
---
## 關聯分析
### 關聯一:年輕世代政治疏離論述
- **關聯文件**: `narratives/domestic/youth_political_alienation.md`
- **功能**: 提供對照與區分。疏離是「積極的退出」,冷感是「消極的休眠」。理解疏離者如何因期待落空而轉為冷感,是預測冷感群體擴張的關鍵。該檔案中「分配底線違約」的邏輯,同樣適用於解釋冷感者的退出動機,但冷感者缺乏疏離者的反抗能量。
### 關聯二:選民投票行為分析框架
- **關聯文件**: `primitives/frameworks/voter_behavior_analysis.md`
- **功能**: 提供分析框架。該框架中的「理念、利益、議題、形象」四重模式,無法解釋冷感者的常態行為,但可用於解釋冷感者在「被動員」時的瞬間轉變——他們直接跳過理念與議題,被最原始的「生存利益」與「恐慌形象」捕獲。
### 關聯三:沉默螺旋
- **關聯文件**: `primitives/concepts/spiral_of_silence.md`
- **功能**: 提供脈絡層。解釋冷感者在社交網絡中的資訊接收模式。在非政治同溫層中,冷感者因缺乏論據而保持沉默,這使得極端或恐慌敘事能在其群體中無阻力傳播。
### 關聯四:執政疲乏論述
- **關聯文件**: `narratives/domestic/dpp_long_rule_fatigue.md`
- **功能**: 提供因果驗證。執政疲乏不僅製造了反對者,更製造了冷感者。當執政無效與在野無力的結構固化,選民的合理回應就是從疏離走向冷感。該檔案有助於理解冷感群體中「憤世嫉俗型」的源流。
### 關聯五:戰爭和平論述
- **關聯文件**: `narratives/cross_strait/war_peace_narrative.md`(占位)
- **功能**: 提供事實層。戰爭和平論述是穿透冷感者防火牆的最強武器。理解該論述如何操作「恐戰」與「避戰」情緒,是預測冷感者在台海危機中如何被動員的關鍵。
### 關聯六:北京對台反應類型學
- **關聯文件**: `primitives/frameworks/beijing_response_typology.md`
- **功能**: 用於交叉驗證。北京的軍事施壓(如第五層封鎖)是觸發冷感者「生存警報」的必要條件。評估北京行動對台灣選情的影響時,必須將冷感者的恐慌動員效應納入計算。
---
## 常見的分析陷阱
### 陷阱一:將「冷感」等同於「中間選民」
這是最致命的陷阱。中間選民是有投票意願但尚未決定投誰的游離者;冷感者是根本不打算投票的缺席者。將冷感者誤判為中間選民,會導致對選舉波動性的嚴重高估(以為他們會被說服),或對突發事件衝擊的嚴重低估(忽略他們被恐慌動員的潛力)。
### 陷阱二:假設「提高資訊曝光率」能解決冷感
冷感者不是因為資訊不足而冷感,而是因為資訊過載與效用過低而冷感。增加政治新聞的曝光率,只會加速他們的「注意力屏蔽」。試圖用傳統媒體或正經政論觸達冷感者,是對牛彈琴。
### 陷阱三:將冷感道德化為「不負責任」
將冷感歸因於公民素養不足,是分析者的傲慢。冷感往往是生活壓力與體制失靈下的理性選擇。這種道德化判斷會讓分析者忽略冷感背後的結構性根源(如低薪高房價),從而無法預測當這些結構性壓力鍋爆發時,冷感者可能產生的激烈反應。
### 陷阱四:認為「冷感者沒有政治影響力」
冷感者在平時沒有影響力,但他們是選舉基數的調節閥。當冷感者大規模缺席時,有利於組織動員力強的政黨;當冷感者被恐慌動員時,有利於操作恐懼敘事的政黨。忽略冷感者的潛在動能,等於忽略了選舉中的「黑天鵝」係數。
### 陷阱五:忽略冷感內部的異質性
冷感不是鐵板一塊。傳統無感型(偏鄉老年)與現代反感型(都會青壯年)的行為邏輯完全不同。前者易被地方樁腳動員,後者易被網路恐慌敘事穿透。用單一模型分析冷感者,必然失真。
### 陷阱六:假設「經濟好轉就能消除冷感」
總體經濟數據的成長(如GDP、股市)對冷感者無感,因為他們面臨的是分配不公與生活壓力。若經濟成長的果實未分配到基層,經濟越好反而可能加深相對剝奪感,強化其「政治無用論」。
---
## 仍不確定的部分
### AI生成內容對冷感者的穿透效應
隨著AI生成技術普及,針對冷感者偏好的娛樂與生活渠道,客製化生成帶有政治意圖的短影音或迷因,其穿透率與說服力目前仍缺乏實證數據。冷感者的「注意力屏蔽」能否抵擋高度擬真且娛樂性極強的AI政治內容,是2028年前的重大變數。
### 經濟衰退臨界點對冷感轉棄絕的觸發閾值
通貨膨脹與失業率必須惡化到何種程度,才會讓冷感者從「消極休眠」轉為「積極棄絕」?此臨界點受到台灣社會安全網厚度、家庭支持系統與文化韌性的影響,目前無法精確量化,僅知此逆轉點存在且後果嚴重。
### 非政治KOL的政治化動員潛力
當非政治領域的KOL(如理財、健身、遊戲實況主)開始談論政治時,對冷感者的動員效力遠大於政治人物。但這些KOL願意承擔多大的政治風險?其粉絲群體(冷感者)對其政治發言的接受度與反彈度如何?此動態仍在演化中。
### 新住民群體的冷感特徵
新住民及其子女在台灣選民結構中的比例持續上升。他們是否會因為文化隔閡與資訊門檻,成為新的冷感群體?或者他們會因為對母國的經驗對比,而產生不同的政治參與動機?尚待長期觀察。
---
## 與其他文件的關係
- 年輕世代政治疏離:`narratives/domestic/youth_political_alienation.md`(對照疏離與冷感的區別,以及疏離向冷感的退化路徑)
- 選民投票行為分析框架:`primitives/frameworks/voter_behavior_analysis.md`(提供常規投票模式框架,用於對照冷感者的例外行為)
- 沉默螺旋:`primitives/concepts/spiral_of_silence.md`(解釋冷感者在社交網絡中的資訊接收與沉默機制)
- 執政疲乏論述:`narratives/domestic/dpp_long_rule_fatigue.md`(解釋冷感群體中「憤世嫉俗型」的源流與擴張原因)
- 戰爭和平論述:`narratives/cross_strait/war_peace_narrative.md`(分析穿透冷感者防火牆的最強敘事武器)
- 北京對台反應類型學:`primitives/frameworks/beijing_response_typology.md`(評估外部軍事壓力觸發冷感者生存警報的條件)
---
## 一句話濃縮
政治冷感是生活壓力與體制失靈下「理性無知」的退出,他們平時隱形於民調之外、對政治宣傳具備物理隔離的韌性,但在生存底線受衝擊時會被恐慌敘事瞬間穿透,成為缺乏理念錨定的避險變數;分析者若將冷感誤判為中間選民,或忽略其「驚嚇動員」的非理性邏輯,將徹底錯讀2028年台灣選舉的隱性風險與民主韌性的真實底線。
---
last_updated: 2025-05-19
@@ -1,329 +0,0 @@
# 摆荡选民
---
**类型**: 政治叙事档案
**当前角色**: 分析对象与选举预测关键变量
**何时调用此档案**: 分析台湾选举最后两周的民意变动;评估民调中「未决定」与「不愿透露」选民的行为;预测经济议题、两岸危机与候选人特质对选情的边际效应;解读三脚督格局下的弃保与回流动态;评估2028各阵营的拓票空间时
---
## 一句话浓缩
摆荡选民不是无知的中间派,而是风险趋避的务实计算者——他们在最后两周的决断,往往不是选择『最喜欢谁』,而是选择『最不恐惧谁』与『最不讨厌谁』;他们的摆荡不是随机噪音,而是台湾政治经济结构变动的微观指针。
---
## 群体定义与边界:谁在摆荡?
### 事实层:识别标准与人口规模
摆荡选民指在历次选举中未展现稳定政党认同、需在每次选举周期重新评估并决定投票对象的选民群体。此定义的核心是「行为上的不稳定性」,而非「意识形态的温和性」。
**识别标准**:
在政治学实证调查中(如「台湾选举与民主化调查」TEDS),通常以政党认同量表中的「中立无反应」与「偏向但非常弱」两类来界定。量表题目通常为:「你觉得自己是偏向哪一个政党?」选项包含强烈认同、微弱认同、独立无党派。
**人口规模估算**:
学界与民调界对摆荡选民规模的估算存在显著差异,主要分歧在于如何处理「隐性认同者」:
| 估算来源 | 估算规模 | 测量方法 | 涵盖对象 |
|---|---|---|---|
| TEDS 学术调查 | 约 15-20% | 面访,多题项量表识别 | 排除弱认同者后的纯粹独立选民 |
| 商业民调(常态) | 约 20-25% | 市话/手机电访,单题询问 | 包含「尚未决定」与「不愿透露」者 |
| 选前动态估算 | 可达 30% | 追踪面板数据,比较历次投票对象 | 包含跨党派投票者(如投A党总统、B党立委) |
**分析含义**:当分析者引用「摆荡选民」比例时,必须先确认其操作型定义。商业民调中的「未决定」选民,常包含不愿向陌生电访员表态的深蓝或深绿选民(隐性基本盘),这群人最终的行为是「回归」而非「摆荡」。详见 `primitives/frameworks/polling_methodology_analysis.md`。
### 脉络层:结构性摆荡 vs 偶发性摆荡
将摆荡选民视为同质群体是严重的分析错误。必须区分两种截然不同的摆荡机制:
**结构性摆荡**:
- **定义**:基于长期社会经济地位与利益计算而产生的周期性政党选择转移。
- **特征**:这类选民有清晰的利益偏好(如经济优先、阶层流动),其摆荡是对执政党表现的评价性反馈。当执政党满足其利益时倾向支持,反之则惩罚。
- **典型群体**:中小企业主、受景气循环影响的农工阶层、关注荷包的中产阶级。
**偶发性摆荡**:
- **定义**:基于特定选举周期的突发事件、候选人个人特质或单一议题而产生的短期转移。
- **特征**:这类选民缺乏稳定的利益计算框架,其决策高度受当下媒体氛围、同侪压力或情感冲动驱动。
- **典型群体**:首投族、对政治冷感但被特定社会运动或候选人魅力临时动员的年轻选民、对传统政治极度厌恶的反建制选民。
**结构性命题**:摆荡选民的存在,是台湾两党制无法完全垄断政治市场的结构性证明。其规模与执政党的「施政满意度」呈高度负相关——执政越久,施政疲劳越大,摆荡池的规模就越大,因为原本的弱认同者会因不满而脱离基本盘,成为待争取的摆荡者。
### 分析含义:破除「中间选民」的迷思
**破除常见简化**:将「摆荡选民」等同于「中间选民」是错误的。「中间选民」暗示其在统独或左右意识形态光谱上居于中点(温和、妥协),但许多摆荡选民在意识形态上可能极为极端(如深绿但反贪腐而惩罚DPP,或深蓝但厌恶KMT权贵而支持第三势力)。他们之所以「摆荡」,不是因为立场温和,而是因为其投票决策的权重分配中,「施政评价」或「候选人特质」的权重大于「意识形态」。分析时,不能用「温和」来预测他们的行为,而必须识别他们当下的「优先关切」。
---
## 政治倾向与投票模式:历次选举的轨迹
### 事实层:近十年的摆荡方向
台湾近十年的全国性选举,摆荡选民的流向呈现明显的「钟摆」与「惩罚」特征:
- **2014 与 2016**:摆荡选民大幅流向DPP与第三势力(时代力量)。驱动力为对KMT执政的「经济分配不均」与「两岸服贸争议」的惩罚。
- **2018**:摆荡选民剧烈摆向KMT(韩流)。驱动力为对DPP执政的「年金改革」与「公投议题操作」的反扑,以及「经济又好又快」的民粹诉求。
- **2020**:摆荡选民回流DPP。驱动力为「香港反送中」事件触发的「主权危机感」,压过了对内政的不满。
- **2022**:摆荡选民再次摆向KMT(地方选举)。驱动力为对DPP执政的「论文抄袭」、「黑金争议」与「防疫后期疲乏」的惩罚。
- **2024**:摆荡选民分裂。部分浅蓝摆荡流向TPP(柯文哲),部分浅绿摆荡因「战争恐惧」与「执政包袱」未回流DPP,导致DPP仅得40.05%,TPP获得26.46%的非传统选票。
详见 `events/recurring_patterns/pattern_pendulum_swings.md`。
### 脉络层:决策时点——最后两周的决断
摆荡选民的投票决定时间点高度集中于选举日的最后两周,甚至最后三天。
**机制解释**:
1. **风险趋避的延迟**:摆荡选民缺乏政党认同的「默认选项」,投票意味着承担选择错误的风险(如投给贪腐者、投给会引发战争者)。延迟决定是为了收集更多信息以降低风险。
2. **信息沉淀的需要**:选战前期的资讯多为噪音(口号、攻击),摆荡选民需要等待「关键时刻」的信号(如封关民调、弃保呼吁、重大事件)来形成判断。
3. **社会网络的催化**:许多摆荡选民在最后时刻受家庭、职场或同侪的「投票指示」影响,这种人际动员通常在选前最后几天达到高峰。
**分析含义**:选前一个月的民调领先,对摆荡选民而言只是「参考信息」,不是「决定因素」。最后两周的议题设定(如北京是否军演、候选人是否失言)才是决定摆荡方向的关键。详见 `primitives/concepts/strategic_voting.md`。
### 候选人个人特质的高度敏感
摆荡选民对政党的忠诚度低,因此对「候选人个人特质」的权重极高。他们评估候选人的核心维度包括:
| 评估维度 | 摆荡选民的关注点 | 负面触发(导致流失) |
|---|---|---|
| 能力与执行力 | 能否解决经济与民生问题? | 空洞论述、被贴上「草包」标签 |
| 诚信与清廉 | 是否会贪污?是否说谎? | 重大贪腐指控、论文抄袭、反复无常 |
| 稳定与安全感 | 是否会引发战争或社会动荡? | 激进言论、被贴上「麻烦制造者」标签 |
| 亲和与接地气 | 是否理解普通人的痛苦? | 权贵姿态、傲慢、脱离现实 |
**分析含义**:摆荡选民的投票常是「负面淘汰」——他们不是投给最好的人,而是投给「最不糟糕」的人。因此,选战末期对摆荡选民最有效的攻击,不是证明「我的政策比你好」,而是证明「对方的人格/能力有致命缺陷,选他会带来灾难」。
---
## 核心议题敏感点:什么驱动了摆荡?
### 事实层:经济与施政评价的硬约束
经济议题是摆荡选民最稳定的驱动力,尤其是与个人切身利益相关的「微观经济」感受。
- **房价与物价**:这是2024年以来摆荡选民最关注的议题。高房价造成的相对剥夺感,是年轻摆荡选民流向TPP的核心原因。
- **薪资停滞**:名义薪资成长追不上通膨的感受,直接转化为对执政党的惩罚。
- **贫富差距**:对「政府只照顾财团」的感知,驱动反建制情绪。
**施政评价的惩罚机制**:摆荡选民对执政党的施政评价呈现「不对称性」——好的经济表现未必能转化为选票(被视为理所当然),但差的经济表现必然被惩罚。这种「负面偏误」使得执政党在争取摆荡选民时处于结构性劣势。
### 脉络层:两岸议题——风险计算的终极测试
两岸议题对摆荡选民而言,不是意识形态的信仰,而是「风险与利益的权衡」。
**摆荡选民的两岸态度光谱**:
他们不是「统一」与「独立」的中间点,而是「维持现状」的极端捍卫者。根据政大选研中心长期追踪,超过八成的台湾民众偏好「维持现状」(包含永远维持、以后再决定、维持现状再独立等),摆荡选民高度集中于「维持现状再决定」与「永远维持现状」两组。
**对北京的态度**:
- **经济面**:务实。许多中年以上的摆荡选民(特别是中小企业主与农渔民)承认经济上无法完全脱离对中依赖,ECFA的终止会实质影响其利益。
- **政治面**:排斥。对北京的军事威慑与外交打压有强烈反感,但这种反感未必转化为对DPP的支持(因为DPP的抗中可能被视为「挑衅」而增加战争风险)。
**对交流的态度**:
支持「有尊严的交流」,反对「卖台式的依赖」。当KMT被塑造为「为了经济可以牺牲主权」时,摆荡选民会流失;当DPP被塑造为「为了抗中而断绝所有交流」时,摆荡选民同样会流失。
**分析含义**:两岸议题对摆荡选民的作用是「门槛」而非「引擎」。和平是默认前提,在此前提下,经济与内政才是决定票投谁的关键。但若某方候选人打破了和平的预期(如北京军演或候选人提出激进统独主张),两岸议题就会瞬间升等为唯一决定因素,此时「避战」本能会压倒一切。
---
## 国际关系态度:对美方的疑虑与依赖
### 事实层:疑美论的温床
摆荡选民对美方的态度呈现矛盾结构:安全上高度依赖,但情感上存在深刻疑虑。
- **安全依赖**:多数摆荡选民认知到,台湾面对北京压力,美方的军事与外交支持是不可或缺的。
- **利益疑虑**:对美方是否「利用台湾制衡中国」、是否「高价卖武器给台湾」、是否「最终会弃台」存在普遍的不安。这种不安是「疑美论」在摆荡选民中发酵的土壤。
- **贸易与经济**:对台美贸易协定抱有期待,但对美猪美牛等涉及食安与国内产业冲击的议题极度敏感。
### 脉络层:不对称同盟的心理负担
摆荡选民对国际关系的认知,受「不对称依赖」的心理影响。他们既不满KMT过度亲中的路线,也担忧DPP过度亲美可能将台湾卷入大国冲突。
**多视角并陈**:
- **DPP的诠释**:亲美是确保台湾安全与国际空间的唯一路径,疑美论是北京的认知战。
- **KMT的诠释**:亲美友日和陆才是平衡之道,过度依赖单方将使台湾失去自主性。
- **摆荡选民的实际感受**:希望有安全感,但不想当棋子。他们最怕的是「被卷入一场不想打的战争」。
**分析含义**:当美方对台发出强烈警告(如疑赖论、要求增加军费)时,摆荡选民的反应不是更团结支持政府,而是可能增加对执政党外交能力的怀疑。分析时不能假设「美方背书=摆荡选民支持」,有时美方的过度施压反而会引发摆荡选民的逆反心理。
---
## 信息行为与认知机制:脆弱与韧性
### 事实层:信息接触渠道与偏好
摆荡选民的信息行为呈现「跨平台游牧」特征,缺乏单一政党的「信息茧房」保护,但也因此暴露于多元甚至冲突的讯息中。
| 年龄层 | 主要信息渠道 | 信任来源 | 内容偏好 |
|---|---|---|---|
| 20-39岁 | YouTube、Threads/IG、Dcard | 网络KOL、同温层朋友、查核平台 | 短影音、讽刺时政、事实查核 |
| 40-59岁 | Line群组、电视新闻、Facebook | 地方意见领袖、传统媒体名嘴 | 懒人包、情绪共鸣、经济民生讯息 |
| 60岁以上 | 电视新闻、广播、Line | 邻里长、宫庙系统、家族意见领袖 | 健康养生、战争恐惧、经济安定 |
### 脉络层:认知脆弱点
摆荡选民由于缺乏坚定的意识形态过滤网,在特定叙事面前极度脆弱:
1. **战争与和平的恐惧叙事**:「投X就是投战争」的论述对摆荡选民(特别是中年以上、有资产需保护者)具有强大的威慑力。这种恐惧不需要逻辑证明,只需不断重复并配合北京的军演动作即可生效。
2. **经济崩坏的恐慌叙事**:「再投DPP,台湾经济会像XX一样」或「ECFA断了,百业萧条」的论述,对受景气影响的选民有直接动员力。
3. **贪腐与黑金的道德叙事**:对执政党「吃铜吃铁」的指控,极易引发摆荡选民的相对剥夺感与愤怒。
4. **社会公平的剥夺叙事**:高房价、低薪、贫富差距的控诉,是吸引年轻摆荡选民流向第三势力的最强力磁铁。
### 脉络层:认知韧性
摆荡选民并非全然被动,以下因素构成其抵抗操弄的韧性:
1. **教育程度**:高学历摆荡选民(特别是都会区)具备较强的信息查核能力,对明显的假讯息有免疫力。
2. **信息源多元**:不依赖单一媒体,习惯交叉比对者,较难被单一叙事洗脑。
3. **生活经验的反证**:若个人经济状况良好,宏观的「经济崩坏」叙事就难以生效;若周遭无战争迹象,极端的「亡国感」叙事也会被怀疑。
4. **对政治人物的普遍不信任**:这种犬儒态度虽不利于民主深化,但反而构成对特定阵营过度吹嘘的防线。
**分析含义**:分析摆荡选民的信息行为时,必须区分「信息暴露」与「信息接受」。他们可能暴露在大量的Line长辈图或YT演算法推送中,但最终是否接受,取决于该叙事是否与其「切身生活经验」共振。脱离生活经验的极端叙事,反而会触发认知韧性。
---
## 近年变化趋势:5-10年的结构性演变
### 事实层:第三势力对摆荡池的重塑
过去十年,台湾摆荡选民的最大结构性变化,是从「蓝绿之间的摆荡」转向「建制与反建制之间的摆荡」。
- **2014-2016**:摆荡选民以「公民运动」形式出现,支持第三势力(社民党、时代力量)作为对KMT的惩罚,但本质上仍在「本土/进步」光谱上,与DPP有合作空间。
- **2018-2020**:韩流崛起,摆荡选民呈现「民粹反建制」特征,部分与DPP的「抗中保台」动员对撞。
- **2022-2024**:柯文哲与TPP成功吸纳了「反蓝绿恶斗」的摆荡选民。这群人不再是在蓝绿间游移,而是将蓝绿视为同一块腐败建制派,寻求体制外的替代选项。
### 脉络层:年轻世代的「无历史包袱摆荡」
40岁以下的摆荡选民(特别是首投至三次投的族群),其政治社会化过程缺乏「党国威权」与「民主抗争」的二元对立记忆。
- **对DPP的观感**:不再是「民主前辈」,而是「执政既得利益者」。
- **对KMT的观感**:不再是「外来威权」,而是「无能且亲中的老政党」。
- **对北京的观感**:缺乏「血浓于水」的幻想,但也未经历1996飞弹危机的恐惧,对北京的认知更多来自香港反送中与日常网络信息,呈现「反感但不想为此打仗」的务实态度。
**结构性命题**:年轻摆荡选民的「无历史包袱」,意味着传统的「统独动员」与「悲情召唤」对他们几乎无效。他们的投票逻辑是「后物质主义」的——关注居住正义、环境保育、数位人权、性别平等,以及最核心的「反权威」与「公平分配」。谁能掌握这套新逻辑,谁就能固化这群原本最不稳定的摆荡者。
详见 `primitives/frameworks/voter_behavior_analysis.md`。
---
## 摆荡的方向性偏好:地理与阶层解析
### 事实层:不是所有摆荡都在蓝绿间
摆荡选民的流向具有高度的地理与阶层异质性,不能以单一模型概括。
| 选区类型 | 摆荡选民结构 | 摆荡方向偏好 | 关键驱动议题 |
|---|---|---|---|
| 台北市/新北市都会区 | 高学历、高所得、高资讯流 | 蓝绿白三方游移,偏好专业形象与进步价值 | 城市治理、国际能见度、反黑金 |
| 桃竹苗科技廊带 | 科技业员工、新移民 | 蓝绿间微幅摆荡,对经济极度敏感 | 产业政策、两岸稳定、交通建设 |
| 中彰投农业区 | 农工阶层、传统地方派系 | 浅绿与浅蓝间摆荡,易受地方派系动员 | 农产品外销(ECFA)、地方建设 |
| 南高屏工业区 | 传统绿营铁票区的松动者 | 绿转白或绿转不投票,极少转蓝 | 经济分配、对DPP执政失望但拒投KMT |
| 花东离岛 | 依赖中央资源分配的边缘选民 | 蓝转白或蓝转不投票,极少转绿 | 观光交流、中央补助、交通医疗 |
### 脉络层:方向性偏好的分析含义
**中等模型自己想不到的洞察**:摆荡选民的方向性偏好,揭示了台湾选举的「非对称流动性」。选民从绿转蓝的门槛,远高于从蓝转绿;但选民从蓝绿转白(反建制)的门槛,目前是最低的。这意味着,TPP的崛起不是吸收了「中间选民」,而是打开了传统蓝绿基本盘的「泄压阀」。当KMT表现不佳时,浅蓝选民不是流向DPP(意识形态过远),而是流向TPP(同为反绿阵营但非传统蓝);同理,浅绿选民对DPP不满时,也可能流向TPP(同为进步价值但非传统绿)。这种「第三方泄压」机制,打破了传统钟摆效应中蓝绿直接互换的假设。
详见 `events/recurring_patterns/pattern_blue_green_return.md`。
---
## 关联分析
### 关联一:摆荡选民与战略投票的交互
- 功能:提供脉络层与用于对照
- 说明:`primitives/concepts/strategic_voting.md` 解释了选民在选制约束下的弃保行为。摆荡选民是战略投票的最主要执行者——因为他们缺乏认同约束,更容易根据「胜算计算」改变投票对象。然而,分析时必须区分:摆荡选民的战略投票是「理性的风险趋避」,还是「被弃保叙事操纵的恐惧反应」?若为前者,民调信息是关键;若为后者,情绪动员是关键。
### 关联二:摆荡选民与基本盘回归的燃料关系
- 功能:提供事实层与机制解释
- 说明:`events/recurring_patterns/pattern_blue_green_return.md` 描述了选战末期第三势力支持度回流至传统大党的现象。摆荡选民正是这股「回归潮」的燃料。当危机感升高,原本游移至第三势力的摆荡选民会因风险趋避而回流传统大党。分析2028时,评估基本盘回归的幅度,本质上是在评估「摆荡选民对危机的恐惧程度」。
### 关联三:摆荡选民与钟摆模式的微观基础
- 功能:提供脉络层
- 说明:`events/recurring_patterns/pattern_pendulum_swings.md` 描述了台湾选举的周期性政党轮替。钟摆之所以能摆动,正是因为摆荡选民对执政党的「惩罚机制」——施政满意度下降,摆荡选民转向在野党。理解摆荡选民的利益计算,是预测钟摆幅度的基础。
### 关联四:摆荡选民与民调方法学的盲区
- 功能:提供事实层与用于交叉验证
- 说明:`primitives/frameworks/polling_methodology_analysis.md` 揭示了民调的误差来源。摆荡选民是民调中最难捕捉的群体——他们常被归入「未决定」或「拒答」,但其真实规模可能被低估(因隐性基本盘伪装成摆荡者)或高估(因深蓝深绿拒答而被算入未决定)。分析民调时,必须对「未决定」群体进行结构拆解,而非假设他们最终会按现有比例分配。
### 关联五:摆荡选民与投票行为微观模型的适配
- 功能:提供脉络层
- 说明:`primitives/frameworks/voter_behavior_analysis.md` 提供了选民决策的微观模型。摆荡选民的行为最不符合「政党认同模型」,而更接近「理性选择模型」(评估利益)或「议题投票模型」(受单一议题驱动)。但在高风险情境下,他们的行为又会退化为「情感动员模型」(恐惧与愤怒)。分析时需根据情境切换模型。
---
## 常见的分析陷阱
### 陷阱一:假设摆荡选民是「温和的中间派」
将摆荡选民等同于意识形态光谱的中间点,是最常见的错误。许多摆荡选民在统独议题上可能有强烈偏好(如深绿但反贪腐,或深蓝但反权贵),他们之所以摆荡,是因为其他议题(经济、清廉)的权重暂时压过了统独。用「温和」策略去争取他们,反而可能因为缺乏鲜明立场而失去他们的信任。
### 陷阱二:将民调的「未决定」全数归为摆荡选民
民调中的「未决定」包含大量不愿表态的深蓝深绿(隐性基本盘)、尚未关注意政治的冷感选民,以及真正的摆荡者。若将「未决定」全数视为摆荡选民,会严重高估摆荡规模,并误判选情的流动性。必须通过交叉分析(如过去投票记录、年龄层)来拆解「未决定」的内部结构。
### 陷阱三:假设两岸危机必然有利于DPP
传统分析认为,北京施压会激发「亡国感」,使摆荡选民倒向DPP。但2024的局部经验显示,若摆荡选民将危机归咎于「执政者的挑衅」或「无法处理两岸关系」,他们可能因「避战本能」而转向承诺「交流避战」的KMT或TPP。危机对摆荡选民的影响,取决于他们如何「归因」——归因于北京的侵略,则DPP受益;归因于执政的无能,则DPP受害。
### 陷阱四:忽略「摆荡至不投票」的隐形出口
摆荡选民的不满不一定转化为「投给对手」,更常转化为「拒绝投票」。当所有选项都无法满足其最低门槛(如既不满DPP的执政,也不信任KMT的亲中,更不认为TPP有能力),摆荡选民会选择「退出」。这表现为投票率的下降,而非对手得票的上升。忽略此出口,会误判对手的动员力。
### 陷阱五:用静态模型预测动态的摆荡选民
摆荡选民的偏好是「形成中」的,而非「固定但隐藏」的。在选前最后两周,一次重大的失言、一场意外的辩论表现、或一篇病毒式传播的懒人包,都可能重塑摆荡选民的风险计算。用选前一个月的民调数据线性外推摆荡选民的行为,忽略了其决策的「情境依赖性」与「临场性」。
### 陷阱六:假设年轻摆荡选民会随年龄增长「回归」蓝绿
传统政治学认为,选民随年龄增长、拥有资产后,会趋向保守而回归蓝绿基本盘。但台湾面临的是「结构性贫富差距」与「低薪常态」,年轻世代即使年龄增长,也难以复制上一代的资产积累路径。因此,他们因「经济安全感」增加而回归传统政党的机率可能低于历史常态。假设他们终将「成熟」而回归,是错把世代效应当成生命周期效应。
---
## 仍不确定的部分
### 不确定一:摆荡选民的真实规模与边界
由于缺乏追踪同一组选民历次投票行为的长期面板数据,我们无法精确知道有多少选民是「跨选举周期的真正摆荡者」,有多少是「偶发性的单次摆荡者」。民调的测量误差与拒答率,使得摆荡选民的规模估算始终存在正负5%的模糊空间。
### 不确定二:TPP能否固化年轻摆荡选民
2024柯文哲吸纳了大量年轻摆荡选票,但这究竟是「对柯文哲个人的魅力投票」,还是「对TPP作为反建制品牌的认同投票」?若2028柯文哲因司法案件无法参选,这群选民是会回流蓝绿,还是继续支持TPP的替代候选人?此为预测2028最大的不确定变量。
### 不确定三:北京干预策略对摆荡选民的实际效果
北京对台施压的强度与形式(军演 vs. 经济制裁 vs. 认知战),对摆荡选民的影响机制截然不同。目前尚无实证模型能精确量化「何种强度的军演」会触发「避战转向」,而「何种强度的军演」会触发「抗中团结」。此门槛因人而异,且随国际局势动态调整。
### 不确定四:AI生成内容与认知战对摆荡选民的影响
2028将是生成式AI技术更加成熟后的首次大选。针对摆荡选民定制的深度伪造内容、个性化政治广告、以及AI驱动的网络风向操作,其影响力与穿透率目前无法评估。摆荡选民的「认知脆弱点」是否会被新技术精准击穿,仍是未知数。
### 不确定五:经济议题与两岸议题的权重消长
在全球地缘政治重组与供应链迁移的背景下,台湾的经济表现可能与两岸关系脱钩(如对美出口增加弥补对中出口下降)。若此趋势持续,传统的「经济依赖中国→两岸和平」叙事在摆荡选民心中的权重是否会下降?这取决于未来几年台湾整体经济红利的分配方式。
---
## 与其他文件的关系
- 战略投票概念:`primitives/concepts/strategic_voting.md` —— 用于理解摆荡选民在选末期的弃保行为与风险计算
- 蓝绿基本盘回归模式:`events/recurring_patterns/pattern_blue_green_return.md` —— 用于分析摆荡选民作为回归燃料的流向
- 摆荡模式:`events/recurring_patterns/pattern_pendulum_swings.md` —— 用于理解摆荡选民驱动钟摆效应的宏观机制
- 投票行为分析:`primitives/frameworks/voter_behavior_analysis.md` —— 用于选择分析摆荡选民决策的微观模型
- 民调方法论分析:`primitives/frameworks/polling_methodology_analysis.md` —— 用于拆解民调中「未决定」群体的内部结构与测量偏差
---
## 一句话浓缩(重申)
> 摆荡选民不是无知的中间派,而是风险趋避的务实计算者——他们的摆荡不是随机噪音,而是对执政惩罚、战争恐惧与反建制情绪的结构性响应;分析2028的关键,不是问他们「偏好谁」,而是问他们「最恐惧什么」与「最厌恶什么」,因为在最后两周的决断中,恐惧与厌恶永远比希望更有驱动力。
---
last_updated: 2026-05-08
@@ -1,316 +0,0 @@
# 白營受眾
---
**類型**: 政治敘事檔案(受眾政治認同特化)
**當前角色**: 台灣第三勢力選民基礎的核心載體、2028 關鍵變動受眾、跨越傳統藍綠界線的負面聯盟
**出生時間**: 約 2014-2019 間逐步成形(伴隨柯文哲現象與網路政治崛起)
**何時調用此檔案**: 分析 2028 大選搖擺票與棄保效應;評估突發司法或兩岸事件對非傳統選民的衝擊;解讀網路聲量與實際投票行為的落差;規劃針對年輕、都會、高教選民的訊息穿透策略;預測 TPP 後柯時代的選民重組或崩解模式;分析「疑美論」與「避戰論」在非藍綠群體中的傳播路徑
---
## 一句話濃縮
> 白營受眾不是鐵板一塊的「柯粉」,而是由「反建制的網路青年」、「避險的理組中產」與「厭倦藍綠的游離者」構成的負面聯盟;他們以「治理理性」包裝情感投射,以「避戰保產」懸置統獨認同,在司法風暴下面臨「殉道者動員」與「誠信破產」的撕裂,是 2028 台灣政治板塊位移中最不穩定且最具決定性的變數。
---
## 為何此檔案對分析者重要
分析者最常犯的結構性錯誤,是將「白營受眾」等同於「柯粉」或「年輕選民」——預設他們具有高度同質性、都是非理性的狂熱支持者、都反中或都親中。這種簡化無法解釋白營受眾在司法風暴後的分化,也無法預測其在 2028 面對棄保壓力時的真實行為。
白營受眾實質上是三個異質性極高的次群體的「負面共識聯盟」:
1. **反建制青年**:恐懼被既有分配體系拋棄,對高房價與低薪憤怒,將政治視為「下剋上」的戰場。
2. **都會務實中產**:恐懼兩岸戰爭破壞財產,也恐懼意識形態干擾經濟理性,追求「效能治理」。
3. **跨陣營厭倦者**:恐懼藍綠繼續綁架國家機器,對統獨議題極度冷感,追求「正常化」的政治生活。
這三群人在「反對藍綠雙元壟斷」時高度重合,但在「TPP 取得權力或面臨危機」時,其內在衝突(如主權立場的表態、利益分配的優先序、對柯文哲個人的忠誠度)會迅速表面化。理解白營受眾,就是理解台灣政治從「認同政治」向「分配政治」過渡期的斷裂帶。
---
## 群體定義與邊界:負面聯盟的脆弱交集
### 事實層
白營受眾並非由單一政黨認同定義,而是由「排斥感」與「偏好」的交集定義。在識別標準上,此群體具備以下特徵:自述「非藍非綠」或「討厭藍綠惡鬥」、對傳統政黨政治高度不信任、高度依賴網路獲取政治資訊、在 2024 總統大選中將票投給柯文哲或政黨票投給民眾黨。
此群體的邊界是模糊且流動的,可依其黏著度分為三層:
1. **核心粉絲(深白,約佔 8-10%)**:對柯文哲具有強烈情感投射,將其視為政治救世主,對其缺點具備高度容忍度,甚至發展出防禦性的「理性化」論述。
2. **議題支持者(淺白/泛白,約佔 10-12%)**:認同 TPP 的特定訴求(如居住正義、政府效能、兩岸避戰),對柯文哲本人有好感但非盲目追隨,具備較高的批判性。
3. **跨黨流動者(白流,約佔 6-8%)**:純粹因「反綠」或「反藍」情緒而暫時棲息,缺乏對白營的理念認同,隨時可能因局勢變化或棄保效應而回流傳統陣營。
### 脈絡層
**結構性命題**:白營受眾是台灣從「認同政治」向「分配政治」過渡期的斷裂帶產物。
過去三十年,台灣政治以統獨與國家認同為主要切割軸線。白營受眾的出現,標誌著一批選民試圖用「治理理性」與「分配正義」來懸置「統獨認同」。他們認為藍綠的統獨爭議是「無解的哲學問題」,而房價、薪資、詐騙才是「可優化的工程問題」。然而,這種懸置是脆弱的——當地緣政治壓力升高時,他們被迫在「避戰保產」與「主權存續」間做出選擇,其內在的逃避主義便會瞬間破功。
#### 分析含義
分析時如何用這個事實?
- **修正「白營等於中間選民」的預設**:白營受眾不是溫和的中間選民,而是帶有強烈情緒(憤怒、焦慮、厭倦)的極端中間派。他們對傳統政治的仇恨值極高,動員他們的不是理性說服,而是情感共鳴與敵我識別。
- **計算棄保效應的啟動閾值**:跨黨流動者是棄保操作的最主要目標。在 2028 大選中,若藍綠對決態勢明確,且「政黨輪替」或「守住本土」的危機感被成功動員,此群體會最先發生「含淚棄保」。
---
## 人口結構與估算:理組思維與男性焦慮的投射
### 事實層
2024 年總統大選,柯文哲獲得 26.46% 選票(約 369 萬票),政黨票獲得 22.07%。這構成了白營受眾的最大公約數。其人口結構呈現極端特徵:
| 維度 | 特徵 | 數據/現象(以 2024 為基準) | 結構意義 |
|---|---|---|---|
| 年齡 | 極度年輕化 | 20-39 歲支持率遠高於平均,20-29 歲區段甚至超過藍綠總和 | 首投族與職場初期群體構成聲量引擎,但投票率受返鄉成本影響大 |
| 教育 | 高教偏斜 | 大學及以上學歷支持率顯著高於平均,呈現極端「理組偏斜」 | 理工/科技業背景比例異常高,人文社會背景者多留在綠營 |
| 性別 | 男高女低 | 男性選民(特別 20-49 歲)支持率顯著領先,女性選民大幅落後 | 社群文化帶有「厭女」與「反政治正確」色彩,形成性別天花板 |
| 地域 | 都會集中 | 台北市、新竹縣市、桃園市核心區得票率超過 25%,農業縣低於 15% | 與科技業聚落及高房價區高度重疊,鄉村幾乎真空 |
| 職業 | 科技/白領為主 | 工程師、醫師、會計師等專業人士,以及服務業基層人員 | 經濟理性與相對剝奪感的矛盾結合體 |
### 脈絡層
**破除常見簡化**:把白營受眾的「高學歷」等同於「進步價值支持者」是嚴重的分析錯誤。
白營的高學歷支持者呈現極端的「理組思維」特徵:重視效率、相信數據、討厭模糊地帶、將意識形態視為「非理性變數」。在民進黨以主權與進步價值作為執政正當性來源、國民黨以歷史情感與兩岸連結作為號召的結構下,理組選民感到雙重被剝奪:他們的經濟貢獻被政治議題稀釋,而他們關心的治理效能被意識形態犧牲。
同時,白營受眾的「男性偏斜」本質上是「失敗男性的政治避難所」。支持白營的年輕男性,面臨著雙重結構性危機:在經濟上無法複製父輩的「養家活口」模式;在性別關係上,女性在教育與職場上的崛起,以及進步價值對傳統男性氣概的解構,讓他們產生強烈的相對剝奪感。柯文哲的「直白」、「不修飾」、「反政治正確」風格,成為他們宣洩焦慮的出口。
#### 分析含義
分析時如何用這個事實?
- **識別訊息穿透的頻率**:對白營受眾訴求「進步價值」或「主權危機」是對牛彈琴,必須訴求「治理效能」、「經濟理性」與「分配正義」。任何被證明「缺乏數據支持」或「效率低落」的政策,都會遭到此群體的嚴厲檢驗。
- **計算白營的性別天花板**:只要白營的論述與社群文化持續帶有「反政治正確」與「攻擊女性」的色彩,其支持度就存在一個堅硬的女性天花板,這使得其在全國性大選中極難突破 30%。
- 交叉引用:此理組思維與性別焦慮的養成,與 `narratives/domestic/youth_political_alienation.md` 中的後物質主義反挫型態密切相關。
---
## 跨藍綠厭倦的核心動機:反建制與分配焦慮
### 事實層
白營受眾跨藍綠的核心動力,不是對第三勢力的理念認同,而是對藍綠雙元壟斷的深度厭倦。此厭倦源於三個具體的現實挫折:
1. **分配正義的停滯**:民進黨執政八年,房價所得比創歷史新高,實質薪資停滯,年輕世代面臨「無殼蝸牛」的結構性困境。
2. **治理效能的低落**:從能源政策的不穩定(影響供電)到詐騙氾濫、黑道治國印象,選民對國家機器的運作效率深感不耐。
3. **意識形態的綁架**:藍綠在統獨與國家認同上的無休止爭鬥,讓關心日常生存的選民感到「被綁架」——政治似乎只關乎主權,而不關乎生活。
### 脈絡層
**中等模型自己想不到的洞察**:白營受眾的「跨藍綠」,本質上是一種「政治消費者的避險行為」,而非「公民社會的超越」。
他們不是在尋找一個更好的政治理念,而是在尋找一個「性價比更高」的政治產品。國民黨太老舊且親中風險高,民進黨太意識形態且分配不公,白營則提供了一種「看似無風險」的替代方案:既不統也不獨,既不保守也不激進,主打「效能」與「清廉」。這種選擇是消費主義邏輯在政治領域的延伸——我買(投票)是因為你目前看起來最划算,一旦你出包(貪腐、無能)或別家打折(藍綠推出有吸引力的候選人),我立刻換品牌。
#### 分析含義
分析時如何用這個事實?
- **評估「背叛敘事」的殺傷力**:對白營受眾發動攻擊,最有效的路徑不是攻擊其政策,而是證明其「與藍綠分贓」或「效能低落」。一旦「反建制」與「高效率」的光環破滅,此群體會迅速撤資(選票與捐款),且不會轉向其他政黨,而是徹底政治冷感。
- **預測 2028 的回流壓力**:若 2028 藍綠推出能回應分配焦慮的候選人(如民進黨推出具居住正義形象的少壯派,或國民黨推出非典型的經濟務實派),白營的「消費性選民」會大量回流。
---
## 議題導向與個人崇拜的「理性化」論述
### 事實層
白營受眾在自我認知上,強烈排斥被貼上「柯粉」或「盲從」的標籤。他們發展出一套精密的「理性化」論述來支撐對柯文哲的支持:
1. **效能至上論**:「他雖然嘴巴壞,但事情做得好」(引用台北市長任期的 SOP、拆違建等事蹟)。
2. **務實避險論**:「他不像藍綠那樣意識形態掛帥,他會跟對岸溝通,不會引戰」。
3. **系統受害者論**:「他會被攻擊是因為他挑戰了整個利益結構,媒體和司法都是打手」。
這套論述將柯文哲的缺點(失言、反覆、爭議)重新框架為「政治素人的必要代價」或「對抗偽善體制的勳章」。
### 脈絡層
**結構性命題**:白營受眾的「理性化崇拜」是一種防禦機制——他們並非不知道柯文哲的缺陷,而是將承認缺陷等同於承認自己反建制的政治選擇失敗。
理組思維的選民無法接受自己是「被情感動員的非理性選民」,因此必須用「數據」、「SOP」、「效率」等工程語彙來包裝情感投射。這形成了一個「自我欺騙的閉環」:我支持他不是因為我盲從,而是因為他有效;當他無效或犯錯時,那是因為系統打壓,而非他個人的無能。這種閉環使得核心粉絲極難被傳統的負面訊息(如失言、貪腐指控)說服,因為任何負面訊息都會被系統性地過濾為「系統的迫害」。
#### 分析含義
分析時如何用這個事實?
- **設計訊息穿透策略**:要打破核心粉絲的防禦,不能直接攻擊柯文哲,而必須攻擊其「理性化論述」的基礎。例如,不是說「柯文哲貪腐」,而是用數據證明「京華城案造成的容積獎勵,讓台北市損失了多少公共財與財政理性」。用理組的語言擊潰理組的幻覺。
- **評估黃國昌的接班困境**:黃國昌缺乏柯文哲那種「素人直白」的特質,無法提供相同的情感投射。他試圖用「法律戰鬥」來替代「素人魅力」,但這只能吸引「反綠情緒票」,無法複製「理性化崇拜」的閉環。詳見 `entities/people/huang_kuo_chang.md`。
---
## 信息行為:YouTube 與社群媒體的同溫層強化和認知脆弱點
### 事實層
白營受眾的信息獲取高度依賴網路,幾乎與傳統媒體(電視新聞、政論節目)絕緣。其主要接觸渠道與偏好如下:
| 平台 | 功能 | 內容偏好 | 認知影響 |
|---|---|---|---|
| **YouTube** | 核心資訊源與長影片消費 | 柯文哲直播、政論網紅(如館長、比特王)、訪談節目 | 演算法推薦強化同溫層,長影片提供「深度理性」的錯覺 |
| **Threads / X** | 即時情緒動員與迷因傳播 | 短平快的嘲諷、反綠梗圖、即時新聞反應 | 情緒極化,形成「我們 vs 偽善的藍綠」的群體認同 |
| **PTT / Dcard** | 議題發酵與論辯 | 深度政治分析(常帶有理組推論)、爆料 | 促成「鄉民正義」的集體行動,但也易受假訊息操弄 |
### 脈絡層
白營受眾的認知脆弱點在於其「信息源的單一性與封閉性」。他們對傳統媒體的不信任,導致他們將信任轉移至網紅與同溫層。這使得他們極易接受以下類型的敘事:
1. **陰謀論與系統迫害論**:任何不利於白營的新聞,都被解讀為「綠媒抹黑」或「司法打壓」。
2. **簡化的敵我識別**:將複雜的政策爭議簡化為「理性(白) vs 意識形態(綠/藍)」的二元對立。
3. **數據的選擇性引用**:偏好引用支持自身立場的片段數據,忽略脈絡,形成「確認偏誤」。
然而,白營受眾也具備一定的**認知韌性**:其高教育程度與網路資訊檢索能力,使他們在面對明顯的假新聞時,具備一定的事實查核能力;同時,他們對「被賣台」的警惕性極高,這使得北京或特定勢力試圖直接滲透白營受眾時,常因觸發其避險機制而失敗。
#### 分析含義
分析時如何用這個事實?
- **預測訊息戰的效力**:在白營受眾中,傳統媒體的負面報導效力極低,甚至會產生「逆火效應」(強化其被迫害的信念)。要影響他們,必須透過他們信任的網紅或平台,使用他們的語言(數據、效率、反建制)。
- **識別外部勢力的操作路徑**:北京或特定勢力若要影響白營受眾,不會直接宣傳統一,而是會透過 YouTube 頻道放大「疑美論」、「民進黨引戰」與「分配不公」的敘事,精準打擊其認知脆弱點。詳見 `entities/media/youtube_political_channels.md`。
---
## 两岸與國際議題態度:避戰保產與孤立主義的混合體
### 事實層
白營受眾在兩岸與國際議題上的態度,呈現高度的實用主義與避險傾向,缺乏堅定的意識形態錨點:
1. **統獨立場**:壓倒性支持「維持現狀」。但他們的「維持現狀」不是戰略模糊,而是「不要改變現狀,不要惹事,也不要被統」的消極願望。
2. **對北京態度**:不認同中共制度,但強烈渴望「兩岸和平交流」。他們將 ECFA 與經貿連結視為「生存所需」,而非「政治統戰」。對北京的厭惡與對經濟利益的需求同時存在。
3. **對美方態度**:呈現「疑美」與「依賴」的矛盾。他們懷疑美國將台灣作為抗中棋子(疑美論的受眾基礎),但在安全上仍依賴美國的保護。對美國要求台灣延長兵役、購買武器感到不耐。
4. **兵役與國防**:對延長兵役強烈反感,這直接觸發了反建制青年的「被剝奪感」——他們認為自己是為上一代的無能外交買單。
### 脈絡層
**破除常見簡化**:把白營受眾的「避戰」等同於「投降主義」或「統派」是嚴重的分析錯誤。
白營受眾支持兩岸和平,是基於「資產避險」與「生存恐懼」,而非對中國的認同。他們多數是「維持現狀」的堅定支持者,甚至比部分經濟選民更抗拒北京的制度強加。他們的邏輯是:「我不想打仗,因為我會失去一切;但如果你(北京)硬要打,我也只能抵抗。」這是一種「孤立主義」的變體——希望台灣能像新加坡一樣,在大國間務實遊走,既保住主權,又賺到錢。
#### 分析含義
分析時如何用這個事實?
- **評估地緣政治衝擊的效應**:若北京在 2028 前持續高強度施壓(如大規模軍演、經濟封鎖),白營受眾的「避戰」論述將被民進黨定調為「天真」,部分避險中產將回流綠營。但若北京採取低強度施壓,且民進黨執政引發強烈的「兵役焦慮」與「經濟邊緣化」恐慌,白營的「兩岸和平」訴求將精準擊中痛點。
- **識別「疑美論」的市場**:白營受眾是「疑美論」在台灣最大的潛在市場。但此疑美論不同於國民黨的親中反美,而是基於「不願成為烏克蘭」的恐懼。操作疑美論若過度親中,會瞬間觸發白營受眾的避險機制而遭到反噬。
---
## 司法風暴後的「流失 vs 留存」分化
### 事實層
2024 年大選後,柯文哲因京華城等案遭羈押起訴,這是白營受眾面臨的首次生存級壓力測試。此事件對三個次群體產生了截然不同的效應:
1. **反建制青年(留存並激進化)**:此群體將司法案件解讀為「當權者對挑戰者的政治迫害」,產生「悲情動員」效應。柯文哲的羈押反而強化了他們的「殉道者」想像,短期內支持度反而固化,甚至在網路上發起更激烈的攻防。
2. **都會務實中產(沉默與流失)**:此群體重視法治與誠信。當起訴書與金流證據陸續揭露,柯文哲的「務實」形象崩塌為「算計」,此群體開始沉默與退卻。他們不會轉向民進黨,但會停止捐款與公開表態。
3. **跨陣營厭倦者(崩盤與回流)**:此群體本就因「清新」而來,司法案件證明了「換人做做看」換來的只是另一種腐敗。他們迅速回流藍綠或轉為不投票。
### 脈絡層
**中等模型自己想不到的洞察**:司法風暴對白營受眾的打擊不是線性的,而是「分層撕裂」的。它摧毀了白營向中產與中間選民擴張的可能性,但同時將核心支持者(反建制青年)激進化,形成一種「鐵板化」效應。
這種分化導致白營受眾內部的話語權重組:流失的中產與游離者過去是白營「理性」門面的支撐,他們的退場使得白營的公共形象從「跨藍綠的理性選擇」滑向「深白的情緒動員」。留在場上的核心粉絲,其「政治迫害論」的音量被放大,這進一步加速了中間選民的疏離,形成惡性循環。
#### 分析含義
分析時如何用這個事實?
- **破除「司法案件必然毀滅政黨」的直覺**:白營不會瞬間泡沫化,但會從「潛在的執政挑戰者」退化為「深白鐵票團」。分析者不應只看整體民調的下降,而應看其內部結構的「質變」——從開放系統變為封閉系統。
- **評估後柯路線的選民基礎**:黃國昌等接盤者面臨的,是一個失去中產光環、只剩反綠情緒的基本盤。這解釋了為何後柯時期的民眾黨在立法院必須走向鷹派戰鬥路線——因為這是留存下來的受眾唯一能共鳴的語言。詳見 `entities/parties/tpp_post_ko.md` 與 `entities/people/ko_wen_je.md`。
---
## 近年變化趨勢與 2028 展望
### 事實層
白營受眾在過去 5-10 年經歷了劇烈的結構性演變:
1. **2014-2018**:以「墨綠」與「進步青年」為主,因柯文哲的素人形象與公開支持太陽花而聚集。
2. **2019-2020**:因香港反送中效應,部分進步青年回流民進黨,留下的是「反建制」與「兩岸避戰」傾向較強的群體,以及對民進黨執政失望的淺藍游離票。
3. **2024**:反建制情緒總爆發,吸納了大量因高房價、低薪而憤怒的青年,以及恐懼兩岸戰爭的都會中產,形成 26% 的最大公約數。
4. **2024 後(當代)**:司法風暴剝離了中產與游離者,留下激進化的深白青年與反綠鐵桿。
### 脈絡層
展望 2028,白營受眾是台灣政治板塊中最關鍵的變動受眾,其流向取決於三大變數:
1. **後柯時代的領導人能否承載「反建制」光環**:柯文哲的司法風暴意味著他親自參選 2028 的機率極低。任何繼任者都帶有「建制派」氣味,難以複製柯的狂熱效應。若新領導人無法維持青年支持,白營受眾將面臨雪崩。
2. **北京壓力與「避戰」論述的市場**:若北京持續高強度施壓,白營的「避戰」論述將被定調為「投降主義」,避險邏輯將撕裂聯盟;若北京低強度施壓,且民進黨引發「兵役焦慮」,白營將成為最大受益者。
3. **民進黨的分配正義成績單**:白營的興起本質上是民進黨內政失分的函數。若賴清德政府有效壓抑房價、提升實質薪資,白營受眾的憤怒將失去標的;若分配正義持續惡化,即使柯文哲不在,白營作為「抗議載體」的功能仍將存在。
#### 分析含義
分析時如何用這個事實?
- **預測 2028 的棄保效應**:白營受眾在 2028 的行為模式,將是「含淚棄保」與「死守第三勢力」的博弈。若國民黨推出強勢候選人,且民進黨執政引發強烈不滿,跨黨流動者與部分都會中產將流向國民黨;若國民黨候選人爭議極大,深白青年將死守民眾黨,避免選票被藍綠吸收。
- **評估白營的長期存續**:白營受眾的長期存續,取決於其能否將「反建制情緒」轉化為「具體利益綁定」。若民眾黨能在地方選舉中建立服務網絡,將選票組織化,則受眾可能固化為新的政治板塊;若持續依賴空戰與個人魅力,終將在常態化政治的沖刷下煙消雲散。
---
## 關聯分析
### 關聯一:與 TPP 選民基礎的連結(提供事實層與脈絡層)
- `entities/parties/tpp_voter_base.md`:白營受眾是 TPP 選民基礎的上位概念。TPP 選民是具備政黨認同的群體,而白營受眾包含了更廣泛的「非藍非綠」游離者。理解受眾的流動性,是解讀 TPP 民調起伏的鑰匙。
### 關聯二:與後柯路線的連結(提供脈絡層)
- `entities/parties/tpp_post_ko.md`:後柯時期的 TPP 路線選擇,直接反映了白營受眾在司法風暴後的分化。黃國昌的鷹派路線是對留存受眾(深白青年、反綠鐵桿)的回應,但也加速了流失受眾(中產、游離者)的背離。
### 關聯三:與柯文哲個人的連結(提供事實層)
- `entities/people/ko_wen_je.md`:柯文哲是白營受眾「理性化崇拜」的核心對象。其司法案件的進展,直接決定了「殉道者敘事」的強度與受眾的動員模式。
### 關聯四:與黃國昌領導風格的連結(提供對照與交叉驗證)
- `entities/people/huang_kuo_chang.md`:黃國昌的「戰鬥路線」與柯文哲的「素人路線」形成對照,這種風格轉換正在重塑白營受眾的邊界——排斥中間選民,吸納深藍化青年。交叉比對黃的發言記錄與受眾的情緒反應,可驗證此假設。
### 關聯五:與年輕疏離敘事的連結(提供脈絡層)
- `narratives/domestic/youth_political_alienation.md`:白營受眾的核心動力之一是「年輕疏離」。理解此敘事的傳播路徑與內容,是預測反建制青年在 2028 歸屬的關鍵。
### 關聯六:與 YouTube 政治頻道的連結(提供事實層)
- `entities/media/youtube_political_channels.md`:YouTube 是白營受眾的核心資訊源。分析特定頻道的演算法推薦邏輯與內容偏好,可揭示白營受眾認知脆弱點的形成機制。
---
## 常見的分析陷阱
### 陷阱一:將「白營受眾」等同於「柯粉」
這是最常見且最危險的錯誤。白營受眾中只有約 8-10% 是死忠的柯粉,其餘是議題支持者與跨黨流動者。將整個群體視為非理性的狂熱分子,會嚴重誤判其在棄保壓力下的理性計算能力,也會錯估司法案件對其內部不同群體的差異性衝擊。
### 陷阱二:假設「年輕人會永遠支持白營」
年輕人對白營的支持是「條件式」的,基於憤怒與相對剝奪感。若民進黨提出有效的分配正義政策,或白營自身陷入傳統政治的分贓與腐敗,年輕人會毫不猶豫地拋棄白營,轉向不投票或尋找下一個「反建制載體」。白營沒有「鐵票」。
### 陷阱三:忽略「理組思維」的特殊性
將白營的高學歷支持者與民進黨的高學歷支持者混為一談是錯的。民進黨的高學歷支持者多為文組,重視價值與理念;白營的高學歷支持者多為理組,重視效能與數據。對白營受眾訴求「進步價值」是對牛彈琴,必須訴求「治理效能」與「經濟理性」。
### 陷阱四:認為司法案件會讓白營瞬間泡沫化
司法案件對白營的打擊是分化的,而非整體的。它會趕走中產與游離票,但會激化核心青年的「悲情動員」。白營不會消失,但會從一個「全民政黨」退化為一個「深白鐵票政黨」,其影響力從「決定勝負」降級為「干擾選情」。
### 陷阱五:將「避戰」等同於「統派」
白營受眾支持兩岸和平,是基於「經濟利益」與「生存恐懼」,而非對中國的認同。他們多數是「維持現狀」的堅定支持者,甚至比部分經濟選民更抗拒北京的制度強加。將白營的「避戰」論述簡化為「親中賣台」,是忽略了其受眾基礎中的避險邏輯。
### 陷阱六:高估白營的「空軍轉陸軍」能力
白營受眾高度依賴網路動員,缺乏實體組織。在總統大選與不分區政黨票中,空軍可以發揮極大效用;但在地方選舉與區域立委選舉中,缺乏陸軍意味著無法固票與配票。用總統大選的得票率來推估白營的地方實力,會產生嚴重的高估。
---
## 仍不確定的部分
### 柯文哲司法案件的終局效應
目前仍不確定柯文哲司法案件的最終判決結果,以及審判過程中是否會出現更具毀滅性的證據(如明確的金流交代)。若最終判決有罪且定讞,白營的「反貪腐」招牌將徹底粉碎,都會中產的流失將不可逆;若最終無罪或輕判,則可能引發更大規模的「政治迫害」動員,反而強化深白鐵票區。
### 後柯時代領導人的市場接受度
黃國昌、黃珊珊等潛在接班人,能否承接柯文哲的「反建制」光環?黃國昌的「戰鬥藍」色彩能否吸引淺綠青年?黃珊珊的「市政」形象能否說服都會中產?目前缺乏足夠的選舉數據來驗證這些接班人在全國性舞台的吸票能力。
### 北京對白營的策略定位
北京是否會將白營視為可操作的「第三勢力」而進行精準拉攏(如透過特定媒體或樁腳釋放利多),還是將其視為不可控的變數而加以打壓?若北京公開表態欣賞白營的某種路線,將在瞬間引爆白營受眾(特別是青年與中產)的避險機制,造成毀滅性打擊。此變數完全掌握在北京手中,目前無法預測。
### 「理組思維」選民的長期政治行為
這群以科技業為主的高學歷選民,過去在台灣政治中從未形成如此明確的板塊。他們是會隨著年齡增長、資產累積而轉向國民黨的傳統保守路線,還是會持續作為一個要求「治理效能」的獨立變數?這缺乏歷史先例,目前難以推斷。
---
## 與其他文件的關係
- `entities/parties/tpp_voter_base.md` —— 提供 TPP 選民基礎的次群體結構與數據支撐,本檔案為其受眾心理與行為模式的深度展開
- `entities/parties/tpp_post_ko.md` —— 提供後柯時期 TPP 的路線選擇與組織變化,本檔案提供其選民基礎的壓力與制約
- `entities/people/ko_wen_je.md` —— 提供柯文哲個人的特質與司法案件事實,本檔案分析其受眾對此的心理防禦與合理化機制
- `entities/people/huang_kuo_chang.md` —— 提供黃國昌的領導風格與立院戰術,本檔案分析此風格對白營受眾邊界的重塑效應
- `narratives/domestic/youth_political_alienation.md` —— 提供年輕疏離的敘事結構,本檔案分析此敘事在白營受眾中的具體投射與動員路徑
- `entities/media/youtube_political_channels.md` —— 提供 YouTube 政治頻道的傳播邏輯,本檔案分析其作為白營受眾核心資訊源的認知影響
---
## 一句話濃縮
> 白營受眾是「反建制憤怒」、「理組治理焦慮」與「藍綠厭倦」構成的負面聯盟,以理性化論述包裝情感投射,以避戰保產懸置統獨認同,在司法風暴下面臨殉道者動員與誠信破產的深層撕裂;預測 2028 的關鍵,不在於白營有多少鐵票,而在於這股跨藍綠的分配焦慮能否在柯文哲退場後找到新的載體,還是終將在棄保壓力與常態化政治的沖刷下解體回流。
---
last_updated: 2025-05-20
@@ -1,15 +0,0 @@
# audiences/social_identity/ 社会身份受众
按职业、族群和社会身份划分的受众档案,用于分析不同社会群体在两岸、经济、身份、外交和选举议题上的接受结构。
## 文件清单
- `military_civil_education.md` — 军公教族群受众分析
- `industrial_workers.md` — 产业工人族群
- `tech_sector.md` — 科技业从业人员
- `service_industry.md` — 基层服务业从业人员
- `farmers_fishers.md` — 农渔民族群受众分析
- `mainlander_community.md` — 外省/眷村族群受众分析
- `hoklo_community.md` — 本省闽南族群
- `hakka_community.md` — 客家族群受众分析
@@ -1,299 +0,0 @@
# 農漁民族群受眾分析
---
**類型**: 政治敘事(社會身分與受眾特化檔案)
**當前角色**: 台灣農漁民族群的政治行為、經濟脆弱性與兩岸敘事接收端
**何時調用**: 分析北京經濟脅迫的實際衝擊與政治反饋;評估 ECFA 議題在 2028 的選舉效力;預測中南部地方派系與農漁會系統的動員方向;解讀農產品外銷受阻事件中的輿論戰;規劃針對農漁民的政策論述或認知作戰防禦
---
## 一句話濃縮
> 農漁民族群是北京經濟脅迫的「最前線肉墊」與地方派系動員的「基層樁腳」,其政治本質是「極端市場脆弱性」與「深層在地依附性」的結合——這使得他們在兩岸關係中既是最容易被經濟痛點擊穿的標靶,也是最容易因「被霸凌」而觸發防禦性本土意識的反彈主體。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「農漁民」視為單純的「經濟理性選民」——預設他們因為產品依賴中國市場,就必然在政治上傾向對北京妥協,或必然將生計受損的憤怒轉移給執政的 DPP。這種簡化完全誤判了農漁民族群的政治心理結構。
農漁民的政治行為,實質上受制於三重結構的拉扯:
1. **生存脆弱性**:看天吃飯、保質期短、單一市場依賴度高,對價格波動與市場封閉極度敏感。
2. **在地依附性**:生活圈與地方派系、農漁會網絡高度重疊,社會關係即政治關係。
3. **尊嚴防禦性**:當外部壓迫(如北京禁令)被感知為「政治打壓」而非「正常貿易摩擦」時,容易觸發「被欺負」的相對剝奪感,進而轉化為防禦性的本土認同。
理解農漁民族群,就是理解北京「以商圍政」、「以農逼政」策略的效用極限,以及 DPP「抗中保台」論述在基層經濟受挫時的韌性與裂縫。
---
## 群體定義與邊界:萎縮中的物理基礎與放大了的符號意義
### 事實層
台灣農業就業人口約 50-60 萬,漁業就業人口約 30 萬,合計約 80-90 萬人。若納入家屬,直接或間接依賴農漁業維生的人口約 200-250 萬,佔總選民比例約 10-12%。然而,此群體正以極快速度萎縮與老化:農民平均年齡已超過 65 歲,漁民平均年齡約 55 歲,且青年接班意願低落。
地域分布高度集中於中南部與東部:雲林縣、嘉義縣、台南市、高雄市、屏東縣、宜蘭縣、花蓮縣、台東縣。這些縣市同時也是地方派系與農漁會系統的傳統勢力範圍。
群體內部存在顯著異質性:
- **農民**:以種植果樹(鳳梨、釋迦、蓮霧)、蔬菜、水稻為主。面對的是病蟲害檢疫禁令(如北京宣稱檢出介殼蟲)。
- **漁民**:分為遠洋漁業(資本密集、國際法規約束)與沿近海及養殖漁業(石斑魚、虱目魚、魷魚)。面對的是活魚運輸船限制與藥物殘留檢測禁令。
### 脈絡層
**結構性命題**:農漁民的「物理萎縮」與其「政治符號權重」呈現反向背離。
雖然農漁民人口持續減少,但他們在兩岸政治論述中的符號意義卻被極大化。北京將農漁民視為「兩岸融合利益」的示範區與「逼統」的施力點;DPP 則將農漁民受挫形塑為「中國打壓台灣基層」的悲情敘事。農漁民不再是單純的經濟從業者,而是兩岸認知戰的「符號載體」。
#### 分析含義
分析時如何用這個事實?
- **修正人口比例的預測權重**:在預測全國性選舉時,不能僅按農漁民人口比例(約 10%)計算其影響力。必須乘以其「媒體符號效應」與「地方派系動員乘數」。一個鳳梨禁令對中南部選情的衝擊,遠大於 10% 人口流失的數學意義。
- **識別政策資源的錯置**:政府對農漁民的補貼與救助,往往基於其「符號意義」而非「經濟規模」。分析政策時,應區分「實質救濟」與「政治公關」。
---
## 政治傾向與投票模式:利益依賴與派系動員的雙重奏
### 事實層
農漁民的投票行為長期呈現「藍大於綠」的基本盤特徵,但此特徵正在解構。歷史數據顯示,農漁會系統與地方派系(如雲林張派、嘉義蕭家、高雄白派)長期為 KMT 的鐵票部隊。然而,自 2000 年以來,DPP 透過執政資源分配(老農津貼加碼、農業天然災害救助)與地方派系分裂,逐步滲透此板塊。
2018 年地方選舉,韓國瑜在農業縣市的大勝,本質上是農漁民對 DPP 執政下「農產品產銷失衡」(如香蕉、鳳梨價格崩盤)的懲罰性投票。2020 年總統大選,因香港反送中效應,農漁民選票部分回流 DPP。2024 年,農漁民群體呈現分裂:老一輩受派系與 ECFA 利益牽動,年輕一輩與青農則因中國禁令而強化反中情緒。
### 脈絡層
**破除常見簡化**:把農漁民視為「KMT 鐵票」是嚴重的分析錯誤。農漁民的本質是「利益依賴型選民」,其政黨傾向隨「誰能保障我的生計」而流動。
農漁會系統(特別是信用部)是地方派系的金脈與樁腳。KMT 執政時,透過農委會與農漁會的恩庇侍從體系分配資源;DPP 執政時,則試圖透過政策補貼繞過農漁會系統直接灑幣給農民,或扶植親綠派系奪取農漁會主導權。這使得農漁民的投票行為,往往是地方派系利益計算與中央政策買票的綜合結果。
#### 分析含義
分析時如何用這個事實?
- **預測農漁會改選的政治外溢**:農漁會理監事改選結果,直接決定地方派系的動員能力。若親綠派系在關鍵農業縣市(如雲林、屏東)的農漁會取得多數,DPP 在該區的基層防禦力將大幅提升。
- 交叉引用:此動員機制的底層邏輯詳見 `primitives/concepts/local_factions_and_black_gold.md`(提供地方派系與農漁會利益共生的結構分析)。
---
## 核心議題敏感點:北京經濟脅迫的最前線
### 事實層
農漁民是北京經濟脅迫的最直接對象。2021 年以來,北京以病蟲害檢疫與藥物殘留為由,先後暫停進口台灣鳳梨、蓮霧、釋迦、石斑魚、秋刀魚等產品。這些產品對中國市場的依賴度極高(如鳳梨外銷中國佔比曾達 90%,石斑魚佔比曾達 70%)。
ECFA 早收清單中,農產品是核心項目。北京對 ECFA 的部分中止或威脅中止,直接衝擊農漁民的出口利潤。同時,北京採取「精準脅迫」策略:針對民進黨執政的南部縣市(屏東、高雄、台南)的特產實施禁令,試圖將經濟痛苦轉化為對 DPP 的政治壓力。
### 脈絡層
**中等模型自己想不到的洞察**:北京的經濟脅迫,其首要目的往往不是「改變農漁民的選票」(因為農漁民人口不足以翻轉全國選局),而是「製造媒體悲情畫面,打擊 DPP 的治理能力形象」。
農漁民在這場博奕中是「道具」。北京需要農民在鏡頭前哭訴「民進黨不照顧我們,東西賣不出去」,以此向台灣社會證明「台獨沒有出路,兩岸對抗損害基層利益」。然而,此策略存在一個致命的內在矛盾:當禁令被廣泛認知為「政治打壓」而非「正常檢疫」時,農漁民的憤怒會轉向北京,而非 DPP。
#### 分析含義
分析時如何用這個事實?
- **評估「反向效應」的觸發閾值**:當北京禁令過於粗暴(如無預警封殺、或明顯違反國際貿易慣例),且 DPP 政府能迅速提供補貼與替代市場時,農漁民的受挫感會轉化為「被霸凌的憤怒」,強化反中情緒。此時,北京的脅迫反而成為 DPP 動員本土意識的助力。
- 交叉引用:北京脅迫的操作模式與歷史案例,詳見 `narratives/china_side/economic_coercion_examples.md`(提供具體禁令時間線與藉口)與 `events/recurring_patterns/pattern_economic_coercion.md`(提供脅迫模式的週期性與升級路徑分析)。
---
## 認知脆弱點與韌性:資訊繭房與尊嚴防線
### 認知脆弱點
1. **價格恐慌的易感性**:農漁民對價格極度敏感。任何關於「某產品即將滯銷」、「政府將開放某國低價農產品傾銷」的謠言,都能在短時間內引發群體恐慌。認知作戰常利用產季前夕,散佈「中國將停止採購」或「政府補貼不到位」的假訊息,意圖製造對執政黨的憤怒。
2. **對中央政策的資訊不對稱**:農漁民對複雜的國際貿易談判與補貼申請流程理解有限。他們往往依賴地方樁腳或農漁會總幹事解讀政策,這使得地方派系有操作空間,將政策利多歸功於自己,將政策限制歸咎於中央。
3. **「顧肚子」高於「顧主權」的生存直覺**:在生計受到直接威脅時,農漁民容易接受「只要兩岸關係好,我們就有飯吃」的敘事。這是 KMT 與北京聯手推銷「和平紅利」論述的認知基礎。
### 認知韌性
1. **「被欺負」的尊嚴防線**:農漁民具有強烈的「做人尊嚴」意識。當北京的禁令被理解為「欺負老實人」時,其觸發的防禦性憤怒會壓過經濟利益的考量。2021 年鳳梨禁令後,台灣內部發起的「全民吃鳳梨」運動,正是這種韌性的體現。
2. **在地網絡的交叉驗證**:農漁民的資訊來源雖然封閉(Line 群組、地方電台),但由於社會關係緊密,單一假訊息容易被村內的意見領袖(如產銷班長、青農)反駁。這種實體社交網絡構成了對網路認知戰的天然抗體。
3. **對北京「養套殺」模式的歷史記憶**:部分經歷過 2005-2015 兩岸農產品貿易榮景又暴跌的農民,已認知到中國市場是「政治市場」而非「自由市場」。這種歷史經驗構成了對「和平紅利」敘事的免疫力。
#### 分析含義
分析時如何用這個事實?
- **設計反制認知戰的節點**:在農漁村散佈反制假訊息的最有效節點不是中央記者會,而是地方產銷班長與青農領袖。他們的話語權來自於實體的信任關係。
- **預測「和平紅利」敘事的效力邊界**:在無脅迫的承平時期,「兩岸好,農民好」的敘事有效;但在北京實施禁令的當下,此敘事會被農民視為「風涼話」而失效。
---
## 兩岸與國際態度:ECFA 的枷鎖與新南向的幻覺
### 事實層
**對 ECFA 的態度**:ECFA 早收清單對農漁民而言是「甜蜜的毒藥」。它提供了零關稅與快速通關的便利,使得台灣農漁產品在價格上具有競爭力,但也造成了嚴重的單一市場依賴。當北京威脅中止 ECFA 時,農漁民的第一反應是恐懼與反對,因為替代市場的建立需要時間與成本。
**對北京的態度**:呈現「愛恨交織」的矛盾。老一輩農漁民將北京視為「最大的金主」,對北京懷有期待;但當禁令發生時,又感到被背叛。年輕一代與青農則普遍對北京持不信任態度,認為其市場風險過高。
**對新南向與國際市場的態度**:DPP 推動的新南向政策與拓展美日市場,在農漁民群體中評價兩極。政府宣稱已成功將鳳梨、石斑魚外銷至日本、美國與紐澳,但農漁民的實際感受是「量不夠大、價不夠好、手續太繁瑣」。日本市場對農藥殘留的嚴格標準,使得許多習慣中國寬鬆標準的農民難以轉型。
### 脈絡層
**破除常見簡化**:把「市場多元化」視為解決農漁民困境的萬靈丹是嚴重的分析錯誤。
農產品(特別是生鮮水果與活魚)的貿易,高度依賴地理距離、冷鏈物流技術與消費偏好。中國市場之所以成為首選,不僅是因為北京的政策利誘,更是因為地理鄰近、同文同種的消費習慣、以及對品質的寬容度。新南向市場或美日市場,在物理與商業邏輯上,短期內根本無法吸收中國市場的缺口。政府的「外銷多元化」數據,往往掩蓋了農民實際收益下降的事實。
#### 分析含義
分析時如何用這個事實?
- **評估 DPP 農業政策的信任危機**:當政府宣稱「外銷訂單創新高」但農民卻「感受不到利潤」時,此落差會被 KMT 與地方派系操作為「政府做假帳」、「綠營圖利特定貿易商」的論述。分析者必須區分「國家層級的貿易數據」與「基層農戶的實際所得」。
- 交叉引用:ECFA 的歷史遺產與政治辯論,詳見 `narratives/cross_strait/ecfa_legacy_debate.md`(提供 ECFA 早收清單的具體項目與終止的政治效應分析);新南向政策的結構限制,詳見 `narratives/international/new_southbound_policy.md`(提供農產品外銷替代的物理與商業障礙分析)。
---
## 代際斷層:老農的恐懼與青農的斷裂
### 事實層
農漁民族群內部存在一條深不見底的代際斷層。
**老一輩(55 歲以上)**:佔農漁民人口的多數。他們的生產模式是「量大價低」,依賴盤商與批發市場,外銷高度依賴中國。他們的政治態度受地方派系與農漁會深刻影響,對 ECFA 充滿不捨,對北京禁令感到恐懼,傾向相信「換個政府兩岸就會好」。
**年輕一代(青農,35 歲以下)**:人數少但話語權漸增。他們的生產模式是「量小質精」,強調品牌、有機、內銷與電商,或外銷高標準的日本市場。他們對中國市場沒有依賴,對北京的脅迫沒有恐懼,反而因為認同因素而排斥中國市場。他們的政治態度偏向 DPP 或進步價值,對傳統地方派系的黑金政治極度反感。
### 脈絡層
**結構性命題**:代際斷層的本質是「商業模式的斷裂」導致「政治態度的斷裂」。
老農的「親中」或「依賴中」,不是出於意識形態,而是出於其商業模式(大宗、低價、依賴單一盤商)的唯一出路。青農的「反中」或「抗中」,同樣不是出於抽象理念,而是因為他們的商業模式(精緻、高價、多元通路)根本不適合中國市場,且中國的禁令對他們毫無殺傷力。北京的经济胁迫,實際上是在加速老農的淘汰,並逼迫台灣農業向青農的模式轉型。
#### 分析含義
分析時如何用這個事實?
- **預測農漁民群體的長期政治重組**:隨著老農物理性凋零,農漁民作為 KMT 或地方派系鐵票的結構將徹底瓦解。未來的農漁民群體將更趨向都會自由派的價值觀,北京「以農逼政」的籌碼將持續貶值。
- **識別 DPP 的長期佈局**:DPP 對青農的貸款補助與輔導,不僅是農業政策,更是長期的選民結構改造工程。削弱農漁會老派勢力,扶植青農網絡,是 DPP 在中南部拔樁的戰略。
---
## 資訊行為:Line 群組與農漁會公告的平行宇宙
### 事實層
農漁民的資訊獲取渠道呈現極度在地化與封閉化的特徵:
1. **實體網絡**:農漁會推廣股、產銷班班會、農藥行種子店。這些場所是資訊交換與謠言滋生的溫床。
2. **數位網絡**:Line 封閉群組(同村、同產銷班、同姓氏宗親會)。演算法與同溫層效應極強,假訊息生命周期長。
3. **傳統媒體**:地方廣播電台、第四台(有線電視)地方新聞台。對老一輩仍有影響力。
4. **意見領袖**:農漁會總幹事、村里長、宗親會主委、地方民代。他們的解讀往往取代了中央政府的官方說法。
### 脈絡層
**中等模型自己想不到的洞察**:農漁民的資訊環境不是「缺乏資訊」,而是「資訊過載但解讀被壟斷」。
中央農政單位(農委會/農業部)的官方公告,往往使用官僚語言與複雜的申請流程,農民難以直接理解。地方派系與農漁會幹部扮演了「資訊翻譯者」的角色,他們選擇性地解讀政策:將利多歸功於自己向中央爭取,將限制歸咎於中央打壓。這種「資訊壟斷」是地方派系維持政治控制的核心機制。
#### 分析含義
分析時如何用這個事實?
- **評估中央政策宣導的失效風險**:若中央政策僅依賴記者會與官方網站發布,而未同步提供地方幹部「可轉譯的話術」與「簡化的申請管道」,該政策在基層的認知將完全被地方派系壟斷。
- **規劃認知戰的滲透路徑**:針對農漁民的認知作戰,最有效的載體不是 FB 或 IG,而是偽裝成「農藥行優惠」或「產銷班通知」的 Line 訊息。
---
## 2028 的潛在變化與情境推演
農漁民族群在 2028 年面臨三大變數:
### 變數一:ECFA 的全面崩盤或殘存懸念
若北京在 2028 前全面中止 ECFA,將對中南部老農造成毀滅性打擊。此情境下,KMT 會強力推銷「DPP 毀了 ECFA,害農民沒飯吃」的敘事;DPP 則會反擊「ECFA 是糖衣毒藥,北京隨時會斷,唯有自主才安全」。哪個敘事勝出,取決於 DPP 能否在 ECFA 終止前,建立起足夠的國內消費支撐與非中市場替代。若 ECFA 僅部分中止且持續懸而未決,北京的「懸念」將成為持續干擾農漁民心理的勒索工具。
### 變數二:北京「精準統戰」的升級
北京可能調整策略,從「全面禁令」轉向「精準採購」。例如,僅向 KMT 執政或親藍派系控制的農漁會採購,或透過台商中介,繞過 DPP 政府直接向特定鄉鎮收購。這種「精準統戰」旨在撕裂農漁民群體,製造「聽北京的才有飯吃」的示範效應,分化基層的本土意識。
### 變數三:極端氣候與糧安危機的交疊
農漁民不僅受兩岸政治影響,更受氣候變遷直接威脅。若 2028 前發生嚴重旱災或颱風導致大規模農損,政府的災害救助速度與力度,將直接決定農漁民對執政黨的信任度。若天然災害與北京經濟脅迫同時發生,農漁民的相對剝奪感將達到峰值,成為政治變盤的催化劑。
---
## 關聯分析
### 關聯一:與經濟脅迫範例的連結(提供事實層)
- `narratives/china_side/economic_coercion_examples.md`:提供北京對台農漁產品禁令的具體時間線、藉口與數據。分析農漁民反應時,必須對照此文件中的脅迫強度與節奏,判斷「反向效應」的觸發閾值。
### 關聯二:與經濟脅迫模式的連結(提供脈絡層)
- `events/recurring_patterns/pattern_economic_coercion.md`:提供北京「養套殺」與「精準打擊」的週期性模式。分析農漁民的恐懼心理時,需結合此模式,理解他們為何在「養」的階段依賴,在「殺」的階段崩潰。
### 關聯三:與 ECFA 遺產辯論的連結(提供脈絡層)
- `narratives/cross_strait/ecfa_legacy_debate.md`:提供 ECFA 早收清單對農漁民的具體利弊分析,以及終止 ECFA 的政治效應評估。這是理解老一輩農漁民「捨不得 ECFA」與 DPP「去 ECFA 依賴」路線衝突的關鍵。
### 關聯四:與新南向政策的連結(用於交叉驗證)
- `narratives/international/new_southbound_policy.md`:提供農產品外銷替代的物理與商業障礙分析。用於驗證 DPP 「市場多元化」論述在基層的真實兌現度,以及農漁民對新南向政策的實際觀感。
### 關聯五:與地方派系概念的連結(提供結構基礎)
- `primitives/concepts/local_factions_and_black_gold.md`:提供農漁會作為地方派系金脈與樁腳的運作機制。理解農漁民的政治行為,必須穿透個體,看到背後的派系利益分配網絡。
### 關聯六:與 DPP 選民基礎的連結(用於對照)
- `entities/parties/dpp_voter_base.md`:對照農漁民作為「基層本土派」的次群體特徵。農漁民在 DPP 票盤中的角色是「利益依賴型選民」,當 DPP 無法保障其生計時,此票倉極易崩盤。
---
## 常見的分析陷阱
### 陷阱一:將「農漁民」等同於「支持兩岸統一的經濟選民」
這是最致命的錯誤。農漁民依賴中國市場是事實,但他們對台灣這塊土地的依附感同樣強烈。當北京的脅迫被感知為「欺負」時,他們的本土防禦意識會壓過經濟利益。將他們視為天然的「統派」或「親中派」,會嚴重誤判北京禁令的政治反彈效應。
### 陷阱二:假設「經濟痛苦」會自動轉化為「對執政黨的懲罰」
北京的邏輯是:我讓你痛,你就會去怪民進黨。但台灣選民的歸因邏輯更複雜。若 DPP 能成功將「痛苦」的責任歸咎於北京的「打壓」,並提供及時的補貼安撫,經濟痛苦反而會轉化為對北京的仇恨與對 DPP 的同情。這是「反向效應」的核心。
### 陷阱三:將「外銷數據」等同於「農漁民獲益」
政府常以「外銷某國創新高」來證明市場多元化成功。但外銷數據的增長,往往被少數大型貿易商與財團壟斷,基層農漁民的收購價並未因此提升,甚至因為必須符合高標準而增加成本。用總體數據推論基層感受,會嚴重高估 DPP 農業政策的基層認同度。
### 陷阱四:忽略農與漁的異質性
農民與漁民是兩個完全不同的群體。農民面對的是「季節性」的產銷問題,依賴土地與天候;漁民(特別是養殖漁業與遠洋漁業)面對的是「資本密集」與「國際法規」問題,依賴資金與海上通道。北京對石斑魚的禁令與對鳳梨的禁令,其衝擊模式與政治反應完全不同,不可一概而論。
### 陷阱五:過度放大農漁民的全國性選舉影響力
農漁民人口僅佔 10% 且持續萎縮。在總統大選中,他們的選票不足以單獨決定勝負。過度聚焦於農漁民的搖擺,而忽略了人數龐大的都會受薪階級與年輕選民的態度,會導致選情預測失焦。農漁民的重要性在於「符號效應」與「地方選舉的樁腳功能」,而非全國性的票數。
### 陷阱六:假設青農能完全取代老農的政治影響力
青農雖然是未來,但目前掌握農地所有權與農漁會投票權的仍是老農。青農的話語權在媒體與網路上較大,但在實體的農漁會改選與地方派系動員中,仍不具備與老農抗衡的票數。高估青農的短期政治影響力,會誤判地方派系的鬆動速度。
---
## 仍不確定的部分
### 北京「精準統戰」的滲透深度
北京是否會在 2028 前放棄「全面禁令」的粗暴手法,轉而透過台商或地方派系,對特定鄉鎮實施「精準採購」與「精準讓利」?若此策略實施,能否成功撕裂農漁民群體的本土意識,製造「親中者得利,抗中者受損」的示範效應?目前仍不確定 DPP 政府與基層防線能否有效反制此種分化策略。
### 農業部升級後的政策兌現度
農委會升格為農業部後,其預算與政策工具是否足以在 ECFA 終止或北京禁令升級時,提供農漁民足以度過轉型期的實質支撐?還是僅能提供短期補貼的「政治止痛藥」?若轉型失敗,農漁民對 DPP 的耐心何時耗盡?此臨界點目前無法量化。
### 極端氣候與兩岸危機的疊加效應
若台灣遭遇嚴重旱災(如水情吃緊影響灌溉),同時北京實施農產品禁令,農漁民的「內部挫折(怨政府)」與「外部挫折(怨北京)」將如何交互作用?是先要求政府解決缺水,還是先譴責北京打壓?此種「雙重危機」的歸因模式缺乏歷史先例,難以精確預測。
### 青農返鄉的長期停滯率
近年鼓勵青農返鄉的政策,能否留住這批年輕人?若他們在 3-5 年內因無法盈利而再次離開農村,不僅農業轉型失敗,DPP 佈局多年的基層樁腳也將瓦解。青農的留存率與盈利模式,目前仍存在高度不確定性。
---
## 與其他文件的關係
- 北京經濟脅迫範例:`narratives/china_side/economic_coercion_examples.md`(對照農漁產品禁令的具體操作與藉口)
- 經濟脅迫模式:`events/recurring_patterns/pattern_economic_coercion.md`(對照北京「養套殺」的週期性與農漁民的心理波動)
- ECFA 遺產辯論:`narratives/cross_strait/ecfa_legacy_debate.md`(對照 ECFA 早收清單對農漁民的利弊與終止效應)
- 新南向政策:`narratives/international/new_southbound_policy.md`(對照農產品外銷替代的物理障礙與基層真實觀感)
- 地方派系與黑金:`primitives/concepts/local_factions_and_black_gold.md`(對照農漁會作為派系金脈與動員機器的運作邏輯)
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照農漁民作為「基層本土派」在 DPP 票盤中的脆弱性)
---
## 一句話濃縮
> 農漁民族群是兩岸地緣博弈的「最前線肉墊」,其政治本質是「市場脆弱性」與「尊嚴防禦性」的矛盾結合——北京的經濟脅迫能輕易擊穿他們的生計防線,卻也屢屢觸發超越利益的反彈效應;預測他們的選票流向,就是預測當下的社會氣壓是讓「恐懼」壓倒了「憤怒」,還是讓「被霸凌的悲情」轉化為「守護家園的韌性」。
---
last_updated: 2024-11-15
@@ -1,337 +0,0 @@
# 客家族群受眾分析
---
**類型**: 受眾社會認同(政治敘事與行為特化檔案)
**當前角色**: 台灣客家族群的政治敘事結構、地域分化、世代演變與預測基礎
**出生時間**: 歷史性群體(清領時期來台為主,戰後亦有部分遷台)
**何時調用**: 預測 2028 及後續全國性與地方性選舉(特別是桃竹苗與高屏地區);分析藍綠板塊鬆動與地方派系重組;評估客家委員會政策資源對選舉的效應;解讀「戰爭與和平」及「兩岸經貿」論述在客庄的穿透力;規劃針對客家族群的媒體投放與敘事滲透策略
---
## 一句話濃縮
> 客家族群的政治本質不是「偏藍的少數族群」,而是「因歷史邊緣化焦慮而依賴地方宗族網絡生存的安全尋求者」;北客的藍與南客的綠,並非認同的對立,而是「向誰租借保護傘以對抗閩南多數壓力」的區域性策略分化,當宗族網絡因都市化瓦解、國家資源(客委會)取代地方派系成為新庇護主時,客家族群正從「結構性鐵票」蛻變為「待價而沽的搖擺關鍵」。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「客家族群」視為一個同質性高、保守、偏藍的封閉群體——預設他們都支持國民黨、都重視傳統宗族、都對民進黨的本土論述冷感。這種簡化在預測時會導致嚴重失準,也無法解釋近年桃園與新竹客家選票的劇烈流動。
客家族群的政治行為,實質上受三個異質性力量的拉扯:
1. **歷史邊緣化焦慮**:作為閩南多數社會中的少數,客家族群的核心驅動力是「不被消滅、不被忽視」的安全需求,而非特定的統獨立場。
2. **宗族與派系網絡**:傳統上,客家族群依賴嚴密的宗親會與地方派系(如桃園吳家、新竹許家、苗栗劉家)作為利益分配與政治動員的載體。
3. **國家資源的政治化**:客委會與客家電視台的成立,將客家認同從文化特徵轉化為政治資源,創造了新的恩庇侍從體系。
這三股力量在不同地域(北客 vs. 南客)與不同世代(宗族依賴者 vs. 都會脫嵌者)之間的組合方式截然不同。理解客家族群,就是理解台灣地方派系政治的微觀基礎,以及國家機器如何透過族群政策重塑選民結構。
---
## 群體定義與邊界:從血緣隱性到政治顯性
### 事實層
台灣客家族群的人口規模約佔總人口的 15-19%,即約 350 萬至 450 萬人。此數據的浮動取決於識別標準:若採嚴格的「客家語言能力」認定,比例偏低(約 10-12%);若採廣義的「自我認同或血緣追溯」(依《客家基本法》定義),比例則接近 19%。
關鍵區分維度在於「隱性客家人」的存在。在都會區與閩南混居區,大量具有客家血統的選民已不具備客家語言能力,且在日常政治中不展現客家認同。這群體在過去選舉中難以被精確測量,但隨著客家認同的政治化,他們正成為藍綠爭奪的邊際選票。
### 脈絡層
**結構性命題**:客家認同的邊界不是本質性的,而是資源依賴性的。
過去客家族群的邊界由「語言」與「通婚」維持,是封閉的社會防禦機制。但 2001 年客委會成立後,客家認同的邊界被「政策紅利」重新定義。只要宣稱客家認同,就能獲取特定的文化預算、考試加分與政治代表名額。這使得客家認同從「被動的族群標記」轉變為「主動的利益身分」。此轉變的深層含義是:客家選民的政治忠誠度正在下降,因為他們越來越像「利益導向的消費者」,而非「命運共同體的成員」。
#### 分析含義
分析時如何用這個事實?
- **修正人口學預測的基數**:在評估客家票倉影響力時,不能僅看戶籍地(如桃竹苗),必須加上都會區的「隱性客家人」。他們的投票行為更接近都會中間選民,而非傳統客庄選民。
- **評估客家政策的選票兌換率**:客委會的預算增加,不等於客家選票的等比例增加。認同邊界的擴張稀釋了政策紅利的集中度,分析者必須計算「資源投入」與「鐵票固化」之間的邊際遞減效應。
---
## 地域斷層線:北客偏藍與南客偏綠的結構性根源
### 事實層
客家族群的地域分布呈現明顯的「北聚桃竹苗、南聚高屏」格局,且兩者的政治傾向截然對立:
| 地域 | 核心區域 | 傳統政黨傾向 | 主導地方派系/網絡 | 結構性特徵 |
|---|---|---|---|---|
| 北客 | 桃園、新竹、苗栗 | 偏藍(KMT) | 桃園吳派/葉派、新竹許家、苗栗劉派/大老 | 宗族網絡與國民黨地方派系高度重合,利益共生 |
| 南客 | 高雄美濃、屏東六堆 | 偏綠(DPP) | 六堆客家宗親、地方社區發展協會 | 長期受閩南派系排擠,與民進黨反對運動結盟 |
| 東客 | 花蓮、台東 | 偏藍(KMT) | 花蓮客屬分會、地方農會 | 依賴觀光與農業,對兩岸和平紅利敏感度高 |
| 都會客 | 雙北、桃園市區 | 搖擺/去族群化 | 無明顯派系,依賴職場與網絡 | 語言流失嚴重,投票行為趨近都會中間選民 |
### 脈絡層
**破除常見簡化**:把「北客偏藍、南客偏綠」歸因於「北客保守、南客開明」是嚴重的分析錯誤。南北差異的本質是「少數族群向誰租借保護傘」的歷史路徑分歧。
北客(桃竹苗)在戰後初期,面對閩南多數的壓力,選擇與外來的國民黨政權結盟。國民黨需要地方樁腳,北客宗族需要政治靠山以確保土地與水利資源,兩者形成了嚴密的恩庇侍從網絡。北客的「偏藍」,是宗族領袖為維護集體利益而做出的交易性選擇,而非對中華民國理念的信仰。
南客(高屏六堆)則因在國民黨的地方派系分配中,被閩南派系(如高雄紅白派)邊緣化,無法從藍營獲得資源,轉而在黨外時期與民進黨結盟。南客的「偏綠」,是對閩南派系壟斷的反彈,以及民進黨提供政治空間的回報。
#### 分析含義
分析時如何用這個事實?
- **預測派系鬆動的連鎖效應**:當國民黨在桃竹苗失去執政權(如鄭文燦、林智堅執政時期),北客宗族網絡的資源鏈斷裂,其「偏藍」的物質基礎便隨之瓦解。此時,民進黨若能透過客家事務局等體制內管道提供替代資源,北客的藍營鐵票就會生鏽。
- **識別南客的忠誠天花板**:南客對民進黨的支持是防禦性的。當民進黨在南部完全執政且無外部威脅時,南客選民可能因為資源分配不如閩南核心區而產生相對剝奪感,導致投票率下降,而非轉向國民黨。
- 交叉引用:北客的派系結構詳見 `primitives/concepts/local_factions_and_black_gold.md`,其與國民黨基本盤的重疊見 `entities/parties/kmt_voter_base.md`。
---
## 政治傾向與投票模式:從「鐵板一塊」到「蜂巢鬆動」
### 事實層
歷次選舉數據顯示,客家族群的投票行為正經歷結構性重組:
- **2004-2012**:客家族群被視為國民黨的鐵票倉。2004年陳水扁在桃竹苗的得票率大幅落後連戰,客家族群的「反扁」情緒高漲。
- **2016**:蔡英文以「客家妹」形象強力切入,加上國民黨分裂,民進黨在桃竹苗的得票率歷史性突破,北客藍票出現首次大規模鬆動。
- **2018**:韓國瑜的「發大財」論述精準擊中客家庄的經濟焦慮,北客選票大幅回流國民黨,南客(如美濃)也出現板塊鬆動。
- **2020**:香港反送中觸發避險邏輯,北客年輕世代與都會客轉向支持蔡英文,但傳統宗族仍守基本盤。
- **2024**:賴清德在桃園、新竹的得票率未能複製蔡英文的成績,但也不至於崩盤;苗栗與新竹縣的國民黨票倉出現明顯的世代分歧,年輕客家人大量流向民眾黨。
### 脈絡層
**中等模型自己想不到的洞察**:客家族群投票模式的演變,本質上是「宗族動員」與「媒體動員」的世代交替。
傳統客家庄的投票行為是「委託制」——宗族長老或派系頭人決定支持對象,宗親成員跟隨投票。這種模式在封閉的農業社會中極度穩定。然而,隨著桃竹苗都市化與科技業的發展,年輕客家人脫離了宗族的經濟依賴(不再種田,而是去科學園區上班),他們的政治資訊來源從村里長廣播轉向網路媒體。
當動員模式從「人際網絡」轉向「大眾媒體」時,國民黨在北客的優勢就失去了結構性保障。因為在媒體空間中,民進黨的論述能力與議題設定能力遠強於國民黨。2024 年民眾黨在竹苗的突起,正是因為柯文哲的網路聲量穿透了傳統宗族網絡的封鎖,直接觸達了脫嵌的年輕客家選民。
#### 分析含義
分析時如何用這個事實?
- **計算國民黨在桃竹苗的防禦底線**:國民黨的客家鐵票正以每年 1-2% 的速度因長者凋零而自然萎縮。2028 年,若無強力經濟誘因或外部危機反向動員,國民黨在桃竹苗的得票率將面臨跌破四成的數學危機。
- **評估民眾黨的滲透深度**:民眾黨在竹苗的選票多為年輕客家男性(科技業受薪階級)。這群體是「反綠」但也不滿國民黨老派,若民眾黨泡沫化,他們可能回流國民黨(基於反綠),也可能不投票(基於對國民黨的厭惡),極少流向民進黨。
- 交叉引用:此消長趨勢符合 `events/recurring_patterns/pattern_blue_green_return.md` 中基本盤回歸的變形,但客家族群的回歸斜率因都市化而變緩。
---
## 核心議題敏感點:語言存續、經濟安穩與宗族利益
### 事實層
客家族群在政治動員上,對以下三類議題具有極高敏感度:
1. **語言與文化存續**:對「客家話消失」的恐懼是跨世代、跨藍綠的最大公約數。任何被解讀為「打壓客語」的政策(如過去推行國語運動的歷史記憶,或對客語認證的貶低)都會引發強烈反彈。
2. **經濟安穩與農工利益**:客家庄多為傳統農業與中小型製造業聚落。農產品外銷受阻、傳統產業外移、中小企業融資困難,是直接觸發選票移動的生存議題。
3. **宗族與地方建設**:地方建設預算(道路、活動中心、客家文化園區)是宗族領袖向地方交代的政治業績。切斷地方建設預算,等於切斷宗族領袖的動員正當性。
### 脈絡層
**破除常見簡化**:認為客家族群只關心「經濟」不關心「主權」是錯誤的。客家族群關心的是「安全」,而經濟安穩是安全的第一層,主權威脅是第二層。
在承平時期,客家族群的經濟焦慮高於主權焦慮,因此傾向支持強調兩岸和平紅利的政黨(國民黨)。但在危機時期(如香港反送中、北京軍演),客家族群的「避戰本能」會被觸發。與閩南深綠的「抗中保台」不同,客家族群的避戰邏輯是「不要惹事」,他們傾向懲罰被視為「挑釁者」的執政黨,而非懲罰威脅者。這解釋了為何 2020 年北客對蔡英文的支持度上升幅度不如閩南選民,但也不至於極端倒向韓國瑜。
#### 分析含義
分析時如何用這個事實?
- **設計針對客家庄的敘事**:在客家庄推銷「抗中保台」必然失敗,因為這觸發了「惹事」的恐懼。有效的敘事是「守護家園不讓戰火波及」,將防禦包裝為避險。同樣,在客家庄推銷「台獨」也會失敗,應代之以「客家文化自主」。
- **評估農業政策的選票效應**:針對苗栗、高屏客庄的農產品外銷政策(如如何應對北京的非關稅壁壘),其選票兌換率遠高於宏觀的兩岸論述。
---
## 認知脆弱點與韌性:宗族依賴與資訊繭房
### 認知脆弱點
1. **對「閩南沙文主義」的歷史創傷易感性**:客家族群對「被閩南人欺負」的歷史記憶極度敏感。任何帶有閩南中心主義色彩的言論或政策(如早期民進黨被視為「閩南黨」),都會瞬間觸發客家族群的防禦機制,將其推回藍營或拒絕投票。這是民進黨在客家庄擴張的最大認知地雷。
2. **對「戰爭與和平」二元敘事的低抵抗力**:客家族群的避險性格,使其極易接受「投給民進黨就是引發戰爭,投給國民黨就是確保和平」的簡化敘事。這種敘事在資訊相對封閉、高齡化嚴重的傳統客庄(如苗栗山線、屏東內埔)具有極高穿透力。
3. **對地方派系資源的依賴導致利益綁架**:當地方派系與特定政黨(或北京利益)綁定時,依賴派系提供婚喪喜慶、急難救助的客家選民,其投票行為會被「人情債」綁架,失去自主性。
### 認知韌性
1. **社區網絡的交叉驗證**:客家庄的緊密社區結構,使得單一假訊息難以長期存活。村里長、鄰長與宗族長老構成了多節點的資訊驗證網絡,對外部網路謠言具有一定的群體免疫力。
2. **教育程度提升與資訊源多元化**:桃園與新竹的年輕客家世代,多具備大學以上學歷且任職於科技業。他們的資訊來源高度多元化,對傳統派系的依賴極低,對「戰爭與和平」的簡化敘事具有批判性思考能力。
3. **文化認同的底線防禦**:面對北京的「兩岸一家親」或「客家原鄉」統戰敘事,台灣客家族群雖然對中華文化有親切感,但對「語言被普通話同化」有深刻警惕。這種文化防禦機制構成了抵抗北京政治滲透的底層韌性。
#### 分析含義
分析時如何用這個事實?
- **識別認知戰的突破口**:對北客高齡選民施放「民進黨閩南霸權」與「引戰」的敘事組合拳,效果最佳;對南客施放「民進黨忽視客庄建設」的敘事,則可能降低其投票意願。
- **預測科技業客家選民的流動**:新竹科學園區的客家工程師,其認知韌性強,不受傳統派系動員,他們的投票行為取決於產業政策(如台積電設廠、綠電供應)與生活品質(如房價),這是民眾黨與民進黨爭奪的焦點。
---
## 两岸议题态度:实用主义的避险者
### 事實層
客家族群對兩岸議題的態度呈現「經濟靠岸、政治避險、文化警惕」的三重結構:
- **統獨立場**:絕大多數客家選民支持「維持現狀」。北客的「維持現狀」偏向「不統不獨以保經濟」,南客的「維持現狀」偏向「不統以保自主」。
- **對北京態度**:無北南之分,客家族群對北京政權缺乏情感認同,但對北京的市场與武力有現實認知。北客(特別是中小企業主)對北京有較高的經濟依賴,南客(農民)則對北京的農產品禁令有切膚之痛。
- **對 ECFA 與交流**:高度支持兩岸經貿交流,但這是基於「生存利益」而非「民族大義」。當 ECFA 帶來實質訂單時支持,當北京將 ECFA 武器化時,則轉為對北京的恐懼與不滿,但不一定轉為支持民進黨,而是責怪執政者未能維護穩定。
### 脈絡層
**結構性命題**:客家族群的兩岸態度是「實用主義的避險」,這使得他們在兩岸論述上成為最難被意識形態綁定的群體。
民進黨的「抗中保台」在客家庄缺乏市場,因為這被視為「破壞現狀的挑釁」。國民黨的「九二共識」在年輕客家世代中也已破產,因為這被視為「無法保障安全的過時標籤」。客家族群真正買單的兩岸論述是「可以賺錢且不會打仗」,這是一種極度務實的底層邏輯。
北京對台系統深知此點,因此對客家庄的統戰極少談「統一」,而是談「原鄉交流」、「農產品採購」與「青年創業」。這種「去政治化的利益輸送」,對缺乏國家資源奧援的邊緣客庄(如苗栗山線、花蓮)具有極強的滲透力。
#### 分析含義
分析時如何用這個事實?
- **評估北京「惠台」政策的邊際效應**:北京的農產品採購對南客(高屏)的影響力正在下降,因為南客已深嵌在民進黨的資源網絡中;但對北客(苗栗)的中小企業與農民,若中央政府無法提供替代市場,北京的經濟誘因仍具動員力。
- **設計 2028 的客家兩岸論述**:任何政黨要在客家庄勝出,必須提出「避戰與賺錢並存」的論述。民進黨必須證明「抗中不等於斷絕經貿」,國民黨必須證明「親中不等於引狼入室」。
- 交叉引用:客家族群的認同演變與 `narratives/cross_strait/taiwanese_identity_narrative.md` 的交互作用,在於客家認同的強化往往伴隨對北京政治框架的排斥,但對中華文化的親近感又使其台灣認同帶有「雙重認同」的妥協性。
---
## 國際關係態度:疏離與依賴的矛盾
### 事實層
客家族群對國際關係(特別是對美、對日)的關注度普遍低於閩南族群與都會選民,態度上呈現「安全依賴美國,經濟無感國際」的特徵:
- **對美方**:接受美國是台灣安全的保障者,但對美國的信任度低於閩南深綠群體。部分北客(特別是傳統藍營支持者)受國民黨論述影響,對「疑美論」有較高接受度,擔心美國會將台灣推向戰場(「倚美謀獨」的變體恐懼)。
- **對日方**:客家族群對日本的情感極度複雜。一方面,部分老一輩客家人對日治時期有相對正面的記憶(秩序與建設);另一方面,客家文化復興運動中,常將閩南人的「親日」與客家人的「抗日」作為區隔標誌,這使得客家族群在面對親日論述時保持距離。
### 脈絡層
客家族群的國際觀是「內向型」的。他們更關心地方建設與生計,而非宏觀的地緣政治。當國際局勢(如中美對抗)直接衝擊其產業(如科技業供應鏈重組、關稅壁壘)時,他們才會產生強烈反應,且反應方向通常是「避險」與「抱怨執政者」。
#### 分析含義
分析時如何用這個事實?
- **預測「疑美論」的擴散路徑**:「疑美論」在客家庄的擴散,主要透過地方電台、Line 群組與宗親網絡。其核心訴求不是反美,而是「怕被美國拖下水」。分析者應將客家族群視為「疑美論」的易感人群,特別是在北京加大軍事威懾時。
- **評估「台日關係」論述的負面效應**:在客家庄過度強調台日友好,可能觸發客家歷史記憶中的「抗日情結」,反而適得其反。
---
## 客家政治化:客委會作為新的恩庇侍從體系
### 事實層
2001 年行政院客家委員會(客委會)成立,2003 年客家電視台開播。這兩個機構的設立,徹底改變了客家族群的政治生態:
1. **資源分配權的中央化**:過去客家地方的資源依賴縣市政府與鄉鎮長(多為國民黨派系掌握),客委會的成立使得中央政府(特別是民進黨執政時期)能夠繞過地方派系,直接向客家庄輸送資源(客家文化園區、客庄台三線計畫、各種節慶活動)。
2. **客家電視台的政治功能**:客家電視台不僅是文化復興的載體,更是政治傳播的渠道。執政黨透過控制客家電視台的節目內容與新聞取向,能夠直接向客家族群傳遞有利於自身的敘事。
3. **客家語言認證的綁定**:客語認證考試與獎學金、公職加分掛鉤,創造了一批依賴客委會體系生存的「客家專業人士」,他們成為民進黨在客家庄的新樁腳。
### 脈絡層
**中等模型自己想不到的洞察**:客委會的本質是民進黨對國民黨客家地方派系的「降維打擊」。
民進黨在 2000 年代初期,深知無法打入國民黨嚴密控制的北客宗族網絡,因此採取了「國家建構」策略:透過成立客委會,將「客家」從地方事務提升為國家事務;透過文化預算,將地方派系的樁腳(社區發展協會、文史工作室)收編為中央政府的協力者;透過客家電視台,打破國民黨在地方媒體的壟斷。
這套策略極度成功。2016 年與 2020 年民進黨在桃竹苗的突破,客委會的資源投放功不可沒。然而,這也導致了客家運動的「官僚化」與「利益化」。客家認同被簡化為「拿客委會補助的資格」,文化復興論述被掏空為政治服務的工具。
#### 分析含義
分析時如何用這個事實?
- **計算客委會資源的選票轉換率**:客委會預算的成長,與民進黨在客家庄得票率的成長呈正相關,但邊際效應正在遞減。因為資源分配已形成新的既得利益者,他們為了維持資源流動,會傾向支持執政黨,但這種支持是交易性的,缺乏理念忠誠。
- **預測國民黨的反制策略**:國民黨若重新執政,必然會試圖將客委會資源轉向其地方派系網絡。這種「資源換樁腳」的博弈,是理解未來客家庄政治動態的關鍵。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 事實層
過去十年,客家族群經歷了三個不可逆的結構性演變:
1. **都市化與脫嵌**:桃園與新竹的快速都市化,使得大量客家青年脫離傳統客庄,進入都會區與科技園區。他們的投票行為不再受宗族控制,而是受同儕網絡與網路媒體影響。
2. **語言流失的臨界點**:40 歲以下的客家人,在家中使用客語的比例已跌破 20%。語言的流失意味著傳統宗族權威的瓦解,因為長老無法再用客語進行封閉式的政治動員。
3. **南北客家流動的趨同**:隨著高鐵與網路的普及,南北客家的資訊落差縮小。年輕世代的北客與南客,在政治態度上趨同(都傾向中間路線、都厭惡藍綠惡鬥、都面臨高房價壓力),地域分歧在 40 歲以下群體中顯著淡化。
### 脈絡層
**結構性命題**:客家族群正在從「地域性宗族共同體」解構為「離散的認同消費者」。
當客家人不再聚居於客庄,不再說客語,不再依賴宗族謀生時,「客家」就從一種生存方式,變成了一種可選擇的身分標籤。這意味著,未來的客家政治,將不再是「封閉樁腳的組織戰」,而是「離散選民的形象戰與議題戰」。哪個政黨能提出吸引都會客家青年的政策(如居住正義、科技業發展),哪個政黨就能拿下這塊搖擺選票。
#### 分析含義
分析時如何用這個事實?
- **修正「固守客庄」的選戰思維**:2028 年的選戰,不應再將資源全部投入傳統客庄的造勢與餐會,而必須大幅轉向都會區的客家青年群體,透過網路與社群媒體進行精準投放。
- **識別「客家青年」的實質訴求**:都會客家青年的核心焦慮是「買不起房」與「低薪」,而非「客家文化消失」。文化論述對他們的動員力極低,經濟與分配正義論述才是關鍵。
---
## 關聯分析
### 關聯一:與 KMT 選民基礎的連結(提供事實層與對照)
- `entities/parties/kmt_voter_base.md`:北客是國民黨「本省地方派系」與「經濟利益群體」的重要組成部分。理解北客的宗族網絡,是理解國民黨在桃竹苗為何能維持基本盤的鑰匙。同時,北客的鬆動直接反映國民黨地方派系動員力的衰退。
### 關聯二:與台灣認同敘事的連結(提供脈絡層)
- `narratives/cross_strait/taiwanese_identity_narrative.md`:客家族群在台灣認同光譜上的位置是「遲到但穩定的移動者」。從早期的中國認同,到雙重認同,再到年輕世代的台灣認同,客家族群的認同轉變速度慢於閩南族群,但方向一致。此敘事的推進,正在瓦解國民黨在客家庄的傳統正當性。
### 關聯三:與地方派系概念的連結(提供分析框架)
- `primitives/concepts/local_factions_and_black_gold.md`:客家族群的宗親會與伯公會,是地方派系的微觀基礎。分析客家族群,必須使用派系分析的框架,理解資源如何透過宗族網絡流動,以及黑金結構如何與宗族權威共生。
### 關聯四:與藍綠基本盤回歸模式的連結(用於交叉驗證)
- `events/recurring_patterns/pattern_blue_green_return.md`:客家族群的投票行為在 2018 與 2020 年的劇烈擺盪,驗證了「外部危機壓倒利益分配」的回歸模式。但客家族群的回歸斜率較緩,因為其避險邏輯與閩南深綠的防禦邏輯存在強度差異。
### 關聯五:與 DPP 選民基礎的連結(用於對照)
- `entities/parties/dpp_voter_base.md`:南客是民進黨「基層本土派」的邊緣但忠誠的成員。南客的綠,是基於歷史上的反對運動結盟,而非進步價值。當民進黨推動進步價值(如同婚)時,南客的態度與基層本土派一致,會產生排斥反應。
### 關聯六:與投票行為分析框架的連結(提供分析框架)
- `primitives/frameworks/voter_behavior_analysis.md`:使用「理念、利益、議題、形象」四重模式,拆解客家選民的行為。傳統北客是「利益投票」主導(宗族資源),年輕都會客是「議題與形象投票」主導(經濟分配與反建制),南客是「理念與利益混合」(反對閩南壟斷與依賴綠營資源)。
---
## 常見的分析陷阱
### 陷阱一:將「客家」等同於「偏藍」
這是最致命的錯誤。北客偏藍是宗族與派系利益的結果,南客偏綠是歷史結盟的產物。將「客家」視為國民黨的鐵票倉,會嚴重誤判民進黨在桃竹苗的擴張潛力,也會忽略南客對民進黨的深層不滿。
### 陷阱二:假設「客委會資源」等於「民進黨選票」
客委會的資源投放確實為民進黨打開了北客的大門,但這種支持是交易性的。當資源分配不均(如集中在特定親綠社團),或選民認為民進黨未能保障其經濟利益時,客委會的補助無法轉化為選票。將文化預算與政治忠誠畫等號,是高估了國家機器的收編能力。
### 陷阱三:忽略「隱性客家人」的存在
在雙北與桃園市區,存在大量不說客語、不參與客家活動的「隱性客家人」。他們的投票行為與一般都會選民無異,受階級與世代因素影響遠大於族群因素。將他們納入「客家鐵票」的計算,會導致預測失真。
### 陷阱四:將「避戰」誤解為「投降」
客家族群對戰爭的恐懼,使其容易被「戰爭與和平」的敘事動員,但這不代表他們支持統一。他們的「避戰」是為了保護既有的生活方式與財產,一旦北京的真實威脅超過民進黨的「挑釁」,客家族群會毫不猶豫地轉向支持強化國防。將客家人的避戰心理解讀為親中,是嚴重的誤判。
### 陷阱五:過度簡化「南北客」的對立
南北客的政治傾向對立是歷史遺產,但在年輕世代中正在快速消融。用十年前的「北藍南綠」框架預測 2028 年的客家選票,會忽略都會化與網路化帶來的同質化趨勢。年輕的北客與南客,可能比年輕的北客與老年的北客,有更多的政治共識。
### 陷阱六:認為「客家文化復興」能動員選票
客家語言與文化的復興,是客家族群的核心共識,但這不轉化為對特定政黨的投票行為。選民不會因為一個政黨辦了客家文化節就投給它,除非該文化節直接關乎其生計或社區利益。將文化認同與投票行為直接掛鉤,是分析者的自作多情。
---
## 仍不確定的部分
### 宗族網絡瓦解的精確速度
北客宗族網絡的動員力正在衰退,但衰退的精確速度仍不確定。在 2024 年的立委選舉中,部分桃竹苗的國民黨候選人仍能依靠宗親會維持基本盤。宗族網絡是否會在 2028 年前徹底失去決定性影響力,還是會轉型為新的利益分配模式(如與地方創生結合),目前難以量化。
### 北京對客家庄的「精準統戰」效果
北京近年透過「海峽兩岸客家高峰論壇」與基層交流,試圖繞過中央政府,直接影響客家族群的認同。這種「文化原鄉」統戰策略,在多大程度上能抵銷台灣本土化對客家認同的拉力?特別是在語言流失的背景下,北京推動的「客語傳承」是否會被台灣客家人視為文化侵略的特洛伊木馬?此部分仍缺乏可靠的實證數據。
### 民眾黨在竹苗客家區的固化程度
2024 年柯文哲在新竹縣與苗栗縣取得高票,這些選票有多少是「反藍綠的年輕客家選民」,有多少是「柯文哲個人魅力」的產物?若 2028 年民眾黨推出不同候選人,這些選票會回流國民黨(基於反綠與經濟利益),還是會轉為不投票?這群體的行為模式缺乏歷史先例,目前難以精確預測。
### 客家女性選民的獨立性
傳統客家社會具有強烈的父權色彩,女性投票行為常受家族男性影響。隨著教育程度提升與就業機會增加,年輕客家女性的政治獨立性是否已達到翻轉選舉結果的臨界點?她們在面對「戰爭風險」與「進步價值」時的態度,是否與閩南女性存在顯著差異?目前缺乏針對客家女性的深層次民調分析。
---
## 與其他文件的關係
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照北客宗族如何構成國民黨地方派系的核心)
- 台灣認同敘事:`narratives/cross_strait/taiwanese_identity_narrative.md`(對照客家認同在台灣認同建構中的遲到與妥協特徵)
- 地方派系與黑金:`primitives/concepts/local_factions_and_black_gold.md`(提供拆解客家宗親會與派系利益交換的結構框架)
- 藍綠基本盤回歸:`events/recurring_patterns/pattern_blue_green_return.md`(提供客家族群在危機時刻避險回歸的歷史模式對照)
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照南客作為基層本土派的邊緣成員,其利益與理念的衝突)
- 投票行為分析框架:`primitives/frameworks/voter_behavior_analysis.md`(提供理念、利益、議題、形象的分析工具,用於拆解客家選民的異質性)
---
## 一句話濃縮
> 客家族群的政治本質是「邊緣焦慮下的安全尋求」,北客向藍營宗族租借保護、南客向綠營本土借殼生存,但當都市化瓦解了宗族、客委會國家化了資源,這群曾經的鐵票正從封閉的蜂巢碎裂為離散的搖擺者;預測客家庄的命運,就是預測傳統恩庇網絡在國家機器與資本市場的雙重夾擊下,還能維持多久的最後幻影。
---
last_updated: 2024-10-27
@@ -1,375 +0,0 @@
# 本省閩南族群
---
**類型**: 社會認同與受眾分析(敘事檔案)
**當前角色**: 台灣最大族群的主體結構、內部異質性、政治動員邏輯與預測基礎
**何時調用**: 預測 2028 及後續全國性選舉的板塊移動;分析「台灣主體性」論述的受眾基礎與邊界;評估兩岸危機、經濟脅迫對最大族群內部不同次群體的差異性衝擊;解讀民調中「台灣人認同」的深層次組成;規劃跨越藍綠的族群動員或文化訴求策略;分析閩南語與本土文化復興運動的政治效應
---
## 一句話濃縮
> 本省閩南族群不是鐵板一塊的「本土鐵票」,而是承載著「台灣人認同」最大公約數的巨大容器;其內部被地域(南綠北藍)、階級(農工 vs 白領)與世代(語言斷層)深刻撕裂,任何政黨若能成功將自身論述嵌入此容器的「主體性」框架,就能主宰台灣政治,若誤判其內部異質性而將其視為單一群體,必將在選舉數學與動員邏輯上遭遇結構性失準。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「本省閩南族群」與「民進黨基本盤」或「台獨支持者」劃上等號。這種簡化忽略了三個關鍵事實:第一,閩南族群佔台灣人口 70-72%,任何政黨若失去閩南族群的起碼支持都無法執政,國民黨在北部與中都會區仍擁有大量閩南選民;第二,閩南族群內部的階級與地域差異,遠大於其與客家族群或外省族群的差異;第三,年輕世代閩南人的「台灣人認同」已去族群化,成為一種國家預設值,不再需要透過閩南語或反對外省人來建構。
閩南族群的本質是台灣政治的「重力場」——它本身不移動,但所有政治行星(政黨、論述、候選人)都必須圍繞它運行並受其牽引。理解閩南族群,不是理解一個單一利益群體,而是理解台灣主體性論述的內部張力、邊界與裂縫。
---
## 群體定義與邊界:從血統論到主觀認同的流動邊界
### 事實層
閩南族群約佔台灣總人口 70-72%,是台灣社會絕對的多數群體。其識別標準在過去三十年經歷了從「血統與語言」向「主觀認同」的轉移。在戶籍與血統意義上,凡明清至戰前自中國閩南地區(泉州、漳州)移民來台者之後裔皆屬之;但在當代政治分析中,更關鍵的識別維度是「自我認同為台灣人」且「家庭主要溝通語言或文化根源包含閩南語(台語)」。
關鍵區分維度在於與其他群體的邊界:
1. **與客家族群(約 15-18%)**:歷史上因拓墾競爭與語言差異存在潛在張力,但在當代國家認同議題上高度同構,差異主要體現在地方派系利益與文化保存訴求上。
2. **與外省族群(約 10-12%)**:二二八事件與威權時期資源分配的歷史創傷,構成了閩南族群「反外來政權」的深層心理結構。此邊界在 40 歲以下世代中急速模糊。
3. **與新住民(約 3-5%)**:新住民(特別是東南亞籍)的下一代(新台灣之子)多在閩南族群的社會網絡中社會化,其政治行為模式正逐漸被閩南主體結構吸納。
### 脈絡層
**結構性命題**:閩南族群從「被壓迫的多數」轉變為「認同的主體」,其邊界策略從「封閉排他」轉向「開放吸納」。
在威權時期,閩南族群是政治與文化上的被壓迫者(語言被打壓、政治權力被外省精英壟斷)。此時的閩南認同是防禦性的,邊界清晰。民主化後,閩南族群成為選舉市場的多數,其認同轉變為「台灣人」的同義詞。為了維持多數統治的正當性,閩南認同必須從血統論轉向公民國族主義,將客家人、外省人乃至新住民包容進「台灣人」的框架中。這種轉變使得「閩南」作為一個獨立族群標籤的政治意義下降,被「台灣」這個國族標籤取代。
#### 分析含義
分析時如何用這個事實?
- **評估「族群動員」的邊際效用遞減**:在 1990 年代,直接訴諸「閩南人出頭天」具有強大動員力;在 2028 年,這種訴求不僅無效,甚至會因排他性而觸怒中間選民。政黨必須將閩南認同轉譯為「台灣主體性」或「民主防衛」的國族語言,才能安全地動員這 70% 的人口。
- **識別跨族群的聯盟基礎**:在國家認同議題上,閩南族群的多數意見(維持現狀/實質獨立)已成為台灣社會的最大公約數。分析者不應再將閩南視為一個需要與其他族群妥協的派系,而應視其為設定議程的主導者。
---
## 歷史記憶與認同建構:二二八的「族群效應」與代際衰減
### 事實層
二二八事件及隨後的清鄉運動,是形塑戰後閩南族群政治意識的創傷性起源。國民黨政權在事件中的鎮壓,使得「外來政權屠殺本土精英」的敘事深植於閩南族群的家族記憶中。此記憶在黨外時期與民主化進程中,被系統性地轉化為反對國民黨的正當性來源。
然而,此記憶的「族群效應」在不同世代呈現顯著差異:
- **65歲以上(經歷或口傳世代)**:記憶鮮明,對國民黨的排斥帶有情感與身體性,投票行為具有高度懲罰性。
- **40-64歲(美麗島與解嚴世代)**:將二二八記憶與民主運動結合,認同建構從「受害者」轉向「反抗者」,是民進黨本土論述的骨幹支持者。
- **20-39歲(太陽花與天然獨世代)**:二二八僅是教科書上的歷史事件,缺乏情感共鳴。他們的台灣認同是基於當下的生活方式與對北京的反感,而非對國民黨的歷史仇恨。
### 脈絡層
**破除常見簡化**:把二二八記憶視為閩南族群永恆的「反藍基因」是嚴重的分析錯誤。二二八的動員效力正在經歷不可逆的代際衰減。
歷史創傷的動員效力依賴於「活人的見證與傳承」。當事件從「我阿公的親身經歷」變成「歷史課本的一章」時,其引發政治行動的能力就會大幅衰退。2018年韓國瑜在高雄(二二八創傷最深的南部)拿下大量閩南選票,證明了當經濟利益訴求足夠強烈時,歷史創傷的防線是可以被突破的。年輕世代對二二八的態度是「尊重歷史,但與我現在的投票無關」。
#### 分析含義
分析時如何用這個事實?
- **修正「悲情動員」的預期效益**:在 2028 選戰中,民進黨若過度依賴二二八或威權受害的歷史敘事來動員閩南選票,將在年輕世代中產生「審美疲勞」甚至反彈。歷史記憶必須與當下的生存威脅(如北京的壓迫)掛鉤,才能重新激活其動員能量。
- **評估國民黨在南部「拔樁」的空間**:當歷史仇恨的約束力下降,國民黨或第三勢力在南部閩南族群中訴求「經濟翻轉」與「放下歷史包袱」,將比過去更具穿透力。
- 交叉引用:二二八歷史記憶的深層結構,詳見 `narratives/cross_strait/2_28_legacy.md`。
---
## 地域分化:南綠北藍的空間化與微觀解構
### 事實層
閩南族群內部最顯著的政治斷層線是地域分布。所謂「南綠北藍」,實質上是閩南族群內部因產業結構與歷史資源分配不同而產生的政治傾向分化。
| 地域 | 閩南族群特徵 | 主導政治傾向 | 結構性邏輯 |
|---|---|---|---|
| 南部(嘉南高屏) | 純度最高,農漁業與傳統製造業為主 | 深綠(本土防禦與利益依賴) | 歷史相對剝奪感最強,依賴民進黨地方執政的資源分配網絡 |
| 北部(雙北) | 與外省/客家族群高度混居,服務業與知識經濟為主 | 淺藍至搖擺(經濟務實與都會價值) | 歷史上是政經中心,受益於國民黨建設,對民進黨抗中路線的經濟風險更敏感 |
| 中部(中彰投雲) | 農工混合,地方派系色彩濃厚 | 搖擺(利益導向) | 派系利益超越統獨,誰能分配資源就支持誰 |
| 都會區(全台六都精華區) | 高度現代化,語言轉換為國語為主 | 淺綠至白(進步價值與反建制) | 後物質主義價值主導,對傳統藍綠的族群動員免疫 |
| 非都會區(農漁村、鄉鎮) | 人口老化,閩南語保留度高 | 深綠或深藍(依賴在地派系) | 恩庇侍從網絡的最後堡壘,資訊封閉性高 |
### 脈絡層
**中等模型自己想不到的洞察**:「南綠北藍」不是閩南族群內部的血統差異,而是「現代化進程的時間差」與「產業資本的位置」的空間化。
南部之所以深綠,是因為其現代化進程長期落後於北部,農漁業與傳統產業的從業者更依賴國家機器的資源分配與保護。民進黨在南部的執政,實質上建立了一套「本土政權照顧本土人」的恩庇侍從體系,取代了國民黨過去的網絡。北部閩南之所以偏藍或搖擺,是因為他們身處全球化的產業鏈中(科技、金融、貿易),其利益與兩岸關係的穩定高度綁定,對民進黨激進路線的風險承受度較低。
更關鍵的是「都會 vs 非都會」的切割。高雄市區(前鎮、苓雅)與高雄美濃(客家庄)或林園(工業區)的投票行為差異,可能大於高雄市區與台北市的差異。都會區的閩南年輕人,其政治偏好更接近都會的外省年輕人,而非鄉下的閩南長輩。
#### 分析含義
分析時如何用這個事實?
- **精準預測搖擺選區的板塊移動**:在 2028 選戰中,分析者不應將「南部」視為鐵板一塊。南部都會區的年輕閩南選票正面臨被 TPP 或無黨籍吸納的風險,而南部農漁村的老年選票則因人口凋零而數量萎縮。民進黨在南部的領先幅度,取決於都會區的流失速度是否超過農漁區的鐵票存量。
- **設計地域差異化的論述**:對南部非都會閩南,論述應聚焦「北京打壓農漁產品」與「本土政權的照顧」;對北部都會閩南,論述應轉向「如何在抗中前提下維持經濟韌性」與「民主防衛」。一套論述打天下必將顧此失彼。
---
## 世代與階級分化:語言斷層與從「共同體」到「原子化」
### 事實層
閩南族群內部的世代差異,核心體現在語言能力與階級位置的交叉上。
1. **語言斷層**:65歲以上閩南人以閩南語為主要甚至唯一語言;40-64歲為雙語世代(閩南語+國語);20-39歲則以國語為主,閩南語能力大幅衰退,僅具備「聽說」能力甚至完全不懂。語言能力的衰退並未削弱其台灣認同,反而使其認同從「語言文化認同」轉向「政治國族認同」。
2. **階級分化**:
- **農工基層(中高齡為主)**:傳統民進黨支持主力,利益依賴型,對分配正義敏感。
- **中小企業主與台商(中年為主)**:利益跨越兩岸,傾向支持國民黨的和平紅利論述,或民進黨的務實派。
- **白領受薪階級與知識精英(年輕為主)**:後物質主義者,支持進步價值與台灣主體性,對傳統閩南文化的父權與保守成分持批判態度。
### 脈絡層
**破除常見簡化**:認為「閩南語流失等於閩南族群認同瓦解」是嚴重的分析錯誤。事實恰恰相反,語言的去必要化,使得台灣認同從一個「族群標記」升級為「國家預設值」。
過去,不會說閩南語的外省人難以被接納為「真正的台灣人」;現在,不會說閩南語的年輕閩南人與外省人、客家人共同建構了一種基於「民主生活方式」的台灣人認同。這種認同比語言認同更堅固,因為它與國家安全直接掛鉤。但這也意味著,傳統以閩南語為載體的政治論述(如競選場合的閩南語演講),對年輕世代的穿透力正在歸零。
#### 分析含義
分析時如何用這個事實?
- **選擇正確的溝通載體**:試圖用閩南語打動 20-39 歲閩南選民是無效的,甚至可能被視為「刻意作態」。對此群體,必須使用國語,並以價值論述(民主、自由、反威權)為主。
- **評估「文化台獨」路線的受眾基礎**:強調閩南文化復興與本土化的論述,僅對 50 歲以上的本土派有效;對年輕世代,必須將文化保存轉譯為「多元文化」或「抗中保台」的防禦性價值,才能引發共鳴。
---
## 政治傾向與投票模式:三大陣營的閩南板塊拆解
### 事實層
閩南族群作為 70% 的多數,其選票分散在三大陣營中,而非由單一政黨壟斷。
1. **民進黨的閩南基本盤(約佔閩南選民的 40-45%)**:
- 核心為南部農工基層、都會自由派與理念獨派。
- 投票驅動力:台灣主體性防禦、反對國民黨、分配利益依賴。
- 交叉引用:此群體與 `entities/parties/dpp_voter_base.md` 中的「基層本土派」與「都會自由派」高度重合。
2. **國民黨的閩南板塊(約佔閩南選民的 25-30%)**:
- 核心為北部都會區的閩南中產階級、中部地方派系成員、台商與依賴兩岸經貿的中小企業主。
- 投票驅動力:經濟利益極大化、避戰心理、對民進黨執政失能的反感。
- 交叉引用:此群體與 `entities/parties/kmt_voter_base.md` 中的「本省地方派系」與「經濟利益群體」高度重合。
3. **第三勢力/游離的閩南選民(約佔閩南選民的 25-30%)**:
- 核心為年輕高學歷閩南人(特別是男性)、對藍綠惡鬥厭倦的中間選民。
- 投票驅動力:反建制情緒、分配正義(高房價/低薪)、實用主義。
- 此群體是 2024 年柯文哲 TPP 的主要支持者,也是 2028 選戰的決勝關鍵。
### 脈絡層
**結構性命題**:閩南族群的政治分裂,本質上是「主權防禦」與「生存利益」的優先序之爭。
當北京壓迫感極強時(如 2020),主權防禦壓倒生存利益,民進黨吸納超過 50% 的閩南選票;當北京壓迫感降低或國內經濟困境凸顯時(如 2024),生存利益反撲,國民黨與 TPP 瓜分不滿的閩南選票。閩南選民不是天然獨,而是「條件式的主權防禦者」——他們願意為主權付出代價,但代價有一個由其經濟承受力決定的臨界點。
#### 分析含義
分析時如何用這個事實?
- **計算民進黨的閩南票流失天花板**:民進黨在閩南族群中的地板約為 35%(深綠基本盤),天花板約為 55%(危機動員)。在常態選舉中,民進黨若無法維持 45% 以上的閩南選票,將面臨當選危機。
- **識別國民黨的閩南突破點**:國民黨在閩南族群中的天花板約為 35%。若要突破,必須在「避戰」與「經濟利益」上提出比民進黨更具說服力的方案,且候選人必須具備足夠的「本土形象」以消除「外來政權」的歷史疑慮。
---
## 核心議題敏感點與認知脆弱點
### 事實層
閩南族群在不同議題上的敏感度呈現高度異質性:
1. **主權與國家安全**:對北京干預台灣選舉、軍事威脅高度敏感。但敏感度呈現「南高北低」、「老高少低」的分布。
2. **經濟分配與生存壓力**:對農產品外銷受阻、中小企業倒閉、高房價、低薪高度敏感。這是閩南選民最普遍的脆弱點,跨越藍綠。
3. **文化尊嚴與母語**:對閩南語被打壓、本土文化被邊緣化有歷史創傷,但主要限於 50 歲以上世代。
4. **社會秩序與治安**:對詐騙、黑道、毒品等治安問題極度反感,這是傳統閩南社區的底線。
### 脈絡層
**中等模型自己想不到的洞察**:閩南族群最深的認知脆弱點,是「被邊緣化的恐懼」——無論是主權上被北京邊緣化,還是經濟上被全球化邊緣化,或是文化上被年輕世代邊緣化。
這種恐懼使得閩南選民極易接受「生存危機」的敘事。民進黨的「抗中保台」是訴諸主權邊緣化的恐懼;國民黨的「兩岸和平紅利」是訴諸經濟邊緣化的恐懼;TPP 的「打破藍綠分贓」是訴諸政治被邊緣化的恐懼。哪一種恐懼在當下最真實,哪一種論述就最能動員閩南選票。
#### 分析含義
分析時如何用這個事實?
- **設計議題攻擊的切入點**:攻擊民進黨「挑釁北京導致經濟邊緣化」,比攻擊其「貪腐」更能動員北部閩南選民;攻擊國民黨「親中賣台導致主權邊緣化」,比攻擊其「老邁」更能動員南部閩南選民。
- **評估假訊息的穿透力**:針對閩南農工基層的假訊息,最有效的框架是「你的生存利益被剝奪」(如「政府進口萊豬害死本土豬農」、「民進黨擋了農產品銷中大賺的機會」);針對閩南長輩的假訊息,最有效的框架是「你的文化被消滅」或「你的子孫要上戰場」。
---
## 認知韌性:抵抗動員與訊息影響的結構
### 事實層
閩南族群並非全然被動的訊息接收者,其內部存在強大的認知韌性因素:
1. **地方派系與人際網絡的交叉驗證**:在非都會區,資訊的傳播依賴人際網絡(廟口、農會、樁腳)。當外部訊息與地方意見領袖的判斷衝突時,地方網絡的信任度通常壓過外部訊息。
2. **民主經驗的積累**:閩南族群經歷了從黨外到政黨輪替的完整民主化進程,對「威權洗腦」有本能的排斥。北京或特定媒體的過度宣傳,常引發反效果。
3. **資訊源的多元化(都會區)**:北部都會閩南的資訊來源極度多元,具備較高的媒體識讀能力,對單一敘事的免疫力強。
### 脈絡層
認知韌性的分布與教育程度、都會化程度高度正相關,但與年齡負相關。這意味著,閩南族群中最缺乏認知韌性的是「非都會區的中低教育程度長者」,而這正是傳統本土派的核心。當地方派系鬆動、人際網絡被網路同溫層取代時,此群體的認知韌性將迅速崩解,成為內容農場與假訊息的易感人群。
#### 分析含義
分析時如何用這個事實?
- **評估「空戰」與「陸戰」的效力邊界**:在南部農漁村,試圖用網路空戰改變老年閩南選民的投票意向極為困難,必須透過地方樁腳與人際網絡進行陸戰;但在北部都會區,陸戰無效,必須依賴空戰與議題設定。
- **預測資訊戰的突破口**:當地方派系因資源枯竭而無法維持人際網絡時,北京或特定勢力的資訊戰即可長驅直入,填補信任真空。這是 2028 前南部基層可能發生的結構性變化。
---
## 兩岸與國際議題態度:實用主義與主權焦慮的交織
### 事實層
閩南族群在兩岸議題上的態度,呈現「主權立場堅定,經濟立場實用」的雙軌特徵:
1. **統獨光譜**:壓倒性支持「維持現狀」(永遠維持或以後再決定),法理台獨與統一皆為極少數。但「維持現狀」的實質內涵是「實質獨立於北京統治之外」。
2. **對北京態度**:普遍不信任北京政權,但區分「北京政府」與「中國市場」。對北京的政治打壓極度反感,但對中國的經濟依賴有務實認知。
3. **ECFA 與兩岸交流**:農工基層與中小企業主對 ECFA 有高度利益依賴,恐懼中止會衝擊生計;都會白領則較支持降低對中依賴,走向國際市場。
4. **對美方態度**:視美國為安全保障的不可替代者,但對美國的承諾存有疑慮(疑美論在閩南基層有一定市場,特別是當美國要求台灣購買不對稱武器或延長兵役時)。
5. **對日方態度**:歷史上存在複雜情結(殖民創傷與現代化建設的交織),但在安全議題上傾向與日本合作制衡北京。
### 脈絡層
**破除常見簡化**:把閩南族群對 ECFA 的依賴等同於「支持兩岸統一」是致命的分析錯誤。閩南選民的利益邏輯是「賺中國的錢,保台灣的主權」。
他們希望魚與熊掌兼得。當北京將經濟利益與政治讓步掛鉤時,閩南選民會陷入痛苦的利益權衡。2024 年,南部農漁民雖然抱怨民進黨無法讓農產品銷中,但他們並未因此轉向支持國民黨的「九二共識」,因為他們清楚「九二共識」意味著主權的妥協。他們選擇了「含淚支持民進黨保主權,但同時抱怨生計受損」。
#### 分析含義
分析時如何用這個事實?
- **預測北京經濟脅迫的政治效應**:北京對農漁產品的禁令,在短期內會引發閩南基層對民進黨執政無能的抱怨,但在中長期會強化「北京打壓台灣」的受害者敘事,反而有利於民進黨的主權動員。北京若想以經濟脅迫改變閩南選民的投票行為,必須提供一個「不涉主權妥協的經濟替代方案」,而這正是北京不願意做的。
- **評估「疑美論」在閩南基層的擴散風險**:若美國在兩岸危機中表現出猶豫或退縮,閩南基層的「疑美論」將迅速轉化為「避戰論」,進而動搖民進黨的防禦底線。
- 交叉引用:閩南族群的國族認同結構,詳見 `primitives/concepts/national_identity.md` 與 `narratives/cross_strait/taiwanese_identity_narrative.md`。
---
## 信息行為與文化認同:媒介斷層與本土符號的爭奪
### 事實層
閩南族群的信息行為呈現極端的世代斷層:
1. **65歲以上**:依賴傳統電視(特別是特定色彩的有線新聞台)、廣播、地方人際網絡。對閩南語媒體有高度忠誠度。Line 群組是主要網路資訊來源,易受長輩圖與假訊息影響。
2. **40-64歲**:電視與網路並重。使用 Facebook 與 Line。是政論節目的核心受眾,對政治有強烈意見,資訊同溫層效應顯著。
3. **20-39歲**:幾乎不看傳統電視。資訊來源為 YouTube、PTT、Dcard、Instagram、Threads。對閩南語媒體無特殊偏好,對傳統政論節目極度反感。
在文化認同上,近年出現「閩南語復興運動」,但此運動的政治意涵已被重新編碼。過去閩南語是「反抗國民黨」的符號;現在閩南語在年輕世代中被賦予了「本土文化資產」與「抗中保台」的新意涵(相對於北京的「推廣普通話」與「閩南語是中國方言」的論述)。
### 脈絡層
**結構性命題**:閩南族群的媒介斷層,使得任何單一的競選傳播策略都無法覆蓋全體。
民進黨過去依賴閩南語電台與造勢晚會動員南部基層,這套模式對 50 歲以上選民仍有效,但對 20-39 歲選民完全失靈。2024 年柯文哲成功透過 YouTube 與短影音吸納年輕閩南選票,證明了新媒介的穿透力已超越傳統的族群動員網絡。閩南語不再是政治動員的必要工具,反而可能成為跨世代溝通的障礙。
#### 分析含義
分析時如何用這個事實?
- **規劃 2028 的媒體預算分配**:試圖用閩南語電視廣告打動年輕閩南選民是資源浪費。必須針對不同世代使用不同語言(國語/閩南語)與不同媒介(傳統媒體/社群媒體)。
- **評估文化認同論述的轉譯成本**:將「保護閩南語」轉譯為「保護台灣文化多樣性以對抗北京文化統戰」,是吸引年輕閩南選民支持本土文化政策的唯一有效路徑。單純訴求「不忘本」對年輕人缺乏吸引力。
---
## 近年變化趨勢與 2028 情境推演
### 事實層
近 5-10 年,閩南族群經歷了三大結構性演變:
1. **人口老化與自然凋零**:南部深綠鐵票的物理基礎正在萎縮。
2. **年輕世代的去族群化認同**:閩南年輕人的台灣認同不再需要閩南語或反國民黨來定義,這使得民進黨的傳統本土論述失去壟斷地位。
3. **經濟結構的轉型焦慮**:從依賴兩岸經貿轉向「去風險化」的過程中,農工基層閩南承受了最大的轉型陣痛。
### 脈絡層
閩南族群作為台灣政治重力場的地位未變,但其內部的重力分布正在轉移。從「南重北輕、老重少輕」轉向「都會重、非都會輕」。2028 年的關鍵變數在於:
1. **北京的施壓模式**:若北京採取高強度軍事威懾,閩南選民將再次被主權恐懼強制縫合,民進黨受惠;若北京採取精準經濟拉攏(如特定產業開放),將加劇閩南內部的階級與地域撕裂。
2. **居住正義與分配的兌現**:年輕閩南選民對高房價與低薪的憤怒,是 2028 最具爆發力的斷層線。若民進黨無法回應,此群體將持續流向 TPP 或成為不投票的冷漠者。
3. **TPP 的固化或泡沫化**:TPP 是否能成為年輕閩南選民的常態性選擇,決定了民進黨在閩南族群中的天花板是否被永久鎖定在 45% 以下。
#### 分析含義
分析時如何用這個事實?
- **預測 2028 的決戰點**:決戰點不在南部的閩南鐵票區(民進黨穩贏但領先縮小),而在北部與中部的都會閩南搖擺選民,以及全台 20-39 歲的年輕閩南選民。這兩群人的投票率與流向,決定 2028 的勝負。
- **評估「抗中保台」的升級需求**:單純的「抗中」已不足以動員年輕閩南,必須升級為「抗中保台 + 內部改革」,將主權防禦與分配正義掛鉤,才能修復雙峰聯盟的裂痕。
---
## 關聯分析
### 關聯一:與台灣人認同敘事的連結(提供脈絡層)
- `narratives/cross_strait/taiwanese_identity_narrative.md`:閩南族群是「台灣人認同」的主要載體與建構者。理解此敘事的演變,是解讀閩南選民為何從「防禦性認同」轉向「國族預設值」的關鍵。
### 關聯二:與二二八歷史遺產的連結(提供事實層與脈絡層)
- `narratives/cross_strait/2_28_legacy.md`:二二八是閩南族群「反外來政權」心理結構的起源。分析此歷史記憶的代際衰減,有助於評估民進黨「悲情動員」的效期與轉型需求。
### 關聯三:與民進黨選民基礎的連結(用於對照與交叉驗證)
- `entities/parties/dpp_voter_base.md`:民進黨的「基層本土派」與「都會自由派」主要即由閩南選民構成。對照兩者,可識別民進黨論述在閩南族群內部的覆蓋率與盲區。
### 關聯四:與國民黨選民基礎的連結(用於對照)
- `entities/parties/kmt_voter_base.md`:國民黨的「本省地方派系」與「經濟利益群體」即為北部與中部的閩南選民。對照兩者,可解釋為何同一族群在不同地域呈現截然不同的投票行為。
### 關聯五:與國族認同概念的連結(提供分析框架)
- `primitives/concepts/national_identity.md`:提供拆解閩南族群「族群認同」與「國家認同」重疊與分離的分析工具。特別是年輕世代「去族群化的國族認同」現象,需以此框架解讀。
---
## 常見的分析陷阱
### 陷阱一:將「閩南族群」等同於「民進黨鐵票」
這是最致命的錯誤。閩南族群佔 70%,民進黨常態得票率約 40%。這意味著有近一半的閩南選民不投民進黨。忽略北部與中部閩南的經濟務實傾向,會嚴重高估民進黨的選舉優勢。
### 陷阱二:假設「閩南語」是動員閩南選民的萬能鑰匙
閩南語在 40 歲以下世代中已失去政治動員功能。試圖用閩南語打動年輕選民,不僅無效,甚至可能因「刻意」而引發反感。語言必須與價值論述(如文化多樣性、抗中)結合,才能產生政治效果。
### 陷阱三:忽略「南綠北藍」內部的階級與都會差異
把南部視為鐵板一塊的深綠,忽略南部都會區年輕選民的搖擺性;把北部閩南視為偏藍,忽略北部都會白領在主權危機時的淺綠傾向。這種粗糙的地理決定論會導致選情預測失準。
### 陷阱四:將「對 ECFA 的依賴」解讀為「支持統一」
閩南農工基層與中小企業主依賴中國市場,但他們支持的是「賺中國的錢」,而非「被中國統治」。將經濟依賴等同於政治傾向,會誤判北京經濟脅迫的真實政治效應。
### 陷阱五:過度解讀「二二八效應」的持續力
二二八的歷史創傷在年輕世代中已轉化為抽象的歷史記憶,不再具備直接的投票驅動力。假設「反國民黨」仍是閩南年輕選民的核心動力,會導致論述設計與現實脫節。
### 陷阱六:將「台灣人認同」的上升等同於「支持法理台獨」
閩南族群壓倒性地支持「台灣人認同」,但這種認同的實質內涵是「維持現狀」。將「台灣人認同」的上升解讀為推動法理台獨的民意基礎,是混淆了「防禦性認同」與「進攻性訴求」。
---
## 仍不確定的部分
### 年輕閩南選民的「避戰臨界點」
年輕閩南選民支持台灣主體性,但他們同時面臨兵役延長與戰爭風險的實體壓力。當兩岸緊張升高到何種程度時,他們會從「抗中保台」轉向「避戰求和」?此臨界點目前缺乏實證數據,且極易受外部事件(如烏克蘭戰爭的發展)牽動。
### 北京「精準經濟拉攏」的分化效果
北京若放棄全面施壓,轉而針對南部農漁業或特定傳統產業進行精準開放(如採購合約),能否有效撕裂閩南基層與民進黨的利益綁定?這取決於閩南基層對民進黨「轉型正義優先於經濟利益」的容忍度,目前仍不確定。
### TPP 崩解後年輕閩南票的流向
若柯文哲司法案件導致 TPP 泡沫化,年輕閩南選票會回流民進黨(基於主權防禦),還是轉向國民黨(基於經濟利益或反建制),或是轉為不投票?此群體的行為模式缺乏歷史先例,其「反建制」與「主權防禦」的優先序仍難以量化。
### 閩南語復興運動的政治歸屬
近年興起的閩南語復興運動(如台語文創作、podcast),是否能成功將閩南語從「老一輩的本土符號」轉譯為「年輕世代的進步價值」?若成功,將強化本土派的文化論述;若失敗,閩南語將加速淪為純粹的家庭溝通工具,失去政治動員功能。
---
## 與其他文件的關係
- 台灣人認同敘事:`narratives/cross_strait/taiwanese_identity_narrative.md`(對照閩南族群作為認同主體的建構過程)
- 二二八歷史遺產:`narratives/cross_strait/2_28_legacy.md`(對照歷史創傷如何形塑閩南族群的防禦性心理結構)
- 民進黨選民基礎:`entities/parties/dpp_voter_base.md`(對照閩南本土派與民進黨的利益綁定與衝突)
- 國民黨選民基礎:`entities/parties/kmt_voter_base.md`(對照北部與中部閩南選民的經濟務實傾向)
- 國族認同概念:`primitives/concepts/national_identity.md`(提供拆解族群認同與國家認同的分析框架)
---
## 一句話濃縮
> 本省閩南族群是承載台灣主體性的巨大容器,其內部被地域、階級與世代的斷層深刻切割;預測台灣政治的走向,不是預測這 70% 的人會往哪裡移動,而是預測哪一股政治勢力能成功將自己的論述嵌入這個容器的最大公約數,同時不被其內部的撕裂所反噬。
---
last_updated: 2024-10-26
@@ -1,351 +0,0 @@
# 產業工人族群
---
**類型**: 受眾社會認同(政治敘事與行為特化檔案)
**當前角色**: 台灣製造業與營建業基層勞動力的政治態度集結、經濟焦慮載體與選舉搖擺變數
**何時調用**: 預測 2028 及後續全國性選舉中勞工政策與經濟議題的效應;分析兩岸經貿政策(如 ECFA、關稅壁壘)對基層選民的實質衝擊與政治反應;評估勞基法修法、移工政策、能源轉型(空污與電價)等內政議題的選票殺傷力;解讀「疑美論」與「避戰論」在藍領群體中的傳播機制;規劃針對中南部傳統產業聚落與營建工地網絡的訊息滲透或動員策略
---
## 一句話濃縮
> 產業工人是台灣政治板塊中「經濟生存焦慮」大於「主權理念認同」的關鍵搖擺載體,他們在地域(南綠北藍)、產業(製造業 vs 營建業)與契約型態(正規僱用 vs 外包點工)的交叉撕裂下,形成既非鐵票也非鐵板一塊的異質性聯盟;誰能兌現「顧腹肚」的實質利益,誰就能暫時租借他們的選票,但一旦利益受損,他們的懲罰性反噬也最為猛烈。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「產業工人」視為一個同質性的「基層勞工」或直覺性地將其等同於「深綠鐵票」或「傳統國民黨支持者」。這種簡化在預測時會導致嚴重失準,也無法解釋為何同樣是工人,南部重工業工人偏綠,北部加工廠工人偏藍,而營建業工人則隨工程案與地方派系流動。
台灣產業工人的政治本質,是「被雙重擠壓的生存焦慮聯盟」:
1. **向上擠壓**:面對資本外移、產業升級與科技業虹吸效應,傳統製造業與營建業的薪資停滯,向上流動的階梯斷裂。
2. **向下擠壓**:面對移工競爭(製造業)、外包化與點工化(營建業),勞動條件與議價能力持續探底。
這群體在政治上呈現高度的「實用主義」與「短期契約化」特徵。他們對抽象的統獨爭議缺乏耐心,對「抗中保台」的熱情取決於這是否會讓他們失去工作(如 ECFA 取消導致關廠);他們對「進步價值」感到疏離,因為同婚與轉型正義無法換算成加班費或讓物價降下來。理解產業工人,就是理解台灣選舉中「經濟民生」這條斷層線何時會壓過「主權國安」。
---
## 群體定義與邊界:三百萬人的異質性拼圖
### 事實層
台灣產業工人的人口規模約在 300 至 400 萬之間,佔總就業人口的 25-30%。此檔案定義的「產業工人」,主要指涉從事體力勞動或基層技術操作的非白領受薪者,核心涵蓋兩大領域:
1. **傳統制造業基層作業員**:包含金屬工業、機械設備、紡織、塑化、食品加工等。主要聚落在中南部(台中精密機械聚落、彰化紡織與水五金、雲林離島工業區、高雄鋼鐵與石化)。
2. **營建業基層工班**:包含模板、鋼筋、泥作、水電、室內裝修等。分佈全台,高度依賴房地產景氣與公共工程標案。
關鍵區分維度在於「僱傭型態」與「產業聚落」。製造業多為廠內集中僱用(受勞基法較強約束),營建業則為高度外包的「點工點價」與無固定僱主(勞保多掛工會,投保薪資低報普遍)。
### 脈絡層
**破除常見簡化**:把產業工人視為「低學歷、低收入的均質群體」是嚴重的分析錯誤。
製造業與營建業的內部差異,甚至大於製造業與服務業的差異。製造業工人受制於工廠的紀律與產線的節奏,其抗爭多圍繞「加班費」、「關廠歇業」與「移工替代」;營建業工人則受制於天候、工地進度與包商的財務狀況,其焦慮多圍繞「無工可做」(雨天停工、景氣衰退)與「職災無保障」。這種勞動型態的差異,直接決定了他們接收政治訊息的時間(工廠休息室 vs 工地工寮)、對政策的敏感點(勞基法修法 vs 職災保險法),以及被動員的網絡(廠區工會 vs 地方派系樁腳)。
#### 分析含義
分析時如何用這個事實?
- **精準評估政策衝擊**:當北京對台施加經濟脅迫(如取消 ECFA 早收清單的石化或機械關稅優惠),分析者必須區分受衝擊的是中南部製造業工人,而非營建業工人。將政策效應一體適用於「勞工選民」,會嚴重高估或低估政治反噬的規模。
- **識別動員網絡的差異**:要滲透製造業工人,關鍵節點是廠區工會幹部與休息室內的 Line 群組;要滲透營建業工人,關鍵節點是工地主任、包頭與地方派系議員。
---
## 次群體結構:生存焦慮的三重變奏
### 傳統製造業工人(中南部聚落的本土派底座)
#### 事實層
此群體約佔產業工人的 50-60%,是中南部選舉的關鍵少數。年齡層偏高(45-65歲佔大宗),面臨嚴重的技術斷層與青黃不接。其核心特徵是「在地化與工廠生活的綁定」——許多人一輩子在同一工業區或同一公司工作,生活圈緊密圍繞工廠、宮廟與地方社區。
在投票行為上,此群體過去是 DPP 的穩定支持者,根源於早期黨外運動與自主工運的連結(如遠化罷工、新光紡織抗爭),以及對國民黨長期「重北輕南」產業政策的相對剝奪感。但近年來,隨著 DPP 執政推動能源轉型(引發空污與電價焦慮)與勞基法修法(引發加班費減少與彈性下降的抱怨),此群體的綠營認同正在鬆動。
#### 脈絡層
**結構性命題**:傳統製造業工人是 DPP「本土政權」論述的隱性債主,他們支持本土是為了換取資源分配,而非進步價值。
當 DPP 將「抗中保台」與「減碳淨零」綁定時,直接觸犯了製造業工人的生存邏輯。對他們而言,「減碳」意味著工廠成本增加、限電停工風險上升;「抗中」若導致兩岸經貿脫鉤,意味著訂單流失與關廠。他們對 DPP 的支持是「利益交易型」的:過去 DPP 爭取了他們的尊嚴與資源,如今若 DPP 的政策威脅到他們的飯碗,他們會毫不猶豫地轉向或拒投。2018 年 DPP 在中南部工業區的潰敗,正是這種「生存焦慮」壓倒「本土認同」的具體展現。
#### 分析含義
分析時如何用這個事實?
- **計算 DPP 在南部的流失率**:在 2028 預測中,若兩岸經貿持續緊縮且電價調漲,DPP 在中南部傳統工業區(如高雄小港、大林蒲、台中港區)的得票率將面臨顯著下滑,此下滑無法被「抗中」論述填補。
- **評估「空污與能源」議題的殺傷力**:對製造業工人攻擊 DPP 的能源政策,不能只談「缺電」,必須連結到「工廠停線」與「失業」的具體恐懼,這比抽象的環保爭議有效百倍。
- 交叉引用:此群體的投票模式受 `narratives/domestic/industrial_policy_discourse.md` 中產業升級與淘汰論述的直接擠壓。
### 營建業工人(全台流動的契約化選民)
#### 事實層
此群體約佔產業工人的 25-30%,是台灣勞動市場中最「去制度化」的一群。他們沒有固定雇主,多數以「點工」形式受僱於層層外包的包商,勞保多掛在職業工會,且普遍存在低報投保薪資的情況。其核心特徵是「短期契約與流動性」——哪裡有工程(無論是建案、捷運、科學園區擴建),他們就往哪裡去。
在投票行為上,此群體是典型的「利益依賴型選民」,且高度受地方派系與包商網絡控制。工地主任或大包頭往往兼任地方樁腳,透過工作機會的分配、節日的慰問金、甚至職災時的私下救濟,建立深厚的恩庇侍從關係。他們的政治傾向不固定,取決於當下誰能掌握工程發包權。
#### 脈絡層
**中等模型自己想不到的洞察**:營建業工人的投票行為是「點工化」的——他們的選票像勞動力一樣,是按次計價、短期租賃的。
營建業工人對政黨的忠誠度極低,對意識形態的敏感度也低。他們在乎的是「有沒有頭路」(工程案多不多)。當房市景氣低迷或政府緊縮公共建設預算時,他們面臨無工可做的危機,此時任何承諾「拼經濟、蓋建設」的候選人都能吸引他們;當政府推出前瞻基礎建設或科學園區擴建時,執政黨則能透過掌控工程發包權,將選票牢牢綁定。這使得他們成為地方派系最愛經營的「鐵衛軍」,因為他們的投票率可以透過組織動員精確控制。
#### 分析含義
分析時如何用這個事實?
- **預測地方派系的動員能量**:營建業工人的投票率,是地方選舉的領先指標。若選前大型公共工程密集發包或建案開工率高,執政陣營的組織票將相對穩固;若景氣寒冬,在野陣營的「顧腹肚」訴求將產生強大穿透力。
- **評估「居住正義」論述的邊際成本**:打擊高房價的政策(如囤房稅、信用管制)雖然受年輕人歡迎,但若導致建商縮手、工地停工,將直接傷害營建業工人的生計。DPP 在推動居住正義時,必須同時維持公共工程量能以吸納就業,否則將在此群體中付出慘重代價。
### 科技廠基層技術員(新貧階級的搖擺者)
#### 事實層
此群體約佔產業工人的 15-20%,主要分佈在桃竹苗的科學園區與工業區,擔任輪班作業員、設備操作員。年齡層較輕(25-45歲),學歷多為高中職或專科。其核心特徵是「高工時與高房價的夾擊」——雖然薪資優於傳統製造業,但面臨極高的輪班壓力(日夜班交替),且收入的大半必須投入園區周邊的高昂房價或租金。
在投票行為上,此群體是典型的「搖擺選民」。他們對 DPP 的進步價值無感,對 KMT 的親中路線疑慮(擔心技術外流影響公司訂單),對 TPP 的反建制形象有高度共鳴。2024 年柯文哲在桃竹苗的高得票率,相當比例來自此群體的年輕男性。
#### 脈絡層
科技廠基層技術員是台灣「分配正義」斷層線的具體化身。他們創造了半導體與科技業的產值,卻無法分享資本利得,只能用健康(輪班)換取勉強過得去的薪水,再用薪水繳給房東或建商。他們對「抗中保台」的態度是實用主義的:如果兩岸緊張能帶來轉單效應讓公司加薪,他們支持;如果兩岸緊張導致供應鏈中斷或兵役延長影響排班,他們反對。
#### 分析含義
分析時如何用這個事實?
- **識別 TPP 的潛在票倉**:科技廠基層技術員是 TPP 最容易固化的板塊。他們厭惡傳統藍綠的派系分贓,渴望「理性、務實、拚經濟」的政治形象。若 2028 TPP 推出具科技背景的候選人或強打居住正義,將進一步掏空 DPP 與 KMT 在桃竹苗的選票。
- 交叉引用:此群體的薪資焦慮與 `narratives/domestic/wage_stagnation_narrative.md` 高度連結,是停滯感最強烈的群體之一。
---
## 核心議題敏感點:經濟生存壓倒一切
### 薪資停滯與外包化的雙重絞殺
#### 事實層
台灣產業工人的實質薪資成長率在過去 20 年間幾近停滯。根據主計處數據,受僱員工經常性薪資扣除物價因素後,製造業與營建業的成長幅度遠低於服務業與金融業。與此同時,勞動彈性化與外包化加劇,營建業幾乎全面點工化,製造業也大量使用派遣與移工,導致本勞的議價能力持續下降。
#### 脈絡層
**破除常見簡化**:把產業工人的薪資停滯單純歸咎於「資方剝削」或「政府無能」是不夠的。這是全球化分工與台灣產業結構的雙重結果。
製造業面臨北京與東南亞的低價競爭,只能壓縮勞動成本以求生存;營建業則受制於層層外包的發包制度,利潤被上游建商與中游包商截走,基層工班只能被動接受點工價格。這種結構性的無力感,使得產業工人對任何「經濟成長」的總體數據(如 GDP 成長率)感到極度憤怒,因為他們切身感受到「經濟成長果實歸老闆,物價上漲苦果歸工人」。
#### 分析含義
分析時如何用這個事實?
- **預測「經濟利好」論述的失效**:當執政黨宣傳台灣經濟創新高時,若無法解決分配不均,這種論述在產業工人中會產生反效果,被視為「何不食肉糜」的挑釁。
- **評估「反商情緒」的動員潛力**:任何能將工人困境歸咎於「財團炒房」、「兩岸買辦」或「政府圖利特定產業」的論述,都能在產業工人中迅速引發共鳴。這是韓國瑜 2018 「貨出得去、人進得來」與柯文哲 2024 「政府被財團控制」論述的底層邏輯。
- 交叉引用:此處必須結合 `narratives/domestic/wage_stagnation_narrative.md`,理解薪資停滯如何轉化為政治憤怒。
### 移工競爭與勞基法修法的信任破產
#### 事實層
台灣移工人數已突破 70 萬,其中產業移工佔絕大多數,集中於製造業與營建業。移工的引入填補了三班制的勞動力缺口,但也壓抑了本勞的薪資成長空間,並改變了職場的勞動條件(如語言溝通成本增加、管理方式趨向嚴苛)。
2016-2018 年的勞基法修法(一例一休與後續鬆綁),是產業工人與 DPP 關係惡化的關鍵轉折。DPP 原本承諾「週休二日」與「落實加班費」,但修法結果卻給予資方更大的排班彈性(如輪班間隔縮短、加班時數上限放寬),被工人視為「砍假」與「圖利資方」。
#### 脈絡層
**中等模型自己想不到的洞察**:移工問題的本質不是種族衝突,而是「國家將勞動力再生產的成本外部化」的結構性矛盾。
台灣政府依賴移工來維持傳統產業的競爭力,卻不願承擔移工的社會福利與家庭照顧成本,也不願強制資本家升級產業。這導致本勞與移工被置於同一個向下沉淪的勞動力市場中互相競爭。本勞對移工的怨懟,本質上是對國家產業政策失敗與勞動檢查不足的代償性憤怒。而勞基法修法的爭議,則徹底摧毀了 DPP 作為「勞工朋友」的道德光環,使得工人對政治人物的承諾產生永久性的信任赤字。
#### 分析含義
分析時如何用這個事實?
- **評估「反移工」論述的邊界**:直接攻擊移工可能引發種族主義爭議,但訴求「保障本勞工作權」、「落實勞檢避免移工淪為低薪奴工」,則能在產業工人中獲得廣泛認同。
- **計算 DPP 的「勞工票」修復成本**:DPP 在 2028 要修復與產業工人的關係,無法靠口號,必須在具體的勞動政策(如嚴格限制加班時數、提高移工直聘比例)上做出實質讓步,但這又會與資方利益衝突,形成兩難。
- 交叉引用:移工論述的具體操作,詳見 `narratives/domestic/migrant_workers_discourse.md`。
---
## 政治傾向與投票模式:地域、產業與政黨的三角關係
### 事實層
產業工人的政治傾向呈現高度的地域與產業差異,絕非單一板塊:
| 地域/產業 | 主導次群體 | 政治傾向特徵 | 關鍵驅動力 |
|---|---|---|---|
| 南部重工業區(高雄、屏東) | 傳統製造業(鋼鐵、石化) | 偏綠,但鬆動中 | 歷史工運記憶、本土認同;受空污與減碳政策摩擦 |
| 中部精密機械與紡織區(台中、彰化) | 傳統製造業與中小企業 | 搖擺,偏藍或派系導向 | 中小企業主僱員關係模糊、地方派系(紅黑派)動員 |
| 桃竹苗科技園區周邊 | 科技廠基層技術員 | 搖擺,流向 TPP | 高房價、高工時、反建制情緒 |
| 北部都會邊緣工業區(新北、桃園) | 傳統製造業與營建業 | 偏藍,或流動 | 外省網絡、軍公教外溢效應、中小型工廠主影響 |
| 全台營建工地 | 營建業工班 | 利益導向,隨派系流動 | 工程發包權、包頭指令 |
### 脈絡層
**結構性命題**:產業工人的投票模式,是「地域歷史認同」與「當下經濟利益」的函數。當經濟利益受損時,地域歷史認同的約束力會迅速衰減。
南部工人偏綠,是因為過去國民黨的重北輕南政策與黨國控制工會的歷史,使得「反國民黨」成為一種代際傳承的集體記憶。但這種記憶是防禦性的,不是進攻性的。當 DPP 執政的政策(如空污管制導致工廠減產、能源轉型導致電價上漲)直接傷害工人利益時,他們不會轉向 KMT,但會選擇「不投票」或「轉投第三勢力」作為消極抵抗。
中部工人搖擺,是因為中部的產業結構多為中小企業,老闆與員工的關係更緊密,員工的投票行為容易受老闆的「暗示」或派系的「買票」影響。北部工人偏藍,則與外省族群在加工區的聚居,以及軍公教網絡的延伸有關。
#### 分析含義
分析時如何用這個事實?
- **修正「南綠北藍」的剛性預設**:在產業工人板塊中,「南綠」的硬度正在下降。2028 若 DPP 無法處理南部的產業轉型陣痛,南部工人的低投票率將成為 DPP 的隱患。
- **識別「韓流」的復興條件**:韓國瑜 2018 成功動員了中南部產業工人,因為他精準抓住了「又髒又窮」的相對剝奪感。若 2028 出現類似的民粹候選人,且 DPP 經濟表現不佳,中部搖擺工人與南部失望工人將再次成為民粹的燃料。
- 交叉引用:此群體與 `entities/parties/dpp_voter_base.md` 中的「基層本土派」高度重疊,但本檔案更強調其「勞動條件」而非「地方派系」的屬性;同時與 `entities/parties/kmt_voter_base.md` 中的「經濟利益群體」部分重疊,但本檔案聚焦於受僱者而非中小企業主。
---
## 兩岸與國際議題態度:現實主義優先的避戰邏輯
### 事實層
產業工人對兩岸議題的態度,呈現明顯的「經濟利益優先」與「避戰」傾向:
1. **對 ECFA 與兩岸經貿的依賴**:傳統製造業(石化、機械、紡織)的訂單高度依賴中國市場,ECFA 早收清單的關稅優惠直接關乎工廠利潤與工人年終獎金。北京取消優惠時,工人的第一反應是對政府無力護盤的憤怒,而非對北京的譴責。
2. **對戰爭風險的極度恐懼**:產業工人是戰爭的最直接受害者——工廠被炸、失業、物價飛漲。他們對「抗中保台」的支持,僅限於「防衛固守」的層次,一旦升高為「備戰」或「延長兵役」,會立刻觸發避戰情緒。
3. **對美方的疑慮**:部分工人(特別是營建業與傳統製造業)傾向將美國視為「挑起兩岸衝突以賣武器」的麻煩製造者,這種「疑美論」在工人群體中有一定市場,但並非基於親中,而是基於「不要破壞我現狀」的避險心理。
### 脈絡層
**破除常見簡化**:把產業工人對 ECFA 的依賴解讀為「親中」或「統派」是嚴重的分析錯誤。
他們不在乎九二共識的內涵,在乎的是關稅能不能免。他們不在乎兩岸誰贏,在乎的是工廠會不會關。當北京用經濟脅迫台灣時,工人的憤怒可能指向北京(因為斷人財路),也可能指向執政黨(因為無力保護產業),這取決於執政黨的論述能力與補救措施。若執政黨只訴諸「抗中」,而提不出受衝擊產業的輔導方案,工人會將經濟損失歸咎於執政黨的「意識形態治國」。
#### 分析含義
分析時如何用這個事實?
- **預測北京經濟脅迫的政治效應**:北京若在 2028 選前精準打擊中南部傳統產業(如取消特定產品關稅),將在產業工人中引發強烈的「求變」心理,這對在野黨有利。但若北京採取全面性軍事威懾,則會觸發工人的避戰恐懼,反而迫使他們向執政黨歸隊以求保護。
- **評估「疑美論」的穿透力**:在產業工人中散播「美國把台灣當棋子」的論述,若能連結到「軍購花掉納稅錢導致社福減少」或「美國逼台積電出走導致工作機會減少」,將具有高度殺傷力。
---
## 信息行為與認知特徵:疲勞身體裡的民粹易感性
### 事實層
產業工人的信息行為特徵為:
1. **渠道**:高度依賴 Line 群組(工廠部門群、工地工班群、宮廟群)、YouTube(新聞台、政論節目)、抖音/TikTok(短影音,特別是年輕技術員)。
2. **信任來源**:對主流媒體與政府官員信任度低,偏好「接地氣」的網紅、宮廟主委、工會幹部、包頭的口語傳播。
3. **內容偏好**:偏好直白、情緒性、二元對立的內容(如「政府圖利財團」、「官商勾結炒房」),對長篇大論的政策分析缺乏耐心。
### 脈絡層
**結構性命題**:高工時與體力勞動剝奪了產業工人深度信息處理的資源,使他們成為「認知捷徑」的高依賴者。
當一個人每天工作 10-12 小時,且多為重複性或高風險的體力勞動時,他下班後只剩精力接收娛樂性或情緒性的信息。這使得他們極易受「簡化敘事」與「陰謀論」影響。任何能將複雜的經濟困境歸因於單一明確對象(如「政府貪污」、「財團炒房」、「美國賣武器」)的論述,都能迅速佔據他們的認知空間。這不是因為他們愚昧,而是因為他們的勞動條件不允許他們進行耗能的批判性思考。
#### 分析含義
分析時如何用這個事實?
- **設計滲透策略**:針對產業工人的訊息,必須短影音化、口語化、情緒化。與其發布一篇 3000 字的產業政策,不如製作一支 30 秒的抖音影片,用一句標語點出「政府政策如何讓你少賺加班費」。
- **評估假訊息的破壞力**:在選前黃金週,針對工廠 Line 群組投放「某候選人當選將關廠」或「某政策將取消加班費」的假訊息,由於缺乏查證時間與意願,其殺傷力極大。
### 認知韌性:實務經驗構成的防線
儘管產業工人對簡化敘事易感,但他們也具備獨特的認知韌性:**對「幹話」的免疫力**。
由於長期處於勞動現場,他們對政策的實際效果有極強的直覺判斷。當政治人物承諾「改善勞動條件」或「提高薪資」時,工人會立刻在心中換算:「這會不會讓我老闆減少發單?」、「這會不會讓我的加班費變少?」。若政策承諾與他們的實務經驗衝突(如一例一休承諾週休二日,實際上卻導致排班更亂、收入減少),他們會迅速識破並產生強烈的反感。這種基於切身利益的「實用主義防線」,使得空泛的進步價值論述在此群體中經常踢到鐵板。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 事實層
1. **高齡化與斷層**:傳統製造業與營建業面臨嚴重的青年斷層,45 歲以下本勞比例極低,許多工廠與工班依賴 60 歲以上的高齡勞工撐場。
2. **移工化與邊緣化**:製造業基層體力工已幾乎全面移工化,本勞僅剩技術員與管理職。這導致本勞與移工的隔離加深,本勞的勞動條件雖優於移工,但向上流動的空間被壓縮。
3. **綠領工作的幻覺**:政府推動能源轉型,承諾創造「綠領工作」,但實際上離岸風電與太陽能板的施工多為臨時性工程,且技術多由外籍技術人員壟斷,對本地傳統工人的轉業幫助有限。
### 脈絡層
**中等模型自己想不到的洞察**:產業工人的高齡化,正在改變他們的政治訴求——從「追求更好的勞動條件」轉向「恐懼失去現有的飯碗」。
年輕工人(如果有的話)在乎的是加薪與升遷;高齡工人在乎的是「做到退休」與「領到勞保年金」。這使得他們對產業外移、關廠、減碳政策的恐懼,遠大於對勞動權益的進步追求。他們更容易被「顧飯碗」的保守論述動員,而非「爭權益」的進步論述動員。這也是為何 DPP 的進步勞動政策(如擴大勞保年改方案),在此群體中常被解讀為「要砍我退休金」的威脅。
#### 分析含義
分析時如何用這個事實?
- **調整勞工政策訴求**:針對產業工人的政策利多,不應強調「進步勞權」(如罷工權),而應強調「保障就業穩定」、「勞保不破產」、「補助職訓留任」。
- **預測世代替換的政治斷層**:當現有高齡產業工人逐漸退出勞動市場,他們的選票也將隨之消失。未來的「基層勞工」選民結構將更偏向服務業與科技廠基層,傳統製造業工人的政治影響力將持續衰退。
---
## 關聯分析
### 關聯一:與薪資停滯敘事的連結(提供事實層與脈絡層)
- `narratives/domestic/wage_stagnation_narrative.md`:產業工人是薪資停滯敘事的最核心受眾。理解此敘事如何將總體經濟數據轉化為「政府無能」的憤怒,是預測產業工人投票傾向的基礎。
### 關聯二:與移工論述的連結(提供脈絡層與對照)
- `narratives/domestic/migrant_workers_discourse.md`:移工論述中的「搶工作」與「拉低薪資」版本,在產業工人中具有高度市場。分析時需對照官方的「移工補充本勞」論述,理解兩種論述的衝突如何撕裂工人的政治認同。
### 關聯三:與產業政策論述的連結(提供脈絡層)
- `narratives/domestic/industrial_policy_discourse.md`:產業政策中的「升級轉型」與「淨零碳排」論述,在產業工人眼中常被解讀為「淘汰傳統產業」的威脅。分析時需評估政策論述與工人生存焦慮的落差。
### 關聯四:與 DPP 選民基礎的連結(用於交叉驗證)
- `entities/parties/dpp_voter_base.md`:產業工人中的南部製造業工人,是 DPP「基層本土派」的核心。驗證 DPP 在南部的選票鬆動程度,必須交叉比對產業工人的經濟焦慮指數。
### 關聯五:與 KMT 選民基礎的連結(用於對照)
- `entities/parties/kmt_voter_base.md`:產業工人中的北部與中部工人,是 KMT「經濟利益群體」的周邊支持者。對照 KMT 如何透過中小企業主網絡影響工人投票,與 DPP 透過工會與宮廟網絡的差異。
### 關聯六:與北京反應類型學的連結(提供分析框架)
- `primitives/frameworks/beijing_response_typology.md`:北京對台經濟脅迫(如取消 ECFA)如何差異性地影響製造業工人(直接衝擊)與營建業工人(間接衝擊),是評估兩岸政策選票效應的關鍵框架。
---
## 常見的分析陷阱
### 陷阱一:將「勞工」視為單一同質群體
這是最致命的錯誤。製造業與營建業的勞動型態、僱傭關係、資訊渠道完全不同。將科技廠工程師、傳產作業員與工地粗工混為一談,會導致政策效應評估完全失準。必須嚴格區分次群體。
### 陷阱二:假設產業工人必然支持「進步勞動政策」
產業工人(特別是高齡本勞)在勞動權益上是保守的。他們更在乎「有沒有班可加」而非「工時是否過長」,更在乎「勞保會不會倒」而非「罷工權是否擴大」。用都會自由派的視角預設工人的政策偏好,會嚴重誤判選情。
### 陷阱三:忽略「老闆」對工人投票行為的影響
在中小企業與營建業中,僱主對受僱者的投票行為具有不成比例的影響力。這不是簡單的「買票」,而是透過日常的權力關係、工作分配與「暗示」形成的壓力。忽略這層網絡,會低估 KMT 與地方派系在工業區的組織票。
### 陷阱四:將「依賴中國市場」等同於「支持統一」
產業工人依賴 ECFA 與中國訂單,純粹是基於生存利益。他們對北京的態度是矛盾且實用的:既恐懼北京的武力威脅,又依賴北京的市場紅利。將他們的利益訴求簡化為「親中賣台」,會完全誤判他們的國家認同底線。
### 陷阱五:過度解讀「韓流」的長期效應
韓國瑜 2018 年成功動員了產業工人,但這是特定時空下的民粹爆發,而非結構性重組。2020 年韓流退潮後,許多工人回歸不投票或傳統投票模式。假設「韓流」是產業工人的常態政治傾向,會高估民粹路線的持續力。
### 陷阱六:認為「抗中保台」對工人無效
雖然工人優先考慮經濟,但「抗中保台」並非完全無效。當北京的威脅具體化為「炸毀我的工廠」或「讓我失業」時,工人的避戰心理會轉化為對執政黨的支持。關鍵在於「威脅的感知方式」,而非論述本身。
---
## 仍不確定的部分
### AI 與自動化對就業的衝擊速度
目前仍不確定 AI 與自動化技術將在多大程度上、多快時間內取代傳統製造業與營建業工人。若衝擊加劇,將引發更大規模的生存焦慮,可能催生更極端的民粹政治訴求;若衝擊緩慢,則高齡化與自然退休將成為主要問題。
### 北京經濟脅迫的「精準度」與選民歸責
北京若在 2028 前對台實施精準的產業制裁(如針對特定縣市的特定產業),台灣產業工人會將經濟損失歸咎於北京(從而支持抗中),還是歸咎於執政黨(從而懲罰 DPP)?這取決於執政黨的危機論述能力與補貼政策,目前仍難以精確預測。
### 勞保年改的觸發時機與政治效應
勞保基金破產年限逼近,政府何時推動勞保年改,將是影響產業工人投票行為的超級變數。若在 2028 前啟動改革,即使不砍現有給付,也會引發高齡工人的強烈恐慌,可能重演軍公教年改的政治反噬;若持續拖延,則破產風險將成為在野黨攻擊的把柄。
### 營建業工人對「居住正義」的態度演變
營建業工人依賴房市景氣維生,但同時也是高房價的受害者(他們也買不起自己蓋的房子)。未來他們是否會因為自身居住困境,而支持打房政策?還是會因為短期工作利益,而反對打房?這種內在矛盾目前缺乏明確的民調數據支撐。
---
## 與其他文件的關係
- 薪資停滯敘事:`narratives/domestic/wage_stagnation_narrative.md`(理解產業工人經濟焦慮的論述載體)
- 移工論述:`narratives/domestic/migrant_workers_discourse.md`(理解本勞與移工競爭論述的傳播機制)
- 產業政策論述:`narratives/domestic/industrial_policy_discourse.md`(理解政府產業升級論述與工人生存焦慮的衝突)
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照南部產業工人與 DPP 基層本土派的重疊與摩擦)
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照北部產業工人與 KMT 經濟利益群體的連結)
---
## 一句話濃縮
> 產業工人是台灣政治經濟結構中被雙重擠壓的生存焦慮聯盟,他們用實用主義的選票租借給承諾顧腹肚的政客,卻隨時準備在利益受損時以不投票或民粹反噬作為懲罰;預測他們的行為,就是預測經濟生存壓力何時會壓倒地域認同與主權恐懼。
---
last_updated: 2024-10-27
@@ -1,339 +0,0 @@
# 外省/眷村族群受眾分析
---
**類型**: 受眾社會認同(敘事與結構特化檔案)
**當前角色**: 台灣政治中「中華民國法統敘事」的核心載體與深藍選民基礎的結構性錨點
**形成時間**: 1949 年(隨中華民國政府遷台)
**何時調用**: 預測 2028 及後續選舉中深藍與 KMT 基本盤的動員極限與棄保行為;分析兩岸議題中「文化中國認同」與「政治反共」的撕裂效應;評估「疑美論」與「戰爭恐懼」在特定族群中的傳播路徑;解讀候選人(特別是具外省背景者)的忠誠度考驗危機;規劃針對深藍族群的訊息滲透或反向認知作戰
---
## 一句話濃縮
> 外省/眷村族群是「中華民國法統的最後守墓人」與「文化中國的殘餘載體」,其政治本質是反共而非親中,但隨著世代交替與物理凋零,正從「血緣與地緣的鐵板一塊」解體為「認同與利益的離散聯盟」。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「外省族群」等同於「親中賣台」或「北京的天然盟友」。這種本質化的簡化不僅背離歷史事實,更會導致對兩岸局勢與台灣內部政治動態的嚴重誤判。
外省/眷村族群的政治心理核心,是一個被多數分析者忽略的內在矛盾:**他們是台灣社會中對「文化中國」情感最深厚的一群人,同時也是對「北京政權」敵意最根深蒂固的一群人。** 他們的「大中國認同」是依附在「中華民國」這個法統載體上的;北京消滅中華民國的企圖,恰恰是對他們存在意義的根本否定。將他們的「文化鄉愁」誤讀為「政治歸順」,是分析兩岸認同光譜時最致命的盲區。
理解外省/眷村族群,就是理解台灣政治中「中華民國認同」的極限與邊界。這個群體正在經歷不可逆的物理性凋零與社會性稀釋,但他們的集體焦慮與防禦性動員,仍將在 2028 及未來的總統大選中,作為 KMT 的防禦底線與深藍逼宮的壓力來源而存在。
---
## 群體定義與邊界:從血緣剛性到認同彈性的演變
### 事實層
外省族群指 1945-1950 年間隨中華民國政府遷台的軍公教人員及其家屬,以及後續少數個別來台者。目前廣義外省族群(含一、二、三代)人口估算約佔台灣總人口 13-15%(約 300-350 萬人)。
群體識別的關鍵維度經歷了歷史演變:
1. **籍貫制度時期(解嚴前)**:依父系血統認定,身份證籍貫欄登記為大陸省份(如山東、浙江)而非台灣縣市,構成剛性的法律與社會邊界。
2. **出生地原則時期(1992 年後)**:修法改依出生地登記,外省二代的籍貫開始出現「台灣化」,血緣邊界鬆動。
3. **當前識別標準**:已無客觀法律區分,主要依賴主觀認同、家庭記憶、語言使用(如流利國語、特定大陸家鄉方言)、以及成長環境(是否於眷村或外省聚落社會化)。
### 脈絡層
**結構性命題**:外省族群的邊界正從「客觀的人口學事實」轉變為「主觀的政治學選擇」。
隨著跨族群通婚率攀升(外省三代與本省通婚率已超過 50%),純粹的血緣外省人正在消失。當前維持此群體邊界的核心黏合劑,不再是 1949 年的流亡記憶,而是對「中華民國法統」的政治認同,以及對「台灣本土化/去中國化」的集體相對剝奪感。這意味著,一個血統上為本省閩客的人,若強烈認同中華民國與文化中國,可能在政治行為上比一個跨族群通婚、對政治冷漠的外省三代更符合「外省族群」的行為特徵。
### 分析含義
分析時如何用這個事實?
- **修正人口比例預測的誤差**:不能單純用 13-15% 的人口比例來推算深藍鐵票規模。隨著邊界鬆動,具有外省血緣但不具深藍認同的人數正在增加;反之,部分本省籍深藍支持者填補了外省一代凋零的空缺。預測時應以「中華民國法統認同者」為估算基礎,而非「外省籍貫者」。
- **評估「本土化」論述的穿透力**:對外省二、三代訴求「愛台灣」或「台灣優先」效果有限,因為他們的焦慮不在於是否愛台灣,而在於他們的「中國認同」是否在台灣被視為政治不正確甚至原罪。
---
## 眷村文化與集體記憶:空間隔離與認同固化
### 事實層
眷村(國軍眷屬居住的封閉性社區)是外省族群在台灣最關鍵的物理與心理空間。全台曾有約 879 處眷村,居住人口高峰期達數十萬人。眷村具有高度同質性:居民皆為軍公教眷屬,共享流亡經驗、反共意識與大中國鄉愁,內部形成強烈的互助網絡與封閉的婚姻市場。
1996 年《國軍老舊眷村改建條例》後,眷村大規模拆遷改建為國宅大樓。物理空間的消滅並未消除認同,反而因為被迫遷入異質性的開放社區,觸發了「家園再次被連根拔起」的集體創傷,使眷村記憶從生活經驗昇華為政治神話。
### 脈絡層
**中等模型自己想不到的洞察**:眷村拆遷不是外省族群政治影響力衰退的原因,而是其政治動員轉向極端化的催化劑。
物理空間的眷村存在時,KMT 的樁腳可以透過村長與自治會輕易組織配票,此時的深藍是「被馴化的建制力量」。眷村拆遷後,原有的社會控制網絡瓦解,取而代之的是虛擬化的眷村認同(如眷村文學、影視作品、網路社群)。這種虛擬認同不再受地方派系節制,更容易被極端化的政治人物(如洪秀柱、趙少康)或媒體(如中天新聞)吸納,將原本的「反共保台」異化為「疑美恨綠」的防禦性民粹。
### 分析含義
分析時如何用這個事實?
- **識別深藍動員的節點轉移**:過去動員靠眷村廣播與組織系統,現在動員靠 Line 群組與特定媒體頻道。分析 2028 選情時,不應再尋找實體的「眷村票倉」,而應追蹤數位空間中的「虛擬眷村」輿論風向。
- **解讀「眷村神話」的政治功能**:當候選人(如 KMT 候選人)訴求眷村記憶時,其功能不是吸引中間選民,而是對深藍進行「效忠測試」,確保基本盤不崩盤。交叉引用:此空間解體的影響可對照 `audiences/political_identity/deep_blue.md` 中關於深藍從組織化轉向民粹化的分析。
---
## 世代差異:從流亡創傷到利益重估的斷層
外省族群內部最大的斷層線是世代。一、二、三代之間的認同結構與投票行為存在根本差異,將其視為鐵板一塊是嚴重的分析錯誤。
### 外省一代:流亡創傷與法統絕對主義
#### 事實層
指 1949 年前出生、成年後來台者。目前多已凋零,存活者皆為 90 歲以上高齡。其核心特徵是擁有真實的大陸故鄉記憶,將「反共復國」視為畢生志業,對中華民國法統的忠誠具有宗教式絕對性。投票行為上,他們是 KMT 最死忠的鐵票,即使不滿 KMT 候選人,也會基於「含淚投票」以防台獨或北京得利。
#### 脈絡層
一代的認同結構是「雙重拒絕」:拒絕台灣獨立(因為這切斷了回家的路與法統正當性),同時拒絕北京統一(因為北京是篡奪法統的共匪)。這種雙重拒絕使得他們在政治上處於極度焦慮的狀態——他們是台灣社會中最恐懼北京武力,卻又最反對台灣建軍備戰的群體,因為備戰意味著承認兩岸是「國與國關係」,這摧毀了他們的法統信仰。
### 外省二代:法統捍衛者與體制受益者
#### 事實層
指 1949-1970 年代在台出生者。目前年齡約 50-75 歲,是當前外省族群的政治主力與意見領袖(代表人物如馬英九、趙少康、洪秀柱、韓國瑜)。他們沒有大陸的實體記憶,但透過家庭與眷村教育,內化了強烈的中國認同與反共意識。他們多受益於中華民國體制(軍公教、黨國體系),經歷過台灣經濟起飛,對「台灣奇蹟」與「中華民國」的連結有深刻信仰。
#### 脈絡層
**破除常見簡化**:把外省二代視為「不知變通的統派」是錯的。外省二代是「中華民國現狀的維持者」。
他們的悲劇在於,他們試圖維持的「現狀」(一個中國就是中華民國、兩岸是爭奪中國正統的內戰延續)在國際現實與台灣內部都已破產。他們對北京的態度極度矛盾:一方面,他們渴望文化與血緣的連結,甚至幻想「三民主義統一中國」的虛幻勝利;另一方面,北京對中華民國的打壓,直接構成對他們人生信仰的侮辱。馬英九的「不統不獨不武」與「親美反共」路線,正是外省二代試圖在理想與現實間勉強縫合的產物。
#### 分析含義
分析時如何用這個事實?
- **解讀 KMT 路線之爭的本質**:KMT 內部的兩岸路線衝突,本質上是外省二代的「法統信仰」與本省地方派系的「利益現實」之間的衝突。當候選人試圖向中間靠攏(如接受「中華民國台灣」論述)時,外省二代會發動猛烈的忠誠度審查(如 2015 年「換柱」風暴的深藍反撲,以及 2024 年對侯友宜的疑慮)。
- 交叉引用:馬英九作為外省二代的典型縮影,其決策邏輯詳見 `entities/people/ma_ying_jeou.md`。
### 外省三代:認同稀釋與利益重估
#### 事實層
指 1980 年代後出生者。目前年齡 20-40 餘歲。他們的成長經驗已完全台灣化,多數不會講家鄉方言,缺乏眷村生活經驗,且跨族群通婚普遍。他們對「大中國」缺乏鄉愁,對「中華民國法統」缺乏信仰。其政治傾向出現顯著分化:
1. **淺藍化/中間化**:繼承家庭的藍營認同,但強度大幅減弱,投票行為更具彈性,可能因經濟利益或候選人形象轉向 TPP 或甚至 DPP。
2. **深藍繼承**:少數因家庭高度政治化或沉浸於深藍網路同溫層,延續強烈的反綠與疑美立場,但論述基礎已從「法統」轉向「反戰/反民進黨威權」。
#### 脈絡層
**結構性命題**:外省三代的「去法統化」,意味著外省族群作為 KMT 鐵票的物理基礎正在瓦解。
外省三代不再背負「法統守墓人」的歷史包袱。他們面臨的生存焦慮(高房價、低薪)與台灣其他年輕人無異,這使得他們更容易被 TPP 的「反建制」與「務實」論述吸引。當他們的投票行為不再被「保衛中華民國法統」所驅動時,外省族群的鐵票屬性便宣告終結。
#### 分析含義
分析時如何用這個事實?
- **預測深藍鐵票的萎縮速率**:每四年一次的大選,外省一代的自然減員加上外省三代的流失,將使 KMT 的深藍鐵票基礎以約 1-2% 的速度持續萎縮。2028 年,KMT 若仍依賴深藍動員,將面臨數學上的必敗。
- **識別 TPP 對藍營年輕票的吸納**:外省三代是 TPP 的重要潛在支持者。他們對 DPP 的反感來自家庭社會化,但對 KMT 的老態與法統包袱同樣缺乏共鳴。TPP 提供了「不綠但不傳統藍」的出口。
---
## 政治傾向與投票模式:鐵票生鏽的機制
### 事實層
外省族群長期以來是 KMT 最穩定的鐵票倉。在總統大選中,外省聚居選區(如台北市大安區、新北市永和區、高雄市左營區)的 KMT 得票率通常顯著高於全國平均 15-20 個百分點以上。
然而,鐵票生鏽的跡象已十分明顯:
- **懲罰性不投票**:當 KMT 候選人未能通過深藍的忠誠度測試時,外省選民傾向不投票而非轉投綠營。2016 年朱立倫在深藍選區的得票率大幅下滑,即為此效應。
- **分裂投票**:在市議員等複數選區,外省選民傾向將選票投給新黨、親民黨或無黨籍深藍候選人,以懲罰 KMT 的「不忠」。
- **向 TPP 流動**:2024 年大選,柯文哲在部分外省三代聚居的都會區取得顯著成長,顯示鐵票的代際鬆動。
### 脈絡層
**破除常見簡化**:外省族群的「鐵票」不是無條件的忠誠,而是「缺乏替代選項的囚徒困境」。
他們不可能投 DPP(因為 DPP 代表台獨與去中國化),也不願投北京支持的第三勢力(因為那等同於向共產黨投降)。因此,即使 KMT 表現再差,他們也只能含淚支持。但 TPP 的崛起打破了這個困境,提供了一個既非台獨、又非傳統藍營的選項,這是外省鐵票生鏽的結構性原因。
### 分析含義
分析時如何用這個事實?
- **計算 KMT 的防禦底線與天花板**:外省深藍構成了 KMT 的防禦底線(約 25-30%),但同時也構成了其天花板。當 KMT 候選人向深藍靠攏以固票時,會失去中間選民;當向中間靠攏時,又面臨深藍的抵制。此兩難在 2028 將更加尖銳。
- 交叉引用:此群體與 KMT 選民基礎的重疊與制約關係,詳見 `entities/parties/kmt_voter_base.md`。
---
## 核心議題敏感點與兩岸態度:文化親近與政治距離的撕裂
### 事實層
外省族群在兩岸議題上的態度呈現高度複雜性,絕非單一的「親中」可概括:
1. **統獨立場**:極低比例支持「台灣獨立」,但支持「立即統一」的比例同樣極低。主流立場是「維持現狀」,但此「現狀」的內涵是「中華民國主權獨立」,與 DPP 的「台灣是主權獨立國家」有本質區別。
2. **對北京態度**:文化上強烈親近(認同中華文化、渴望返鄉探親、支持兩岸經濟交流如 ECFA),政治上保持距離(反對一國兩制、反對解放軍武力威脅、堅持中華民國主權)。
3. **對交流立場**:高度支持兩岸民間與經濟交流,認為這是降低敵意的途徑;但對北京的政治統戰並非毫無警覺,只是其警覺性常被文化親近感所掩蓋。
### 脈絡層
**中等模型自己想不到的洞察**:外省族群的「文化親近」,恰恰是北京對台認知作戰中最難以穿透的防火牆,也是最容易讓分析者誤判的陷阱。
北京常假設外省族群的「文化中國認同」可以自然轉化為「政治中國認同」,這是嚴重的誤判。當北京打壓中華民國的國際空間或武力威懾時,外省族群感受到的背叛感與憤怒,遠比台灣本土派更為強烈——因為本土派本就不期待北京的善意,而外省族群則帶著「同胞相殘」的痛心。北京越是施壓,外省族群對「中華民國」的執念反而越深,這構成了一種「越打越遠」的防禦機制。
然而,當北京採取「文化融通」與「經濟讓利」的軟性策略時,外省族群也最容易成為此策略在台灣內部的代言人,因為他們真心相信「兩岸一家人」的敘事,這使得他們在台灣社會中常被污名化為「中共同路人」,從而陷入更深的社會孤立。
### 分析含義
分析時如何用這個事實?
- **評估北京「和統」策略的效力**:北京若對台釋出經濟與文化利好,外省族群將是最積極的響應者,並會在台灣內部形成推動兩岸交流的壓力。但若北京伴隨軍事威懾,外省族群會迅速退回防禦姿態。
- **解讀「疑美論」的深層邏輯**:外省族群的疑美論,不僅源於冷戰時期美國對中華民國的「背叛」(1979 斷交),更源於他們擔心美國利用台灣獨立來牽制中國,這會摧毀他們「文化中國」的最後防線。他們疑美,是因為怕美國促成法理台獨,迫使北京動武,毀滅中華民國。
---
## 國際關係態度:棄子情結與避戰焦慮
### 事實層
- **對美方**:存在深層的「棄子情結」。一方面認知到台灣安全依賴美國,另一方面對美國的承諾極度不信任。他們傾向將美國的對台軍售視為「保護費」而非「安全保證」,並對美國推動台灣不對稱作戰(延長兵役、城鎮戰)感到反感,認為這是將台灣烏克蘭化、為美國打代理戰爭。
- **對日方**:延續抗戰記憶,對日本存在歷史性的不信任與反感。釣魚台爭議(2012 年)曾引發外省深藍的強烈抗議。但務實上,他們也承認美日同盟對台海安全的重要性。
- **對國際**:對國際社會的「口頭支持、實質孤立」感到麻木與悲觀,這強化了他們「台灣只能依靠兩岸自己解決」的認知。
### 脈絡層
外省族群的國際觀,本質上是「大中國中心主義」與「小國悲情」的混合體。他們在心理上無法接受台灣是國際體系的棋子(因為中華民國曾是創始成員與五強),但在現實中又無力改變台灣被邊緣化的處境。這種落差催生了強烈的「避戰焦慮」——他們比任何群體都恐懼戰爭,因為戰爭意味著他們第二次失去家園,且這次將無處可去。
### 分析含義
分析時如何用這個事實?
- **預測「抗中保台」論述的邊際效應遞減**:對外省族群訴求「抗中」,無法動員他們,只會觸發他們對戰爭的恐懼與對美國的不信任。他們更傾向接受「備戰才能避戰」的 KMT 論述,而非 DPP 的「全民防衛」路線。
- 交叉引用:此群體的認同焦慮與台灣主體性敘事的衝突,詳見 `narratives/cross_strait/taiwanese_identity_narrative.md`。
---
## 信息行為與認知特徵:封閉生態系與防禦性吸收
### 事實層
- **主要接觸渠道**:電視(TVBS、中天新聞台轉戰網路後的 YouTube 頻道)、廣播(飛碟電台等)、報紙(聯合報)、數位社群(Line 群組、臉書深藍同溫層)。
- **信任來源**:高度依賴同溫層的訊息轉發,對綠營媒體(三立、民視)與主流國際媒體(如 CNN、NYT)持本能性排斥,認為其帶有反中或挺獨偏見。
- **內容偏好**:兩岸關係分析、中華民國歷史翻案、批判民進黨執政缺失、美國對台灣的陰謀論。
### 脈絡層
**結構性命題**:外省族群的信息生態是一個高度封閉的「迴聲室」,但其封閉性不是因為缺乏資訊取得能力,而是因為「認知防禦機制」。
外省二代普遍教育程度高,具備解讀國際資訊的能力,但他們選擇性地吸收符合其法統信仰的訊息。當外部資訊(如國際社會普遍不支持中華民國法統、北京對台的武力威脅)與其信仰衝突時,他們會啟動防禦機制,將其歸因為「國際陰謀」或「綠營操控」。這使得他們極易接受「疑美論」與「和統論」的敘事,因為這些敘事提供了心理安慰。
### 認知脆弱點
1. **疑美論**:極易接受美國將棄台、美國軍火商圖利等敘事,因為這符合其歷史創傷(1979 斷交)。
2. **戰爭恐懼**:極易被「票投民進黨,青年上戰場」等恐懼訴求動員。
3. **法統悲情**:極易被「中華民國被消滅」的危機感動員,無論威脅是來自台獨或北京。
### 認知韌性
1. **反共教育底蘊**:外省一代與二代的反共意識根深蒂固,使得北京直接的政治宣傳(如一國兩制、武統論)在此群體中遭遇極大阻力。他們可以接受「兩岸一家親」,但無法接受「共產黨統治台灣」。
2. **資訊解讀能力**:外省二代的高教育程度,使他們對過於粗糙的假訊息仍有一定辨識力,除非該假訊息精準迎合其法統焦慮。
### 分析含義
分析時如何用這個事實?
- **設計認知作戰的切入點**:若要對外省族群進行認知滲透,最有效的途徑不是直接推銷北京立場,而是放大其「疑美」與「避戰」焦慮,將其對 DPP 的反感轉化為對台灣整體防衛體系的冷漠或不合作。
- **評估深藍輿論的失控風險**:當 KMT 領導層試圖向中間路線調整時,深藍的 Line 群組與網路頻道會成為黨內叛變的溫床,其殺傷力遠大於傳統的黨員投票。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 事實層
1. **物理凋零加速**:外省一代幾已退出選民結構,外省二代步入退休與老年,深藍選民的死亡率已高於新增率。
2. **眷村記憶的虛擬化與商品化**:實體眷村消失後,眷村文化被轉化為影視作品與文創消費,其政治反抗意味降低,懷舊消費意味升高。
3. **跨族群通婚稀釋**:外省三代的血統純粹性不再,家庭內部的政治討論不再由單一法統聲音主導。
4. **深藍論述的極端化與邊緣化**:面對台灣主流民意的本土化趨勢,深藍論述(如恢復國統綱領、堅持九二共識一中原則)在台灣內部已失去市場,被迫轉向防禦性的民粹(如強烈反綠、疑美),甚至與北京的部分和統論述產生危險的共振。
### 脈絡層
外省族群正在經歷一場「從主流到邊緣」的心理失落。在威權時期,他們是國家的主流;在民主化後,他們成為多數中的少數。這種失落感使得他們對政治的態度從「自信的統治者」轉變為「焦慮的守衛者」。當 KMT 無法再為他們提供安全感時,部分人會選擇退回封閉社群,部分人會被更激進的政治人物吸引,這解釋了為何韓國瑜現象能在深藍群體中引爆——韓提供了情緒出口,而非政策解方。
### 分析含義
分析時如何用這個事實?
- **預測 2028 KMT 的候選人策略**:KMT 若提名傳統外省精英(如趙少康類型),將無法吸引中間選民;若提名本省本土派,又面臨深藍抵制。KMT 必須找到能同時安撫深藍焦慮又不嚇跑中間選民的候選人,這幾乎是不可能的任務。
- 交叉引用:二二八事件作為外省族群「原罪」標籤的歷史根源,如何持續影響其防禦心理,詳見 `narratives/cross_strait/2_28_legacy.md`。
---
## 關聯分析
### 關聯一:與深藍政治認同的連結(提供脈絡層)
- `audiences/political_identity/deep_blue.md`:外省族群是深藍認同的核心載體,但深藍不等於外省。本省籍的軍公教與部分客家選民也可能具備深藍特徵。此文件提供區分「血緣外省」與「意識形態深藍」的分析框架。
### 關聯二:與 KMT 選民基礎的連結(提供事實層)
- `entities/parties/kmt_voter_base.md`:外省族群構成了 KMT 選民基礎中的「理念深藍」板塊。理解外省族群的凋零,就是理解 KMT 基本盤萎縮的物理原因。KMT 的路線搖擺,本質上是外省深藍與本省地方派系的利益博弈。
### 關聯三:與二二八歷史敘事的連結(提供對照)
- `narratives/cross_strait/2_28_legacy.md`:二二八敘事是外省族群集體心理的「原罪烙印」。外省族群對二二八的防禦性反應(強調本省暴徒、省籍衝突的複雜性),與本土派的正義訴求形成尖銳對立。理解此對立,是解讀外省族群為何對 DPP 的轉型正義極度反感的关键。
### 關聯四:與台灣認同敘事的連結(用於交叉驗證)
- `narratives/cross_strait/taiwanese_identity_narrative.md`:台灣認同的崛起,直接擠壓了中華民國法統認同的生存空間。外省族群對「台灣認同」的抗拒,不僅是政治立場之爭,更是存在意義的保衛戰。驗證外省三代是否已轉向台灣認同,是預測其投票行為的關鍵。
### 關聯五:與馬英九個人檔案的連結(提供具體案例)
- `entities/people/ma_ying_jeou.md`:馬英九是外省二代政治心理的完美縮影——對中華民國法統的絕對忠誠、對文化中國的深厚情感、對北京政權的防禦性距離、以及對美國的依賴與不信任。分析馬英九的決策邏輯,等同於解讀外省二代精英的集體潛意識。
### 關聯六:與北京反應類型學的連結(提供脈絡層)
- `primitives/frameworks/beijing_response_typology.md`:北京對台的不同策略(軍事威懾 vs. 經濟讓利),如何在外省族群內部產生撕裂效應。軍事威懾會喚醒其反共防禦,經濟讓利則會激活其文化親近,此矛盾是北京對台統戰最難處理的盲區。
---
## 常見的分析陷阱
### 陷阱一:將「外省族群」等同於「親中賣台」
這是最嚴重且最常見的錯誤。外省族群的政治本質是「反共保台」,只是他們保的「台」是中華民國體制下的台灣。他們對文化中國的親近,絕不等同於對北京政權的政治歸順。將他們推向北京懷抱的,往往是台灣內部的台獨壓力與省籍歧視,而非他們本來的立場。
### 陷阱二:假設外省鐵票永遠不會跑
外省一代的鐵票確實不會跑,但他們正在凋零。外省二、三代的投票行為已具備高度彈性。當 KMT 候選人無法回應其避戰焦慮,或 TPP 提供更具吸引力的反建制選項時,外省三代的選票會迅速流失。用 20 年前的鐵票思維預測 2028,會嚴重高估 KMT 的基本盤。
### 陷阱三:忽略外省族群內部的階級分化
外省族群並非全是軍公教精英。早期許多下級士兵與眷戶生活清苦,改建後的國宅也多為中低收入戶居住。這些底層外省選民的利益焦慮(如經濟困境、社會福利)與其法統認同同樣強烈,當 KMT 無法照顧其利益時,他們同樣可能被民粹(如韓國瑜)或第三勢力吸納。
### 陷阱四:將「疑美論」視為外省族群的專利
疑美論在外省族群中確實有深厚基礎,但近年已隨著美國對台軍售延遲、要求台灣增加國防預算等事件,擴散至本省中間選民與年輕世代。將疑美論僅歸因於外省族群的歷史創傷,會忽略其當前在台灣社會的結構性擴張。
### 陷阱五:認為「中華民國論述」可以輕易轉化為「台灣論述」
對外省族群而言,「中華民國」與「台灣」的內涵有本質區別。DPP 的「中華民國台灣」論述,在外省族群眼中是對法統的閹割與竄奪。試圖用「中華民國台灣」來安撫深藍,不僅無效,反而會被視為另一種形式的台獨,觸發更強烈的防禦反應。
### 陷阱六:混淆「反台獨」與「支持統一」
外省族群強烈反對台獨,但這不代表他們支持在當下與北京統一。他們反對的是改變現狀(法統現狀),而非拒絕未來的統一可能性。在當前北京體制下,多數外省族群寧可維持中華民國的實質獨立,也不願接受一國兩制。將反台獨直接連結為支持統一,是邏輯上的跳躍。
---
## 仍不確定的部分
### 外省三代的最終政治歸屬
外省三代目前正處於政治社會化的關鍵期。他們會延續家庭的深藍認同,還是徹底融入台灣主體意識?或者成為 TPP 等第三勢力的穩定支持者?這取決於未來幾年兩岸局勢的發展、台灣內部的經濟狀況,以及 KMT 能否提出吸引年輕人的論述。目前缺乏足夠的長期追蹤數據來確認其最終走向。
### 北京「文化統戰」對外省二代的滲透極限
北京透過祭祖、文化交流、經濟讓利等方式對外省二代進行統戰,其效果在馬英九時期顯著,但在習近平上台後因軍事威懾增加而受挫。未來北京若再次轉向軟性攻勢,外省二代(特別是已退休、有強烈鄉愁者)是否會在「避戰」與「文化親近」的雙重驅動下,成為台灣內部推動兩岸政治談判的壓力團體?其防禦底線在哪裡?目前仍難以精確評估。
### 眷村記憶商品化後的政治效應
當眷村記憶從政治反抗的載體轉變為文創消費的商品,它是否還能維持其動員深藍的功能?或者,這種商品化反而會稀釋其政治濃度,使外省認同成為一種無害的文化標籤,從而加速外省族群在政治上的消融?此過程的長期效應仍不確定。
### 「疑美論」與「避戰焦慮」的交叉臨界點
當美國要求台灣承擔更多防衛責任(如大幅增加國防預算、延長義務役),而北京同時加大軍事壓力時,外省族群的「疑美」與「避戰」情緒是否會合流為強大的反建制力量,甚至迫使 KMT 在 2028 採取更極端的反美路線?此臨界點的觸發條件目前無法量化。
---
## 與其他文件的關係
- 深藍政治認同:`audiences/political_identity/deep_blue.md`(對照血緣外省與意識形態深藍的重疊與差異)
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照外省族群作為 KMT 理念深藍板塊的物理萎縮效應)
- 二二八歷史敘事:`narratives/cross_strait/2_28_legacy.md`(對照外省族群的防禦性創傷與原罪標籤)
- 台灣認同敘事:`narratives/cross_strait/taiwanese_identity_narrative.md`(對照中華民國法統認同與台灣主體認同的擠壓與替代關係)
- 馬英九個人檔案:`entities/people/ma_ying_jeou.md`(作為外省二代政治心理與決策邏輯的具體案例對照)
---
## 一句話濃縮
> 外省/眷村族群是「文化中國的孤兒」與「中華民國的守墓人」,他們對北京的態度是鄉愁與恐共的撕裂,對台灣的態度是防禦與失落的交織;預測此群體的政治影響力,就是預測一個正在物理凋零的歷史共同體,其殘餘的防禦性焦慮能在多大程度上繼續綁架台灣的兩岸選擇。
---
last_updated: 2024-11-05
@@ -1,323 +0,0 @@
# 軍公教族群受眾分析
---
**類型**: 受眾社會認同(政治敘事與行為特化檔案)
**當前角色**: 台灣軍公教群體的內部結構、認同演變與政治行為預測基礎
**出生時間**: 不適用(群體橫跨 20-90 歲,核心投票群體集中於 50 歲以上)
**何時調用**: 預測 2028 及後續全國性選舉中軍公教票盤的流向;分析年金改革後的餘震與動員潛力;評估兩岸危機對「國家體制依附者」的差異性心理衝擊;解讀 KMT 基本盤的鬆動與重組;規劃針對退休軍公教的敘事滲透或避險策略;分析「疑美論」與「避戰論」在保守群體中的傳播機制
---
## 一句話濃縮
> 軍公教不是鐵板一塊的「深藍鐵票」,而是「國家體制依附者」與「中華民國認同守門人」的複合體;年金改革撕裂了他們與國家的心理契約,使其從「最忠誠的執政基礎」異化為「最憤怒的被剝奪者」,但他們對體制穩定與兩岸和平的結構性渴望,又使其無法徹底拋棄既有政治框架,陷入「既怨恨國民黨無能護產,又恐懼民進黨毀棄國家」的認同囚徒困境。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「軍公教」視為一個同質性的深藍鐵票區——預設他們都無條件支持國民黨、都抱持大中國認同、都反對台灣主體性。這種簡化在預測時會導致嚴重失準,也無法解釋軍公教群體在近兩次大選中的微妙流動。
軍公教的群體本質,是三個異質性極高、且內在邏輯相互衝突的次群體的「體制利益共同體」:
1. **退役軍人**:恐懼中華民國法統滅絕與個人尊嚴被抹殺,高度依賴組織網絡與意識形態動員。
2. **公務員體系**:恐懼行政中立被政治干預與法制契約被破壞,追求體制穩定與可預期的利益保障。
3. **教育人員**:內部極度分化,恐懼意識形態介入專業自主,但在國家認同上已發生嚴重的代際與地域斷裂。
這三群人在「反對民進黨執政」時高度重合(特別是在年金改革議題上),但在「國民黨的路線調整」與「兩岸終局想像」上,其內在衝突會迅速表面化。理解軍公教的受眾結構,就是理解台灣「國家機器代理人」在面對政黨輪替與國家認同重構時的底層焦慮。
---
## 群體定義與邊界:人口規模與識別標準
### 事實層
軍公教群體的直接人口規模約在 80-100 萬之間(包含現役軍人約 15 萬、退役榮民約 40 萬、公務員約 36 萬、公立學校教師約 20 萬),若加上其家屬(傳統上家屬的政治傾向高度受主要收入者影響),總影響人口可達 200-300 萬,約佔台灣選民的 10-15%。
此群體的識別標準不僅是「職業別」,更是「與國家機器的契約關係」。其核心特徵是:生涯的主要經濟來源(在職薪資或退休俸)直接依賴國家預算,且其職業社會化過程中嵌入了對「國家體制」的效忠要求(如軍人的宣誓、公務員的行政中立、教師的師道尊嚴)。
### 脈絡層
**結構性命題**:軍公教的政治行為,本質上是「契約保衛戰」。
他們對政治的態度,取決於哪個政黨被認為更可能「履約」。在威權時期,國民黨透過恩庇侍從體系確保了契約的兌現(包含優渥的 18% 優惠存款利率與退休給付),換取了絕對的政治忠誠。民主化後,此契約面臨兩個結構性挑戰:一是國家財政無法負擔過去的承諾(年金改革的客觀基礎),二是政黨輪替使得「國家」的定義與認同發生變化(民進黨的本土化重構)。當民進黨推動年金改革時,軍公教感知到的不是財政調整,而是「國家違約」與「政黨迫害」。
### 分析含義
分析時如何用這個事實?
- **評估「契約破壞」的長期動員力**:年金改革雖已法制化,但「違約感」不會隨時間消散,而是轉化為深層的政治不信任。任何試圖動員軍公教的策略,必須建立在「恢復契約尊嚴」的敘事上,而非僅僅是政策微調。
- **計算家屬票的溢出效應**:軍公教家屬的投票行為往往比本人更具情緒性(因為感受到家庭經濟的直接剝奪)。在票倉分析時,應將軍公教票的影響力乘以 1.5-2 倍的家屬擴散係數。
---
## 內部斷層線一:軍 vs 公 vs 教——三種體制依附的異質性
### 事實層
| 次群體 | 人口特徵 | 認同底色 | 年改衝擊感知 | 組織網絡 |
|---|---|---|---|---|
| 退役軍人(軍) | 外省比例高,高齡化嚴重,集中於眷村與特定社區 | 中國認同與大中華法統,反台獨 | 最強烈(18% 優存與退休俸被砍,視為尊嚴踐踏) | 黃復興黨部(雖解散但網絡存續)、退伍軍人協會 |
| 公務員(公) | 本省籍為主,年齡分布較廣,散居各地 | 實用主義,傾向維持現狀,對法制穩定敏感 | 中等(退休所得替代率下降,但多數仍有基本保障) | 公務員協會(組織鬆散,動員力弱) |
| 教師(教) | 本省籍絕對多數,性別比例均衡,城鄉差異大 | 嚴重分化:中小學偏本土,大學/高中偏自由或深藍 | 中等偏強(關心在職權益與退撫基金破產危機) | 全國教師工會總聯合會(偏綠)/ 教師會(偏藍或中立) |
### 脈絡層
**破除常見簡化**:把軍公教視為「深藍鐵票」是嚴重的分析錯誤。軍公教內部的認同斷裂,比軍公教與一般選民的差異還要大。
退役軍人是唯一的「理念型深藍」群體,其政治社會化在威權時期的反共愛國教育下完成,對中華民國的認同帶有信仰性質。公務員是「利益型保守」群體,他們支持國民黨是因為國民黨代表法制與秩序的穩定,而非出於統一信仰。教師則是「專業型搖擺」群體,特別是 1990 年代後師資培育多元化與本土教育課綱的推行,使得新一代教師的國家認同已與社會主流無異,甚至因為支持工會權與轉型正義而傾向民進黨。
#### 分析含義
分析時如何用這個事實?
- **精準打擊的受眾區隔**:對退役軍人訴求「中華民國法統保衛戰」與「反去中國化」有效;對公務員訴求「法制穩定」與「行政中立」有效;對教師訴求「專業自主」與「退撫基金永續」有效。混用這些敘事會導致跨群體的排斥。
- 交叉引用:退役軍人的行為模式與 `audiences/political_identity/deep_blue.md` 高度重疊,而年輕公務員與教師則更接近 `audiences/political_identity/light_blue.md` 甚至中間選民。
---
## 內部斷層線二:退休 vs 在職——憤怒的固化與恐懼的沉默
### 事實層
退休軍公教與在職軍公教在政治態度與行為上呈現顯著差異。退休群體是年金改革的直接受害者,其退休所得被大幅削減,且無法透過在職加薪來彌補。他們擁有充足的時間參與政治活動(如遊行、抗議、監督投票),且其憤怒具有高度的同儕聚集性(透過退除役官兵輔導委員會體系與同袍網絡強化)。
在職群體雖然也面臨退休給付縮水的預期,但他們更關心在職期間的升遷、考績與工作環境。在民進黨執政下,高階文官與將領的任命必須考量政治忠誠(如「忠誠查核」的爭議),這使得在職人員在公開場合傾向保持沉默,避免被貼上「不配合政府」的標籤。
### 脈絡層
**中等模型自己想不到的洞察**:在職軍公教是「沉默的搖擺選民」,他們對民進黨的改革有怨,但對國民黨的護盤能力更感絕望。
傳統分析假設在職軍公教會因為未來的退休利益受損而全面倒向國民黨。但實際上,在職人員處於「雙重恐懼」之中:恐懼民進黨繼續砍年金,但也恐懼國民黨為了選票做出無法兌現的承諾,導致退撫基金破產。更重要的是,在職人員必須在民進黨政府手下生存,公開支持國民黨可能帶來行政上的麻煩。因此,他們的反抗往往是消極的(不投票或投廢票),而非積極的(捐款、動員、公開站台)。
#### 分析含義
分析時如何用這個事實?
- **修正「年改憤怒 = 國民黨選票」的等式**:年改憤怒轉化為國民黨選票的轉換率,在退休群體中可能高達 80%,但在在職群體中可能低於 50%。在職群體中有相當比例會選擇第三勢力(如民眾黨)或不投票,作為對兩大黨的雙重懲罰。
- **評估「行政中立」敘事的穿透力**:民進黨若強調「保障在職軍公教權益」與「行政中立」,能有效安撫在職群體的恐懼,削弱國民黨的動員力。
---
## 核心政治認同與投票模式:從「鐵票」到「囚徒」
### 事實層
軍公教曾是國民黨最穩固的鐵票部隊。在 2008 年之前,軍公教社區的投票率極高(常超過 80%),且國民黨得票率常達 80% 以上。然而,2016 年民進黨全面執政並推動年金改革後,此結構發生質變:
1. **2016-2018**:年改抗議高峰期,軍公教對國民黨的期待最高,但國民黨在立法院未能有效阻擋年改法案,導致深層失望。2018 年地方選舉,軍公教基於「教訓民進黨」的動員高度歸隊,韓國瑜成功吸納了這股憤怒。
2. **2020**:香港反送中事件觸發了軍公教群體內部的「避戰」與「反共」矛盾。退役軍人基於反共立場多數仍支持國民黨,但部分公務員與教師因恐懼戰爭而選擇支持蔡英文的「維持現狀」。
3. **2024**:侯友宜作為本土形象較強的候選人,未能完全激發深藍退役軍人的熱情(其對侯的「疏離感」與對韓的「狂熱」形成對比),加上柯文哲吸納了部分對國民黨失望的在職軍公教,導致藍營在傳統鐵票區的得票率未達預期。
### 脈絡層
**破除常見簡化**:年改並未讓軍公教「更愛國民黨」,而是讓他們「更恨民進黨」,但恨意不等於忠誠。
年金改革後,軍公教與國民黨的關係從「信仰式綁定」轉變為「交易式結盟」。他們支持國民黨,是因為國民黨是唯一承諾修復年改的政黨,但他們對國民黨在野時的戰鬥力深感懷疑。這種「恨民進黨但疑國民黨」的心理,使得軍公教選票從「鐵票」退化為「情緒票」——當國民黨候選人無法點燃情緒(如 2024 的侯友宜),投票率就會下降;當出現更具反抗形象的第三勢力(如 2024 的柯文哲),選票就會流失。
#### 分析含義
分析時如何用這個事實?
- **計算國民黨的「年改紅利」衰退曲線**:年改的憤怒動員力隨時間遞減。2028 年,退休軍公教的憤怒仍存,但在職群體已逐漸接受新制為常態。國民黨若仍將「反年改」作為核心訴求,其邊際效益將大幅下降。
- 交叉引用:此動態與 `events/key_events/event_pension_reform_2017.md` 的長期效應分析直接相關。
---
## 組織化網絡與信息行為:黃復興的幽靈與同溫層
### 事實層
軍公教的組織化網絡以退役軍人的「黃復興黨部」最為著名。黃復興曾是國民黨內最龐大的特種黨部,掌握數十萬黨員,具有獨立的提名與輔選機器。2024 年,朱立倫推動黃復興裁併至地方黨部,名義上是組織改造,實質上是削弱深藍對黨機器的控制。
然而,黃復興的「實體」雖然消失,其「網絡」卻以退伍軍人協會、榮民榮眷基金會、眷村改建社區管委會等形式存續。這些網絡是封閉的、高信任度的,且具有極強的訊息過濾能力。
在信息行為上,此群體高度依賴同溫層:
- **退休群體**:以 LINE 群組、臉書社團為主要信息來源,信任傳統媒體(如 TVBS、中天新聞網)與特定政治人物(如韓國瑜、趙少康)。對主流媒體(如三立、民視)持本能性排斥。
- **在職群體**:資訊來源較為多元,但受制於行政中立要求,在公開社群媒體上極少表態。對政府內部公文與政策宣導的信任度低,反而更信任同僚間的私下傳聞。
### 脈絡層
**結構性命題**:軍公教的資訊環境是「高密度、低多樣性」的,這使其成為特定政治敘事的完美培養皿,但也限制了敘事向外擴散的能力。
黃復興網絡的存續,意味著深藍敘事(如「民進黨貪腐」、「美國不可靠」、「兩岸必須和平」)可以在沒有外部挑戰的情況下迅速穿透整個群體。但這種封閉性也是其弱點:當敘事與客觀現實產生嚴重衝突時(如國民黨在立法院的妥協),群體內部會產生巨大的認知失調,進而轉化為對政治整體的冷感。
#### 分析含義
分析時如何用這個事實?
- **評估「深藍敘事」的穿透速度**:針對退役軍人的敘事,只要在幾個核心 LINE 群組中引爆,24 小時內即可覆蓋全台 80% 的退役軍人網絡。但此敘事極難穿透至 40 歲以下的在職群體。
- **預測黃復興裁併的長期效應**:黃復興的裁併削弱了國民黨中央對深藍的「指令控制權」,但深藍的「自發動員力」仍在。這意味著國民黨主席未來更難壓制深藍的反彈,深藍可能更傾向支持黨內的極端派或第三勢力。
---
## 核心議題敏感點與認知脆弱/韌性
### 事實層
軍公教群體的核心敏感議題可分為三個層次:
1. **物質利益層**:退休金額、優惠存款、退撫基金破產危機、在職加薪幅度。
2. **制度尊嚴層**:年改的溯及既往(被視為違反法律不溯及既往原則)、轉型正義對威權時期的否定(被視為對其生涯貢獻的抹殺)。
3. **國家認同層**:中華民國國號與旗幟的存廢、去中國化教育、兩岸戰爭風險。
其認知脆弱點在於:
- **「契約破壞」的受害者敘事**:極易接受「民進黨是清算軍公教的政黨」的敘事,將所有政策變動都解讀為政治迫害。
- **疑美論與棄台論**:基於對民進黨的不信任,極易接受「美國會出賣台灣」、「美國只會賣武器賺錢」的敘事,這與其內心深處對戰爭的恐懼產生共振。
- **懷舊威權敘事**:部分高齡退役軍人對威權時期的社會秩序抱有浪漫化想像,極易被「強人領導」、「社會安定」的敘事吸引。
其認知韌性在於:
- **法治主義信仰**:公務員體系對程序正義與法制穩定有高度信仰,對缺乏法理基礎的民粹動員(如韓國瑜的部分激進言論)存在內在排斥。
- **資訊解讀能力**:整體教育程度高,對低級的假訊息(如簡單的移花接木圖片)有較強的辨識力,需要更高級的「斷章取義」或「脈絡扭曲」才能穿透。
- **專業倫理**:在職軍公教受制於專業倫理與行政中立,不易被激進化至參與非法抗爭。
### 脈絡層
**中等模型自己想不到的洞察**:軍公教對「兩岸和平」的執著,不僅僅是出於「統派」意識形態,更是出於「生存利益」的理性計算。
對於依賴退休金生活的軍公教而言,戰爭意味著退休金發放中斷、房產價值歸零、醫療體系崩潰。他們是現狀維持的最大既得利益者,因此也是戰爭風險的最敏感人群。民進黨的「抗中保台」論述,在此群體中被解讀為「將台灣推向戰爭邊緣,威脅我的生存底線」。這不是意識形態的衝突,而是生存理性的對撞。
#### 分析含義
分析時如何用這個事實?
- **預測「避戰」敘事的市場**:2028 年,若兩岸緊張持續,任何主打「避戰」、「和平協議」、「重啟對話」的候選人或政黨,將自動獲得軍公教群體的青睞,即使其候選人特質並不完美。
- **評估「疑美論」的擴張路徑**:疑美論在軍公教群體中的傳播,不是因為他們親中,而是因為他們需要一個理由來否定民進黨的國防路線(「既然美國不可靠,就不應挑釁北京」)。攻擊美國,本質上是為了攻擊民進黨。
---
## 兩岸與國際態度:實用主義的避險與尊嚴的焦慮
### 事實層
在兩岸議題上,軍公教的態度呈現「表層統一、深層避險」的特徵:
- **對北京**:退役軍人基於反共與大中華情結的矛盾,對北京持「防備但不願徹底決裂」的態度;公務員與教師則多數支持維持現狀,對北京的敵意低於民進黨支持者,但高於純粹的經濟選民。
- **對 ECFA 與交流**:高度支持兩岸經貿交流,因為這被視為降低戰爭風險的有效途徑。ECFA 中斷對其直接經濟利益影響不大,但對其心理安全感影響極大。
- **對美方**:呈現「安全依賴」與「信任赤字」的並存。依賴美國提供安全保障,但高度懷疑美國的承諾(「疑美論」的核心受眾)。
- **對日方**:退役軍人對日本有強烈的歷史恩怨(基於抗戰記憶),對台日關係升溫持排斥態度;公務員與教師則較為務實。
### 脈絡層
**破除常見簡化**:將軍公教的「親中」等同於「支持統一」是錯誤的。他們的「親中」往往是「親體制」(中華民國體制)與「求和平」的混合物。
當北京打壓中華民國的國際空間(如要求外國企業更改台灣稱謂)時,軍公教的反應與社會主流無異,甚至更為憤怒,因為這觸及了他們對「國家尊嚴」的底線。他們支持兩岸交流,前提是交流必須建立在「對等尊嚴」之上。若北京的施壓被解讀為「消滅中華民國」,軍公教中的務實派會迅速向防禦性認同移動。
#### 分析含義
分析時如何用這個事實?
- **評估北京「區別對待」策略的效力**:北京若在 2028 前對台灣農漁業或特定產業施壓,但對退役軍人釋放「和平統一尊重現行制度」的訊號,將難以分化軍公教群體,因為其物質利益(退休金)與國家體制綁定,而非與兩岸經貿綁定。
- 交叉引用:此群體的認同演變與 `narratives/cross_strait/kmt_authoritarian_legacy.md` 中威權遺產的當代重構密切相關。
---
## 近年變化趨勢與 2028 情境推演
### 事實層
近 5-10 年,軍公教群體面臨三大結構性演變:
1. **物理性凋零**:外省退役軍人每年自然減員數以萬計,傳統眷村鐵票區的影響力持續下降。
2. **本省化與本土化**:現役軍公教中本省籍已佔絕對多數,其國家認同已從「大中國」轉向「中華民國台灣」,對國民黨的忠誠度下降,對民進黨的敵意也趨於理性化。
3. **組織控制力衰退**:國民黨對軍公教的動員從「指令式」(透過黃復興)轉為「情緒式」(透過反綠憤怒),控制力大幅減弱。
### 脈絡層
**結構性命題**:軍公教正在從「國民黨的財產」轉變為「反綠陣營的浮動資產」。
2028 年的軍公教選票,將不再由國民黨自動提款,而是成為藍白競合的關鍵戰場。若國民黨候選人無法激發「尊嚴保衛戰」的熱情,而民眾黨候選人成功將「反綠」與「避戰」綁定,軍公教中的在職與年輕群體將毫不猶豫地流向民眾黨。
### 2028 情境推演
#### 情境一:兩岸高度緊張(北京軍事威懾升級)
此情境下,軍公教的「避戰焦慮」將壓倒「年改憤怒」。他們會將選票投給最有可能「與北京對話」的候選人。若國民黨候選人成功壟斷「和平使者」的敘事,將獲得軍公教的高度歸隊;若民眾黨候選人提出更具體的避戰方案,將分流國民黨的選票。民進黨的「抗中保台」在此群體中將完全失效,甚至產生強烈反彈。
#### 情境二:兩岸低度緊張(北京和統攻勢)
此情境下,軍公教的「年改憤怒」與「生活利益」將重回主導。若民進黨政府在兩岸關係和緩的背景下,未能有效解決退撫基金的財務危機或在職人員的加薪問題,軍公教將基於利益投票給在野黨。但此時,國民黨的「反年改」承諾若被視為不切實際,選票將流向主打「財政紀律與分配正義」的民眾黨。
#### 情境三:內政議題主導(無重大兩岸事件)
此情境下,軍公教的投票行為將最為分化。退休群體仍基於慣性支持國民黨,但在職群體將根據候選人的形象、能力與政策可行性進行判斷。國民黨若提名深藍色彩濃厚的候選人,可能穩住退休票但流失在職票;若提名本土形象候選人,則可能兩頭落空。
---
## 關聯分析
### 關聯一:與 KMT 選民基礎的連結(提供脈絡層)
- `entities/parties/kmt_voter_base.md`:軍公教是 KMT 選民基礎中「經濟利益群體」的核心組成。理解軍公教的「契約破壞感」,是解讀 KMT 為何在 2018 年大勝卻在 2020 年未能守住基本盤的關鍵——因為軍公教的憤怒無法轉化為對 KMT 無能的寬恕。
### 關聯二:與深藍認同的連結(提供事實層與對照)
- `audiences/political_identity/deep_blue.md`:退役軍人是深藍認同的最大載體,但軍公教整體並不等於深藍。對照此二檔案,可識別出深藍敘事在穿透公務員與教師群體時的邊界與阻力。
### 關聯三:與淺藍認同的連結(提供事實層與對照)
- `audiences/political_identity/light_blue.md`:在職公務員與年輕教師的認同結構更接近淺藍甚至中間選民。年改後,此群體的流動性增加,是藍白競合的關鍵搖擺票。
### 關聯四:與年金改革事件的連結(提供事實層)
- `events/key_events/event_pension_reform_2017.md`:年金改革是軍公教政治態度的斷代史事件。此事件檔案提供了軍公教從「國家守護者」轉變為「國家對抗者」的具體時間線與政策細節,是分析其長期創傷的基礎。
### 關聯五:與威權遺產敘事的連結(提供脈絡層)
- `narratives/cross_strait/kmt_authoritarian_legacy.md`:軍公教的尊嚴焦慮,與民進黨推動的轉型正義敘事(將威權時期國家機器代理人視為加害者)存在根本衝突。理解此敘事衝突,才能解釋為何軍公教對「不當黨產處理」與「中正紀念堂轉型」等議題反應極度激烈。
### 關聯六:與北京反應類型學的連結(提供分析框架)
- `primitives/frameworks/beijing_response_typology.md`:北京的不同施壓模式如何差異性地影響軍公教的「避戰焦慮」。軍事威懾會強化其對和平的渴望(可能轉向承諾避戰的候選人),經濟脅迫則可能強化其對國家安全的擔憂(可能轉向支持國防預算的候選人,儘管這與其反綠立場矛盾)。
---
## 常見的分析陷阱
### 陷阱一:將「軍公教」等同於「深藍鐵票」
這是最常見的錯誤。軍公教中只有退役軍人是深藍鐵票,公務員與教師的藍綠光譜極寬。將整個群體的行為預設為支持國民黨,會嚴重誤判民眾黨在軍公教群體中的擴張潛力,也會忽略民進黨透過政策安撫在職人員的可能性。
### 陷阱二:假設「年改憤怒」是永恆的動員機器
年改的憤怒具有時效性與代際差異。退休者的憤怒是固化且不可逆的,但在職者的憤怒會隨著新制成為常態而消退。到了 2028 年,若退撫基金財務改善或在職待遇提升,年改作為動員主軸的效力將大幅遞減。
### 陷阱三:忽略「避戰焦慮」與「統獨立場」的脫鉤
軍公教支持兩岸和平,不代表他們支持統一。他們的「避戰」是出於生存利益,而非意識形態。將其「避戰」訴求解讀為「親中賣台」,是民進黨常見的論述錯誤;將其「避戰」解讀為「支持統一」,是深藍常見的論述自嗨。
### 陷阱四:將「黃復興」的解散視為組織的消亡
黃復興的實體雖然解散,但其人際網絡與信任結構並未消失,只是從「由上而下的指令控制」轉變為「由下而上的情緒共振」。忽略這種隱形網絡的動員力,會低估深藍在初選與大選中的爆發力。
### 陷阱五:過度解讀 2018 年的韓流效應
2018 年韓國瑜在軍公教群體中的超高支持度,是「反建制憤怒」與「年改受害者情結」的極端爆發,並非常態。試圖在 2028 年複製韓流,忽略了軍公教群體在經歷 2020 與 2024 後的「情緒疲勞」,他們現在更渴望穩定與尊嚴,而非激情與破壞。
### 陷阱六:認為軍公教對「疑美論」的接受是基於親中立場
軍公教接受「疑美論」,不是因為他們認同北京,而是因為他們需要一個理由來否定民進黨的國防路線,從而合理化自己的「避戰」訴求。疑美論在此群體中是「工具性」的,而非「信仰性」的。
---
## 仍不確定的部分
### 退撫基金的財務永續性
退撫基金是否會在未來十年內面臨破產危機?若破產危機成真,在職軍公教的恐慌將徹底改變其政治態度,可能轉向支持任何能提出有效財務解決方案的政黨,即使該政黨過去是他們的敵人。目前仍不確定政府的撥補速度能否追上人口老化的速度。
### 民眾黨在軍公教群體中的固化程度
2024 年柯文哲吸納了部分在職軍公教選票,但這是「抗議票」還是「常態化支持」?若民眾黨在 2028 推出具軍公教背景或能提出具體退撫改革方案的候選人,能否進一步蠶食國民黨的鐵票?此群體的政黨忠誠轉移缺乏歷史先例,難以精確預測。
### 轉型正義的邊界與反彈
民進黨推動的轉型正義(如不當黨產處理、威權象徵清除)是否會觸及軍公教群體的「尊嚴紅線」?若轉型正義從「制度追究」升級為「個人道德清算」(如追究威權時期特定公務員的責任),將可能再次觸發軍公教的防禦性動員,甚至跨越藍綠界線引發體制危機。
### 北京對退役軍人群體的精準統戰
北京是否會繞過國民黨,直接對台灣退役將領與榮民群體進行「民族主義」與「避戰和平」的精準統戰?若北京成功將「反對台獨」與「保衛中華民國尊嚴」進行概念偷換,可能會在退役軍人中引發嚴重的認同混亂,進而影響其在職子弟的態度。
---
## 與其他文件的關係
- KMT 選民基礎:`entities/parties/kmt_voter_base.md`(對照軍公教作為 KMT 核心鐵票的演變與鬆動)
- 深藍認同:`audiences/political_identity/deep_blue.md`(對照退役軍人與深藍認同的重疊與差異)
- 淺藍認同:`audiences/political_identity/light_blue.md`(對照在職公務員與教師的搖擺特徵)
- 年金改革事件:`events/key_events/event_pension_reform_2017.md`(提供軍公教政治態度斷裂的關鍵事件基礎)
- 威權遺產敘事:`narratives/cross_strait/kmt_authoritarian_legacy.md`(解釋軍公教對轉型正義的防禦性反應機制)
---
## 一句話濃縮
> 軍公教是「國家契約的依附者」與「體制尊嚴的守門人」,年金改革撕裂了他們與國家的心理契約,使其成為最憤怒的被剝奪者,但對戰爭摧毀生存底線的恐懼,又迫使他們在藍白之間尋找最能兌現「避戰與護產」的交易對象,而非無條件回歸傳統政黨的懷抱。
---
last_updated: 2025-03-22
@@ -1,312 +0,0 @@
# 基層服務業從業人員
---
**類型**: 受眾社會身分(政治敘事與行為分析檔案)
**當前角色**: 台灣選舉中規模最大但結構最鬆散的「沉默搖擺州」;經濟脆弱性與時間貧困的交叉點
**出生時間(群體成型)**: 1990 年代後期服務業佔比突破 60% 時確立其結構性地位
**何時調用此檔案**: 預測 2028 及後續選舉中經濟議題的動員效力;評估通膨、最低工資、居住政策對基層選票的衝擊;分析短影音與社群媒體對低時間餘裕選民的認知塑造;解讀「韓流」或「柯文哲現象」在基層服務業中的支持基礎;規劃針對非都會區或高工時群體的訊息投放策略
---
## 一句話濃縮
> 基層服務業從業人員不是政治冷漠的盲從者,而是受制於「時間貧困」與「經濟脆弱」的生存型選民;他們的政治參與度與物價漲幅成正比、與實質薪資成反比,其集體覺醒往往是對執政者的懲罰性公投,而非對特定意識形態的效忠。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將基層服務業從業人員視為「低學歷、低資訊、容易被傳統樁腳動員的盲從者」,或者將他們與「傳統農工階級」畫上等號。這種簡化無法解釋 2018 年韓國瑜在夜市與計程車界的橫掃,也無法解釋 2024 年柯文哲在外送與服務業年輕群體中的高支持度。
基層服務業的本質是「時間貧困」與「經濟脆弱」的雙重擠壓:
1. **時間貧困**:長工時與排班制剝奪了他們參與公共討論的時間資本,使他們成為民主政治中「系統性缺席」的群體。
2. **經濟脆弱**:薪資停滯與高物價使他們對「生存議題」(房租、物價、健保)極度敏感,但對「後物質議題」(主權、環保、轉型正義)缺乏共鳴。
這群體在選舉中呈現極高的「波動性」與極低的「忠誠度」。他們不欠任何政黨的票,他們的投票行為是對「當下生存壓力」的直覺反應。理解基層服務業,就是理解台灣選舉中「經濟痛感」如何轉化為「政治變天」的底層物理學。
---
## 群體定義與邊界:四百萬人的異質性集合
### 事實層
台灣基層服務業從業人員規模約佔總就業人口的 40-45%,人數約 400-500 萬。此群體涵蓋餐飲業、零售業、運輸業(含計程車與外送)、個人服務業(美容美髮、照護)等。其識別標準不是職業別,而是「薪資水平」與「勞動條件」:月薪貼近基本工資(2024 年為 27,470 元新台幣)、工時偏長(常態超過 10 小時)、排班制為主、缺乏穩定的勞動契約與工會保護。
此群體內部存在顯著的次群體分化:
| 次群體 | 人口特徵 | 勞動型態 | 核心焦慮 |
|---|---|---|---|
| 餐飲與零售青年 | 18-35 歲為主,學歷高中至大學 | 服務業排班、打工、外送 | 低薪無法負擔房租、未來無望 |
| 運輸與物流從業 | 30-55 歲男性為主 | 計程車、貨運、外送平台 | 油價、平台抽成、職業傷害 |
| 傳統個人服務業 | 40-65 歲女性為主 | 美容美髮、傳統市場 | 客源流失、健保與退休金 |
| 長照與居家服務 | 35-60 歲女性為主 | 高體力消耗、低社會地位 | 職業傷害、低薪、照顧負擔 |
### 脈絡層
**破除常見簡化**:把基層服務業視為「均質的底層階級」是嚴重的分析錯誤。
基層服務業的內部異質性極高。外送員的流動性與反抗性遠高於傳統市場攤商;長照人員的社會福利依賴度遠高於計程車司機。他們唯一的共同點是「缺乏盈餘」,但盈餘缺乏的原因各異:青年是因為起薪過低,中年是因為家庭負擔過重,老年是因為退休金不足。
**結構性命題**:基層服務業是台灣經濟轉型的「避震器」,也是「代謝物」。當高科技產業吸走所有資源與人才時,服務業承接了被製造業與科技業排擠的勞動力;但他們的薪資成長被物價與房租完全吞噬,形成「經濟成長的旁觀者」。
### 分析含義
分析時如何用這個事實?
- **避免單一訴求**:對基層服務業喊「抗中保台」或「發大財」都無法打動所有人。必須針對次群體的特定痛點(青年房租、運輸油價、長照負擔)進行精準訴求。
- **計算隱性失業率的政治效應**:外送平台與低薪服務業吸納了大量高學歷青年,這群體的「學歷-職位錯配」是極大的政治不滿蓄水池。
---
## 資訊行為:時間貧困下的演算法餵養
### 事實層
基層服務業的資訊接觸渠道受制於「時間貧困」。長工時與碎片化的休息時間,使他們無法進行深度閱讀或追蹤公共議題。其主要資訊來源為:
1. **短影音平台**(TikTok、Reels、Shorts):在休息時間快速滑動,獲取即時的情緒刺激。
2. **封閉式社群**(Line 群組):依賴同事、親友的轉傳,信任度建立在人際關係而非媒體權威。
3. **演算法推播**:被動接收平台推薦的內容,極少主動搜尋。
他們對資訊的信任來源是「跟我一樣的人」或「說出我心聲的人」,而非傳統媒體或學者。這使得網紅、直播主、甚至「路邊阿伯」的短評,比報社社論更有影響力。
### 脈絡層
**中等模型自己想不到的洞察**:基層服務業的「低政治參與」不是因為「不關心」,而是因為「沒時間關心」。
民主參與需要時間成本:查證資訊、參與討論、參加集會。這些對朝九晚五的白領是基本權利,對排班制服務業卻是奢侈品。因此,他們的政治認知高度依賴「捷思法」(heuristics):誰看起來最生氣、誰的口號最簡單、誰承諾最直接的改變,就相信誰。這使得他們成為民粹敘事與假訊息的易感人群,不是因為愚蠢,而是因為時間貧困剝奪了他們深度思考的資源。
### 分析含義
分析時如何用這個事實?
- **評估短影音戰的效力**:在 2028 選戰中,能在 15 秒內傳達「痛點」與「解方」的短影音,對服務業選民的影響力將遠超過政見發表會。
- **預測假訊息的穿透率**:針對物價、房租、兩岸戰爭的聳動假訊息,在 Line 群組中的傳播速度與服務業的排班休息時間高度相關。
---
## 政治傾向與投票模式:懲罰性公投的幽靈
### 事實層
基層服務業的投票率長期低於全國平均,但在特定條件下會爆發性動員:
- **2018**:韓國瑜的「發大財」精準擊中夜市攤商與計程車司機的生存焦慮,動員了大量過去不投票的基層服務業。
- **2020**:香港反送中觸發避險心理,部分基層服務業(特別是中年女性)歸隊支持 DPP,但青年服務業仍因低薪問題對 DPP 冷感。
- **2024**:柯文哲吸納了大量青年服務業(外送、餐飲)的「反建制」選票,而傳統運輸與攤商則因對 DPP執政的經濟不滿,流向 KMT 或不投票。
他們的投票模式呈現「鐘擺效應」:對現狀不滿時,傾向懲罰執政黨,而非基於理念支持在野黨。
### 脈絡層
**破除常見簡化**:把基層服務業的藍綠傾向用「南綠北藍」來劃分是無效的。
基層服務業的投票行為是「利益投票」的極致展現。他們不在乎候選人的統獨立場,只在乎「我的生意會不會變好」、「我的房租會不會降」。2018 年韓國瑜能跨越濁水溪,正是因為「經濟100分,政治0分」的論述,暫時解除了基層服務業的意識形態武裝,讓他們純粹基於生存利益投票。
### 分析含義
分析時如何用這個事實?
- **預測「經濟投票」的臨界點**:當消費者物價指數(CPI)年增率連續超過 3%,且實質經常性薪資轉為負成長時,基層服務業的「懲罰性投票」機率將大幅上升。
- **評估「抗中」牌的邊際效用遞減**:對基層服務業而言,「主權危機」是遠慮,「房租上漲」是近憂。當近憂無解時,遠慮的動員力將失效。
---
## 核心議題敏感點:生存三箭與相對剝奪感
### 事實層
基層服務業最敏感的三大議題,依序為:
1. **經濟與物價**:實質薪資停滯與原物料上漲的雙面夾擊。交叉引用:`narratives/domestic/wage_stagnation_narrative.md`
2. **居住與房租**:無力購屋,且租金佔收入比極高(雙北甚至超過 40%)。交叉引用:`narratives/domestic/housing_crisis_discourse.md`
3. **生育與未來**:低薪與高工時使其無力成家,少子化在此群體中最為慘烈。交叉引用:`narratives/domestic/low_birthrate_discourse.md`
此外,他們對「產業政策」的態度是矛盾的:既羨慕科技業的高薪,又憤怒於政府資源全傾斜於科技業,導致服務業被視為「次等公民」。交叉引用:`narratives/domestic/industrial_policy_discourse.md`
### 脈絡層
**結構性命題**:基層服務業的相對剝奪感,來自於「看得到天花板,但永遠爬不上去」。
台灣的經濟成長果實集中在半導體與資通訊產業,服務業的勞動生產力成長極為緩慢。當媒體天天報導科技業年終獎金數十個月時,服務業從業人員的相對剝奪感被極度放大。這種剝奪感不是來自絕對貧窮,而是來自「社會不公平」的直覺。
### 分析含義
分析時如何用這個事實?
- **識別「仇富」與「反建制」的動員潛力**:任何能將「低薪」歸咎於「政府圖利財團/科技業」的論述,都能在基層服務業中引發強烈共鳴。
- **評估「分配正義」政策的選票轉換率**:提高基本工資、擴大租金補貼、增加社會住宅,這些政策對基層服務業的實質利益大於任何稅收減免。
---
## 認知脆弱點與韌性:情緒的俘虜,常識的守門人
### 事實層
**認知脆弱點**:
- **速效解方依賴**:容易接受「只要X,就能Y」的簡化敘事(如「只要重啟核四,電價就不漲」、「只要兩岸通,經濟就會好」)。
- **陰謀論易感性**:對複雜的國際政經局勢缺乏理解時間,傾向相信「有人在背後操縱」的簡化解釋(如「美國賣萊豬是為了毒害台灣」、「疫苗採購有黑幕」)。
- **仇恨動員**:對剝奪他們利益的人(如「炒房者」、「無良老闆」、「傲慢官員」)有極高的仇恨值,容易被引導至特定代罪羔羊。
**認知韌性**:
- **常識檢驗**:對「與自身經驗不符」的論述有極強的免疫力。政治人物若宣稱「經濟是二十年來最好」,會被基層服務業的日常帳本直接打臉。
- **街頭智慧**:長期與顧客互動,培養出對「人情世故」的敏銳度,能快速識別「說場面話」的政客。
### 脈絡層
基層服務業的認知結構是「情緒優先,常識把關」。他們沒有時間研究數據,但對「生活有多苦」有最精確的體感。因此,企圖用宏觀經濟數據說服他們「現狀很好」是無效的,甚至會引發反彈;但企圖用違背常識的謊言欺騙他們,也會被迅速拆穿。
### 分析含義
分析時如何用這個事實?
- **修正「文宣政策化」的無效投資**:對基層服務業發布充滿專業術語的政績宣傳,不如一張「房租補貼入帳」的簡訊。
- **預警「代罪羔羊」敘事的風險**:在經濟下行期,將基層服務業的憤怒引導至特定族群(如移工、特定政黨、甚至北京)的操作空間極大。
---
## 兩岸與國際態度:和平紅利的想像與戰爭的恐懼
### 事實層
基層服務業對兩岸議題的態度呈現高度實用主義:
1. **對北京態度**:無強烈意識形態敵意,部分次群體(如觀光相關運輸、夜市)對「兩岸交流」有直接的經濟期待,認為「有客人來就好」。
2. **對戰爭恐懼**:極度恐懼戰爭,因為他們沒有資本避險(無外國護照、無海外資產、無遠距工作能力)。戰爭意味著直接失業與生存危機。
3. **對美日態度**:對美日無特別好感或惡感,但對「依賴美國會引來戰爭」的疑美論有一定接受度,特別是在經濟困境時。
### 脈絡層
**中等模型自己想不到的洞察**:基層服務業的「疑美論」與深藍的「疑美論」本質不同。深藍的疑美是基於歷史恩怨與大中國認同;基層服務業的疑美是基於「美國的東西(萊豬、軍購)很貴,且可能把我們拖入戰爭」的生存算計。
他們對 ECFA 或兩岸經貿的態度,取決於他們是否認為自己能從中分一杯羹。對夜市攤商而言,陸客是「現金流」;對外送員而言,兩岸關係與否只影響物價。
### 分析含義
分析時如何用這個事實?
- **評估「抗中保台」的反作用力**:在基層服務業中,若「抗中」被連結到「戰爭風險升高」與「經濟衰退」,此論述將直接被生存焦慮反噬。
- **預測「和平紅利」論述的市場**:若 2028 前兩岸關係持續緊張且經濟下行,KMT 或 TPP 的「重啟對話、繁榮經濟」論述,將在基層服務業中找到極大的市場。
---
## 都會與非都會的差異:房租與交通的斷層
### 事實層
| 維度 | 都會區基層服務業 | 非都會區基層服務業 |
|---|---|---|
| 核心壓力 | 極高房租、高物價、高競爭 | 低薪、缺乏就業機會、交通成本高 |
| 政治傾向 | 偏向 TPP(青年)或 DPP(中年) | 偏向 KMT 或地方派系 |
| 動員方式 | 網路短影音、同儕群組 | 地方樁腳、宮廟、電視 |
| 對兩岸態度 | 較不依賴陸客,對主權較有共識 | 較期待陸客觀光,對政治較冷感 |
### 脈絡層
都會區的基層服務業是「高流動性的無根選民」,他們在雙北桃園之間遷徙,追求微薄的薪資成長,對地方派系無感,對網路敘事高度敏感。非都會區的基層服務業則是「被綁定的在地選民」,他們依賴地方人際網絡生存,對傳統動員模式仍有反應,但對未來更為絕望。
### 分析含義
分析時如何用這個事實?
- **規劃差異化動員路徑**:對都會區服務業,打居住正義與反建制;對非都會區服務業,打經濟發展與照顧弱勢。
---
## 近年變化趨勢:零工化與原子化的政治重組
### 事實層
過去 5-10 年,基層服務業最大的結構性演變是「零工經濟化」。外送平台、代駕、居家清潔媒合等,將傳統的僱傭關係拆解為「接單制」。這帶來了名義上的自由,但實質上是勞動保障的全面撤退。
### 脈絡層
**結構性命題**:零工化摧毀了基層服務業的「集體行動能力」,但強化了他們的「原子化憤怒」。
過去,工廠與公司是政治動員的節點;現在,外送員在街頭奔馳,彼此互不相識,沒有工會,沒有主管,也沒有人替他們向政治人物施壓。他們的憤怒無法透過組織化的管道抒發,只能累積為對整個體制的敵意,在選舉時爆發為對執政者的懲罰性投票,或被民粹領袖收割。
### 分析含義
分析時如何用這個事實?
- **預測「數位民粹」的崛起**:零工勞動者缺乏實體組織,但他們在演算法驅動的網路空間中高度聚集。能掌握其數位足跡與情緒痛點的政治人物,將能繞過傳統組織,直接動員這股龐大的原子化力量。
---
## 關聯分析
### 關聯一:與薪資停滯敘事的連結(提供事實層與脈絡層)
- `narratives/domestic/wage_stagnation_narrative.md`:基層服務業是薪資停滯敘事的「物理載體」。此敘事能否轉化為選票,取決於哪個政黨能成功將「低薪」歸咎於對手,並提出可信的解方。
### 關聯二:與居住危機論述的連結(提供事實層)
- `narratives/domestic/housing_crisis_discourse.md`:房租是基層服務業最尖銳的痛點。居住危機論述在都會區服務業中具有極高的動員潛力,特別是對 20-39 歲的青年服務業。
### 關聯三:與少子化論述的連結(提供脈絡層)
- `narratives/domestic/low_birthrate_discourse.md`:基層服務業的生育率最低,少子化論述若能連結到「政府沒有給年輕人安身立命的環境」,將引發深層的生存共鳴。
### 關聯四:與產業政策論述的連結(用於對照)
- `narratives/domestic/industrial_policy_discourse.md`:產業政策論述若過度傾斜於高科技業,將直接觸發基層服務業的相對剝奪感。分析時需評估「護國群山」論述在服務業眼中的「排斥效應」。
### 關聯五:與投票行為框架的連結(提供分析框架)
- `primitives/frameworks/voter_behavior_analysis.md`:使用「理念、利益、議題、形象」四重模式,拆解基層服務業的「利益壓倒理念」與「形象壓倒議題」特徵。
### 關聯六:與 DPP/KMT 選民基礎的對照(用於交叉驗證)
- `entities/parties/dpp_voter_base.md` 與 `entities/parties/kmt_voter_base.md`:基層服務業是兩黨基本盤的「浮動地層」。DPP 的基層本土派與 KMT 的本省地方派系,其底層皆由基層服務業構成,誰能回應其生存焦慮,誰就能暫時租借這塊地層。
---
## 常見的分析陷阱
### 陷阱一:將基層服務業視為「保守反動」的鐵票
這是嚴重的誤判。基層服務業不是保守,而是「恐懼改變會讓現狀更差」。當有可信的改變承諾時(如 2018 韓國瑜),他們會展現極高的冒險意願。他們是生存的實用主義者,不是意識形態的保守派。
### 陷阱二:假設「提高基本工資」必然轉化為對 DPP 的支持
提高基本工資雖然直接受益基層服務業,但若伴隨物價上漲與排班減少(雇主成本轉嫁),其「實質感受」可能是負面的。將政策利多直接等同於選票,忽略了基層對「變相減薪」的敏銳體感。
### 陷阱三:忽略「時間貧困」對競選策略的影響
企圖用長篇政見說服基層服務業是無效的。他們沒有時間看。若競選訊息不能在 15 秒內傳達痛點與解方,就等於不存在。用白領的文宣邏輯打基層選戰,是資源的極大浪費。
### 陷阱四:將「低學歷」等同於「低判斷力」
基層服務業缺乏的是時間與理論訓練,不是常識與生存智慧。企圖用違背常識的數據(如「實質薪資成長」)來說服他們,會被視為傲慢與欺騙,反而強化其反建制情緒。
### 陷阱五:過度簡化其對兩岸關係的態度
假設基層服務業都支持「兩岸開放賺錢」或都恐懼「北京統一」都是錯的。他們的態度是分裂的:想要陸客的生意,但不想打仗;想要便宜的淘寶貨,但不想被併吞。任何將兩岸議題二元化的企圖,都會在此群體中產生認知失調。
### 陷阱六:將外送員與傳統攤商視為同一群體
外送員是零工經濟的原子化勞工,傳統攤商是微型創業主。兩者的利益結構完全不同,甚至存在衝突(平台抽成 vs. 消費者價格)。將兩者混為一談,會導致政策訴求的失焦。
---
## 仍不確定的部分
### 零工勞動者的政治固化路徑
外送員等零工勞動者在經歷最初的「反建制」情緒後,是否會形成新的階級意識?還是會持續在演算法的隔離下保持原子化?這將決定他們是成為新政黨的基礎,還是持續作為民粹政治的浮萍。目前缺乏長期追蹤數據。
### AI 與自動化對基層服務業的政治衝擊
當 AI 點餐、自助結帳、無人計程車開始大規模取代基層服務業時,這群體的「生存焦慮」是否會轉化為對科技業與執政者的「階級仇恨」?此變數可能在 2028 後成為台灣政治的未爆彈,但目前仍難以量化其時間表。
### 「居住正義」政策的選票轉換率
社會住宅與租金補貼,究竟是會讓基層服務業感恩歸隊,還是被視為「杯水車薪」而引發更大不滿?政策的「象徵意義」與「實質效益」之間的落差,目前仍不確定。
### 北京對基層服務業的「精準統戰」
北京是否會從過去的「集體讓利」(如觀光客),轉向針對特定基層群體的「精準施壓/讓利」(如針對特定農漁產品、特定運輸航線)?這種策略能否有效撕裂基層服務業對執政黨的支持?其效果仍待觀察。
---
## 與其他文件的關係
- 薪資停滯敘事:`narratives/domestic/wage_stagnation_narrative.md`(對照基層服務業低薪的結構性成因與政治效應)
- 居住危機論述:`narratives/domestic/housing_crisis_discourse.md`(對照房租壓力如何形塑服務業的投票傾向)
- 少子化論述:`narratives/domestic/low_birthrate_discourse.md`(對照生存壓力與生育意願的連動關係)
- 產業政策論述:`narratives/domestic/industrial_policy_discourse.md`(對照科技業與服務業的資源分配衝突)
- 投票行為分析框架:`primitives/frameworks/voter_behavior_analysis.md`(提供拆解基層服務業「利益/形象投票」的分析工具)
- DPP 選民基礎:`entities/parties/dpp_voter_base.md`(對照基層本土派與基層服務業的重疊與背離)
---
## 一句話濃縮
> 基層服務業從業人員是台灣經濟成長的旁觀者與通膨的承受者,他們在時間貧困中以生存直覺投票,既不是藍綠的鐵票,也不是意識形態的俘虜,而是隨時可能因痛感臨界而爆發的懲罰性公投。
---
last_updated: 2025-03-15
@@ -1,322 +0,0 @@
# 科技業從業人員
---
**類型**: 受眾社會認同(政治敘事特化檔案)
**當前角色**: 台灣科技業從業人員的群體結構、政治態度光譜、認知特徵與預測基礎
**何時調用**: 預測 2028 及後續全國性選舉中科技重鎮(竹竹苗、中南科)的投票行為;分析兩岸經貿政策、產業外移、能源政策對執政黨選票的具體衝擊;評估「疑美論」與「矽盾論」在高科技人群中的傳播效力;規劃針對高學歷理組男性的政策論述與競選策略;解讀國際局勢(如晶片法案、關稅威脅)如何轉化為國內政治壓力
---
## 一句話濃縮
> 科技業從業人員是台灣政經結構中「不可或缺卻充滿焦慮」的階級:他們的經濟貢獻撐起了「矽盾」的自信,但他們的生存焦慮(高房價、產業外移、地緣風險)又使其成為最易因利益受損而反噬執政者的搖擺力量;他們不是天然支持任何政黨,而是「技術實用主義」與「資產焦慮」的混合體。
---
## 為何此檔案對分析者重要
分析者最容易犯的結構性錯誤,是將「科技業從業人員」視為一個同質性的高薪精英群體——預設他們都支持自由派進步價值、都因依賴國際供應鏈而堅定親美、都因反中而支持 DPP。這種簡化在預測時會導致嚴重失準,也無法解釋 2022 與 2024 年新竹市與科技重鎮的選票流動。
科技業從業人員實質上是內部差異極大的異質性群體,其政治態度由三條斷層線交織而成:
1. **產業別差異**:半導體(護國神山群) vs. 硬體代工(西進依賴者) vs. 軟體網路(全球遠端者),對兩岸與國際局勢的利益計算完全不同。
2. **世代與資產差異**:已購房持股的「科技新貴」 vs. 領高薪卻買不起房的「科技流亡者」,對執政黨的經濟評價天差地遠。
3. **認同與實用差異**:將科技實力視為「台灣主體性基礎」的理念派 vs. 將科技專業視為「全球移動籌碼」的務實派,在面對北京壓力與美國拉扯時的歸屬感截然不同。
理解科技業群體,就是理解台灣當前「經濟最佳卻政治最焦慮」的矛盾核心,以及 2028 大選中「高學歷理組男性」這一關鍵搖擺板塊的底層邏輯。
---
## 群體定義與邊界:異質的「矽島聯盟」
### 事實層
台灣科技業從業人員(狹義定義為半導體、電子製造、資通訊、軟體及新興科技產業的直接受僱者)規模約為 60 至 80 萬人。若加上其家屬,影響人口約達 180 至 240 萬人,佔台灣選民結構的 10-12%。此群體的核心識別標準並非僅是「在科技公司任職」,而是「其經濟命運高度受全球供應鏈重組與地緣政治波動的直接影響」。
地理分布上,呈現高度集中的「廊道效應」:
- **新竹科學園區(竹科)及周邊**:以 IC 設計、晶圓代工為核心,人口結構最年輕,平均學歷最高,是 2022 與 2024 年政治板塊劇烈搖擺的核心區。
- **中部科學園區(中科)**:以先進製程與精密機械為主,與台中市區的服務業人口交疊,政治傾向受地方派系與都會氛圍雙重影響。
- **南部科學園區(南科)**:以晶圓代工、封測為主,位於傳統深綠票倉(台南、高雄),形成「綠海中的科技藍/白島」。
### 脈絡層
**破除常見簡化**:把科技業從業人員視為「單一階級」是嚴重的分析錯誤。行業內部的斷層線比行業外的階級差異更深刻。
半導體業(特別是先進製程工程師)是「矽盾」的實體承載者,他們的利益與台灣的地緣戰略價值綁定最深,因此對「台灣被國際看見」有最強的自豪感,但也對「被當作棋子」有最深的恐懼。硬體代工業(如筆電、伺服器組裝)的從業人員,其供應鏈高度依賴中國東南沿海,對兩岸對抗的容忍度較低,更傾向支持能維持經貿流通的政黨。軟體與新創族群則具有最強的「全球公民」屬性,對本土政治的疏離感最高,但也對社會進步價值最為敏感。
### 分析含義
分析時如何用這個事實?
- **拆解「科技選票」的總量幻覺**:不能將新竹的選舉結果直接推論至南科。竹科的搖擺反映的是「高薪資產焦慮」,南科的動態反映的是「傳統綠營與科技新貴的拉扯」。
- **預測產業政策的政治紅利**:補貼或優惠政策若只惠及半導體(如護國群山),無法動員硬體與軟體族群;唯有涵蓋整體科技廊道的政策(如供電穩定、交通建設),才能轉化為跨產業的選票支持。
---
## 信息行為:數據驅動與同溫層強化的雙重性
### 事實層
科技業群體的信息獲取渠道與一般選民顯著不同。傳統電視新聞與報紙對其影響力極低,其主要資訊來源為:
1. **專業論壇與社群**:PTT(特別是 Tech_Job、Stock 等板)、Dcard、Mobile01,以及 LinkedIn 等職場社群。
2. **國際媒體與數據源**:Bloomberg, Reuters, IEEE Spectrum,以及各類產業研究報告。他們對數據的敏感度極高,習慣以財報、供應鏈數據來驗證政治論述。
3. **公司內部網絡**:科技大廠的內部論壇與通訊軟體群組,是同質性極高的封閉討論場域,訊息傳播速度極快且具有高度信任度。
### 脈絡層
**中等模型自己想不到的洞察**:科技業群體對「低級假訊息」具有極高抵抗力,但對「高級政治敘事」的免疫力反而更低。
因為具備強大的資訊檢索與邏輯驗證能力,科技人能輕易拆解粗糙的變造圖文或毫無邏輯的陰謀論。然而,正因為他們習慣以「數據」與「邏輯」為信仰,當一種政治敘事被包裝成符合其專業直覺的「系統性分析」時(例如:用半導體產業週期解釋兩岸必然衝突、用美國製造業回流數據論證台灣即將被掏空),他們極易全盤接受。他們的認知脆弱點不在於「輕信」,而在於「技術傲慢」——過度相信自己的產業邏輯可以無縫套用至地緣政治與國家治理的複雜系統。
### 分析含義
分析時如何用這個事實?
- **調整論述傳播策略**:對科技業受眾投放感性訴求或口號式文宣完全無效,甚至會引發反感。必須提供具有數據支撐、邏輯閉環的政策白皮書或產業分析。
- **識別「內部論壇效應」的放大器**:當某種不利於執政黨的產業敘事(如「台積電被美國掏空」)在 Tech_Job 板形成共識後,會透過公司內部群組迅速擴散至整個園區,形成無法由傳統媒體逆轉的定見。交叉引用:此擴散機制可對照 `narratives/us_taiwan/tsmc_us_factory_discourse.md` 中的掏空論述傳播路徑。
---
## 政治傾向與投票模式:從「綠營鐵票」到「第三勢力溫床」的演變
### 事實層
科技業群體的政黨支持度在過去十年經歷了劇烈的重組:
- **2014-2016**:太陽花學運期間,科技業(特別是軟體與新創)年輕世代高度傾向 DPP,將其視為對抗兩岸政商權貴(如買辦集團)的力量。
- **2018-2020**:雖因香港反送中事件而基於「避險邏輯」支持 DPP,但對 DPP 內政(如能源政策、勞基法修法)的不滿已開始累積。
- **2022-2024**:發生結構性轉向。新竹市成為 TPP 的核心根據地,高虹安現象與柯文哲的高得票率,證實科技業高薪理組男性大規模流向第三勢力。2024 年總統大選,柯文哲在竹科周邊里開出極高票數,賴清德在此區的得票率遠低於全國平均。
### 脈絡層
**結構性命題**:科技業的政治傾向本質上是「反租稅剝削」與「反資源錯置」的實用主義,而非傳統的統獨意識形態。
科技業選民(特別是 30-45 歲的骨幹)對政治的期待是「效率」與「公平」。他們每天面對全球競爭,深知台灣的優勢脆弱,因此對 DPP 執政下累積的三大痛點極度敏感:
1. **居住不正義**:他們是全台薪資最高的群體,卻面臨全台最離譜的房價(新竹房價所得比飆升),這產生了強烈的「相對剝奪感」——「我年薪兩百萬卻買不起房,政府還在炒地皮」。
2. **能源政策不務實**:作為用電大戶與具備理工背景的群體,他們對「非核家園」的可行性高度質疑,認為這是意識形態凌駕專業,威脅產業穩定。
3. **重分配政策的相對剝奪**:他們是綜所稅的主力繳納者,卻感覺政府將資源用於補貼低效產業或社福買票,而非投資於未來科技基礎建設。
TPP 精準捕捉了這三點,以「居住正義」、「務實能源」與「打破藍綠分贓」的論述,成功將科技業的經濟焦慮轉化為政治支持。
### 分析含義
分析時如何用這個事實?
- **修正「抗中保台」在科技業的邊際效應**:在 2028 選戰中,若無極端軍事危機,DPP 在科技業重鎮繼續打「主權牌」將嚴重失效,甚至因觸發「避戰焦慮」而加速選票流失。
- **計算 TPP 的防禦底線**:即使柯文哲面臨司法案件,只要 TPP 能維持「理工務實」與「反建制」的形象,科技業選票就很難回流 DPP,最多轉為不投票。交叉引用:此投票模式與 `entities/parties/tpp_voter_base.md` 中的高學歷理組男性板塊高度重合。
---
## 核心議題敏感點:矽盾的驕傲與焦慮
### 議題一:「矽盾」認同的雙面刃
#### 事實層
「矽盾」(Silicon Shield)是科技業群體最核心的集體認同——相信台灣在全球半導體供應鏈的不可替代性,是確保台灣免受北京武力侵犯的最終保障。此認同在台積電成為「護國神山」後達到頂峰。
#### 脈絡層
**破除常見簡化**:將「矽盾認同」等同於「對國家安全的自信」是錯誤的。矽盾認同的底層是深層的「人質焦慮」。
科技人比任何人都清楚半導體技術的迭代速度。他們知道「不可替代性」是有保存期限的。當美國透過 CHIPS Act 與技術禁令強迫供應鏈重組,當北京傾國家之力發展自主晶片,科技人感受到的不是安全感,而是「台灣正在被掏空」或「台灣正在成為大國博弈的戰場」的恐懼。他們認為自己不是持盾者,而是被綁在盾牌上的人質。交叉引用:此心理機制詳見 `primitives/concepts/silicon_shield.md` 與 `narratives/us_taiwan/chip_island_discourse.md`。
#### 分析含義
分析時如何用這個事實?
- **預測「技術掏空論」的殺傷力**:任何關於「台積電赴美/赴日設廠是去台化」的論述,在科技業內部都具有核彈級的動員力,因為它直接刺穿了矽盾認同的焦慮核心。DPP 若無法有效論述「海外擴廠是擴張而非掏空」,將在此議題上持續失血。
### 議題二:台積電赴美與 CHIPS Act 的複雜態度
#### 事實層
對於台積電赴亞利桑那州設廠及美國《晶片法案》,科技業內部存在嚴重的意見分歧,且此分歧與其政治傾向高度相關:
- **弱版詮釋(全球佈局派)**:認為這是台積電走向全球、降低地緣風險的必要策略,有助於深化台美關係。
- **強版詮釋(技術掏空派)**:認為美國意圖竊取台灣核心技術,工會問題與成本劣勢將拖垮台積電競爭力,這是美國對台灣的經濟榨取。
- **極端版詮釋(棄台前兆派)**:結合疑美論,認為美國一旦建立自主產能,就會拋棄台灣,將台灣烏克蘭化。
#### 脈絡層
此議題之所以敏感,是因為它觸及了科技人「專業尊嚴」與「國家認同」的衝突。在專業上,他們知道美國製造的效率瓶頸;在情感上,他們不願承認台灣的命運必須仰賴美國的施捨。當美國對台灣晶片施加關稅威脅時,這種被「盟友」背刺的憤怒感會達到最高點。交叉引用:此情緒可對照 `narratives/us_taiwan/tariff_on_taiwan_chips.md` 與 `narratives/us_taiwan/tsmc_us_factory_discourse.md`。
#### 分析含義
分析時如何用這個事實?
- **評估「疑美論」在科技業的穿透率**:科技業的疑美論不是源於對中國的認同,而是源於對美國資本主義與霸權邏輯的清醒認知。攻擊他們「親中賣台」完全無效,必須正視其對美國經濟利己主義的合理質疑。
### 議題三:工時壓力與政治參與的擠出效應
#### 事實層
科技業(特別是半導體與 IC 設計)的超長工時是結構性的。這導致了嚴重的「政治參與擠出效應」——他們沒有時間參與實體政治活動(造勢、遊行),甚至對冗長的政治新聞缺乏耐心。
#### 脈絡層
這種擠出效應造成了科技業政治參與的「極化」:要麼完全冷漠不投票,要麼只對「高強度、低時間成本」的議題產生反應。這也是為何網路投票、懶人包圖卡、以及具有娛樂性或強烈情緒性的短影音,在科技業群體中特別有效。他們的政治判斷往往是在極度疲勞的碎片時間中做出的,容易被最簡潔有力的「金句」或「數據圖表」定錨。
#### 分析含義
分析時如何用這個事實?
- **設計接觸策略**:對科技業的政治溝通必須「短、準、硬」(短篇幅、精準打擊痛點、硬數據支撐)。長篇大論的政見發表會對此群體無效。
---
## 兩岸與國際關係態度:實用主義的避險邏輯
### 事實層
科技業對兩岸關係的態度,呈現高度一致的「避險與實用」傾向:
- **對北京**:極度反感其威權體制與技術抄襲,但務實承認中國市場與供應鏈的現實存在。不支持法理台獨,因為那意味著供應鏈的瞬間斷裂;也不接受統一,因為那意味著失去自由與智慧財產權的保護。
- **對 ECFA 與兩岸經貿**:半導體業因美國禁令而逐漸「去中化」,對 ECFA 依賴度低;硬體代工業則對 ECFA 中止深感焦慮,恐失去僅存的毛利。
- **對美方**:高度依賴美國的技術授權與市場,支持深化台美安全關係,但對美國的經濟保護主義(關稅、技術排他)抱持深刻的不信任。
- **對國際局勢**:對美中對抗具有最前線的體感,將地緣政治視為最大的「系統性風險」。
### 脈絡層
**中等模型自己想不到的洞察**:科技業的「維持現狀」與一般民眾的「維持現狀」內涵完全不同。
一般民眾的維持現狀是「不戰不和、保持模糊」的靜態期望。科技業的維持現狀是「在美中技術脫鉤的夾縫中,持續擴張台灣技術不可替代性」的動態平衡。他們知道現狀無法靜態維持,必須不斷往前跑才能留在原地。因此,他們對政治人物的期待不是「維持模糊」,而是「精準管控風險,為產業爭取升級時間」。當政治人物的操作(無論是北京的挑釁或執政黨的挑釁)增加了不必要的系統性風險時,他們會毫不留情地反對。
### 分析含義
分析時如何用這個事實?
- **評估「抗中保台」的升級風險**:DPP 若在 2028 前採取被科技業視為「不必要的挑釁」的兩岸政策,將直接觸發科技業的避險機制,導致選票崩盤。
- **預測北京「經濟脅迫」的分化效果**:北京若精準打擊傳統硬體與零組件產業(如 ECFA 早收清單中止),將在科技業內部製造「半導體無感 vs. 代工重傷」的階級撕裂,削弱科技業作為單一政治板塊的凝聚力。交叉引用:產業分化效應可對照 `narratives/domestic/industrial_policy_discourse.md`。
---
## 認知韌性與脆弱點:技術理性的光與影
### 認知韌性:對低級假訊息的免疫力
#### 事實層
科技業群體因具備高教育程度、強邏輯思維與豐富的跨國資訊獲取能力,對傳統的認知戰(如低劣的變造影片、毫無邏輯的陰謀論、情緒煽動的農場文)具有極高的辨識與抵抗能力。他們是台灣社會中最不易被基礎假訊息動員的群體。
#### 脈絡層
這種韌性來自於其職業訓練:Debug 思維使他們習慣尋找邏輯漏洞,數據驅動決策使他們要求量化證據。任何無法通過基本事實查核與邏輯推演的論述,在此群體中會被秒殺。
### 認知脆弱點:技術傲慢與確認偏誤的陷阱
#### 事實層
然而,科技業群體對「符合其專業直覺的進階敘事」極度脆弱。其核心脆弱點包括:
1. **技術決定論**:傾向相信所有問題最終都是技術問題,可以用工程思維解決。這使他們低估了政治、歷史與社會情感的複雜性,容易接受「經濟決定論」或「實力決定論」的簡化敘事。
2. **精英主義的確認偏誤**:高薪與高社經地位使他們容易相信「我的成功全憑自身努力,失敗者的困境是自身問題」,從而對社會重分配政策抱持敵意,容易被「反福利、反補貼、反稅收」的右翼自由主義論述吸引。
3. **供應鏈焦慮的極化推論**:對產業數據的過度解讀,容易將短期的庫存調整或單一客戶變化,誇大為「台灣產業即將崩潰」或「美國即將棄台」的末日敘事。
#### 脈絡層
**結構性命題**:科技業的認知脆弱點,恰恰是其認知韌性的反面。正因為他們自信能看穿低級騙局,他們對高級包裝的意識形態毫無防備。
當北京或特定政治勢力使用「產業分析報告」的格式,引用真實但片面斷章取義的數據,推導出「執政黨無能導致產業外移」或「美國掏空台灣」的結論時,科技人極易將其視為「客觀的專業分析」而全盤接受,並成為該敘事在封閉同溫層內的強力傳播者。
#### 分析含義
分析時如何用這個事實?
- **識別高階認知戰的目標群體**:科技業是高階認知戰(以數據和產業邏輯包裝的政治宣傳)的首要目標。防守方不能只做事實查核,必須提供更完整、更具前瞻性的產業戰略論述來對抗。
- **預測「精英民粹」的爆發點**:當科技業的精英主義與其現實的資產焦慮(買不起房)結合時,會產生極具破壞力的「精英民粹主義」——他們既反對傳統權貴,也反對底層福利,支持強人領袖以技術官僚的效率進行破壞性改革。
---
## 近年變化趨勢:5-10 年內的結構性演變
### 趨勢一:從「科技新貴」到「科技流亡者」的資產焦慮固化
過去十年,科技業的薪資成長幅度遠低於新竹與都會區的房價漲幅。這製造了一批「高薪無產者」——他們的現金流極佳,但無法轉化為不動產的安全感。這種焦慮在 30-40 歲的區間最為強烈,直接轉化為對執政黨的強烈不滿。此趨勢在短期內無逆轉可能,意味著科技業對現狀的反叛情緒將持續累積。
### 趨勢二:美中脫鉤下的「選邊站」壓力與心理疲勞
過去台灣科技業可以在美中之間左右逢源,但隨著美國技術禁令(如華為禁令、先進製程設備限制)趨嚴,科技業被迫選邊站。這種失去戰略自主性的被迫感,造成了嚴重的心理疲勞。他們對「被美國當棋子」的怨懟與對「被中國威脅」的恐懼同時存在,使得任何單一偏向美國或中國的論述都無法讓他們安心。
### 趨勢三:軟體與新興科技(AI)族群的崛起與政治異質性
隨著 AI 產業爆發,軟體工程師與演算法研究員的數量與影響力上升。與硬體/半導體工程師相比,這群體更具全球移動力,對台灣實體基礎建設(如電網)的依賴較低,政治態度更偏向自由意志主義或都會進步派。他們可能成為科技業內部新的政治變數,不完全適用傳統的「理組務實派」框架。
---
## 關聯分析
### 關聯一:與台積電赴美論述的連結(提供脈絡層)
- `narratives/us_taiwan/tsmc_us_factory_discourse.md`:科技業是「掏空論」與「擴張論」交鋒的第一線。理解科技人的焦慮,是預測此論述在選舉中發酵程度的基礎。
### 關聯二:與晶片島論述的連結(提供事實層)
- `narratives/us_taiwan/chip_island_discourse.md`:晶片島論述建構了科技業的集體自豪感,但也同時帶來了「被覬覦」的集體焦慮。此檔案解釋了科技業為何在主權議題上呈現「自信與脆弱並存」的矛盾。
### 關聯三:與矽盾概念的連結(提供分析框架)
- `primitives/concepts/silicon_shield.md`:矽盾是科技業政治態度的底層邏輯。當矽盾被相信時,科技人傾向務實避戰;當矽盾被懷疑時,科技人傾向焦慮避險。
### 關聯四:與產業政策論述的連結(用於對照)
- `narratives/domestic/industrial_policy_discourse.md`:科技業對產業政策的評價標準是「是否提升效率與競爭力」,而非「是否照顧弱勢」。對照此檔案可理解科技業為何對綠營的「分配型產業政策」缺乏共鳴。
### 關聯五:與關稅威脅論述的連結(用於交叉驗證)
- `narratives/us_taiwan/tariff_on_taiwan_chips.md`:美國對台晶片課徵關稅的威脅,是觸發科技業「疑美論」的最強催化劑。交叉驗證科技業的反應模式,可評估此論述在 2028 的殺傷力。
### 關聯六:與 TPP 選民基礎的連結(提供脈絡層)
- `entities/parties/tpp_voter_base.md`:科技業高學歷理組男性是 TPP 的核心資產。理解科技業的焦慮,就是理解 TPP 的選票防線。
---
## 常見的分析陷阱
### 陷阱一:將「高薪」等同於「滿意現狀」
這是最致命的錯誤。科技業的高薪伴隨著極高的工時壓力與極高的資產焦慮(高房價)。他們是「高薪的無產者」,對現狀的不滿可能比低薪服務業更強烈,因為他們自認付出了極高的努力卻得不到相應的生活品質保障。
### 陷阱二:假設科技業因「依賴美國技術」而無條件親美
科技業在技術上依賴美國,但在情感與利益上對美國的保護主義充滿怨懟。他們支持台美安全合作,但強烈反對美國的經濟榨取。將科技業視為「天然親美」,會忽略「疑美論」在此群體中的隱性傳播力。
### 陷阱三:忽略「文組」與「理組」的深層斷裂
高學歷選民不是鐵板一塊。高學歷文組(社會科學、法政)傾向理念投票與進步價值,是 DPP 的穩定支持者;高學歷理組(理工、資訊)傾向利益投票與實用主義,正快速流向 TPP。用整體「高學歷支持度」來預測選舉,會掩蓋理組流失的嚴重性。
### 陷阱四:認為「矽盾」讓科技業更有安全感
「矽盾」帶來的是「人質焦慮」,而非安全感。科技人比任何人都清楚台灣半導體的脆弱點與替代可能性。將矽盾論述視為科技業的「定心丸」,是誤判了其心理底層的恐懼。
### 陷阱五:將科技業視為單一產業
半導體、硬體代工、軟體服務的利益與政治傾向差異極大。用台積電的邏輯推論和碩的員工,或用聯發科的邏輯推論 Google 的工程師,都會導致嚴重的預測失準。
### 陷阱六:假設科技業對假訊息完全免疫
科技業對低級假訊息免疫,但對「高級黑函」(用真實數據推導錯誤政治結論的論述)極度脆弱。他們的技術傲慢使他們更容易掉入確認偏誤的陷阱。
---
## 仍不確定的部分
### AI 產業爆發對政治態度的長期影響
AI 產業的興起正在改變科技業的內部結構。軟體與演算法工程師的政治傾向是否會隨著 AI 產業的硬體化(對 GPU 與算力的依賴)而向傳統半導體業的務實派靠攏?還是會因為其全球遠端工作的特性而更加疏離本土政治?目前仍缺乏足夠的歷史數據驗證。
### 美國關稅政策的實質衝擊
若美國在 2028 前真的對台灣晶片課徵高額關稅,科技業的「疑美論」會從隱性焦慮轉為顯性憤怒嗎?還是會因為必須更加依賴美國市場而被迫噤聲?這取決於關稅的具體稅率與實施方式,目前仍為未知數。
### TPP 崩解後科技業選票的流向
若柯文哲司法案件導致 TPP 支持度崩解,科技業的「高薪理組男性」選票會流向哪裡?是基於反綠情緒流向 KMT,還是基於社會自由傾向回流 DPP,或是轉為不投票?此群體缺乏對 KMT 的傳統情感連結,其行為模式在缺乏 TPP 選項時具有高度不確定性。
### 北京對科技業的「精準統戰」效果
北京若從過去的「以商逼政」(針對傳統台商),轉向「精準統戰」(如針對台灣 IC 設計業者開放中國市場誘惑,或針對台灣 AI 人才提供高薪招攬),能否有效分化科技業的政治態度?目前仍不確定台灣科技業在面對中國市場誘惑與美國技術封鎖的雙重擠壓下,會產生更強的「抗中保台」共識,還是更深的「避戰求和」傾向。
---
## 與其他文件的關係
- 台積電赴美論述:`narratives/us_taiwan/tsmc_us_factory_discourse.md`(對照科技業對產業外移的焦慮結構)
- 晶片島論述:`narratives/us_taiwan/chip_island_discourse.md`(對照科技業集體認同的建構基礎)
- 矽盾概念:`primitives/concepts/silicon_shield.md`(提供科技業安全觀與焦慮感的底層邏輯)
- 產業政策論述:`narratives/domestic/industrial_policy_discourse.md`(對照科技業對執政黨資源分配的評價標準)
- 關稅威脅論述:`narratives/us_taiwan/tariff_on_taiwan_chips.md`(評估外部經濟脅迫對科技業投票意向的衝擊)
- TPP 選民基礎:`entities/parties/tpp_voter_base.md`(對照科技業理組男性與 TPP 核心支持者的重疊性)
---
## 一句話濃縮
> 科技業從業人員是台灣的「焦慮精英」——他們撐起了矽盾的驕傲,卻承受著資產焦慮與地緣人質的雙重摺磨;預測他們的政治走向,不是預測他們相信哪種意識形態,而是預測他們在「技術掏空」與「戰爭毀滅」的雙重恐懼中,會將選票作為避險工具投向何方。
---
last_updated: 2024-10-25
Loaded 100 of 642 files, more files were not shown because too many files have changed in this diff. Show more