Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	offline-backend-20260512/backend/app/gateway/deps.py
This commit is contained in:
吉鑫升 committed 2026-05-18 19:35:16 +08:00
commit 449703a6f0
289 files changed
+80131 -4314

No files matched your search

@@ -0,0 +1,645 @@
# AI 写作后端新路线设计草案
## 1. 目标
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
- 不影响现有同事正在开发的功能
- 不强耦合现有 DeerFlow 通用对话主链路
- 可独立部署、独立调试、独立回滚
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
---
## 2. 推荐建设方式
优先建议:
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
建议目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
如果当前后端没有 `apps/` 结构,也可以采用:
```text
offline-backend-20260512/
ai-writing-orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
---
## 3. 技术路线建议
推荐组合:
- 编排框架:`LangGraph`
- 模型调用:接你们内网模型网关
- 检索层:接内网知识库 / 文档库 / 搜索服务
- 状态存储:先用数据库或 Redis + DB,后续再演进
- 事件输出:SSE 或流式 HTTP
推荐原因:
- `LangGraph` 适合状态机和多节点回退
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
- 后续加人工确认节点和 checkpoint 会更自然
---
## 4. 分层设计
建议按四层拆分。
## 4.1 API 层
职责:
- 接收前端请求
- 鉴权
- 参数校验
- 返回 workflow 信息
- 提供事件流出口
建议文件:
```text
api/
routes/
workflows.py
workflow_events.py
schemas/
workflow_request.py
workflow_response.py
```
## 4.2 Application 层
职责:
- 组织用例
- 调度 workflow service
- 管理人工确认动作
- 处理重试和终止逻辑
建议文件:
```text
application/
services/
workflow_service.py
workflow_event_service.py
human_action_service.py
use_cases/
create_workflow.py
start_workflow.py
confirm_outline.py
rewrite_section.py
approve_final.py
```
## 4.3 Domain 层
职责:
- 定义工作流状态机
- 定义 Agent 输入输出协议
- 定义 artifact 与评分模型
建议文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 4.4 Infrastructure 层
职责:
- 访问模型网关
- 访问检索服务
- 访问数据库
- 发送事件
- 缓存与日志
建议文件:
```text
infrastructure/
llm/
model_client.py
retrieval/
intranet_search_client.py
knowledge_base_client.py
persistence/
workflow_repository.py
artifact_repository.py
streaming/
sse_publisher.py
logging/
workflow_logger.py
```
---
## 5. 工作流最小闭环
第一版建议只做如下状态机:
1. `collect_materials`
2. `generate_outline`
3. `await_outline_confirmation`
4. `write_draft`
5. `review_draft`
6. `rewrite_if_needed`
7. `complete`
终止规则:
- 编辑评分达标则完成
- 否则进入重写
- 最多重写 2 次
- 超过次数则标记 `human_intervention_required`
---
## 6. Agent 模块设计
## 6.1 Researcher
输入:
- 主题
- 用户要求
- 检索范围
- 可用资料源
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
程序职责:
- 先做检索
- 再把检索结果裁剪后送给模型
- 输出必须结构化
## 6.2 Outliner
输入:
- 用户要求
- 研究结果
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
约束:
- 提纲优先,不写正文
## 6.3 Writer
输入:
- 已确认提纲
- 素材卡片
- 风格要求
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
建议:
- 按章节分段生成,不要一次长文直出
## 6.4 Editor
输入:
- 初稿
- 提纲
- 素材
- 用户要求
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
规则:
- 先查事实,再查结构,再查文风
## 6.5 Supervisor
输入:
- 各阶段结果
- 重试计数
- 评分结果
输出:
- 下一跳节点
- 是否需要人工确认
- 是否结束
---
## 7. Prompt 组织建议
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
建议目录:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
原则:
- 每个 Agent 一套职责
- 每个 Agent 一套输出 schema
- Prompt 版本可管理
- 后续支持 A/B 调参
---
## 8. 弱模型适配策略
## 8.1 先检索后生成
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
## 8.2 短上下文任务化
每个节点只给弱模型一小段明确任务:
- 研究归纳
- 提纲整理
- 单章节写作
- 单章节审稿
## 8.3 结构化输出
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
## 8.4 章节级重写
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
## 8.5 程序接管质量门槛
例如:
- 资料引用数不足,不进入下一步
- 提纲章节数过少,直接重生
- 编辑评分低于阈值,自动重写
---
## 9. 数据模型建议
## 9.1 Workflow
建议字段:
- `id`
- `status`
- `current_stage`
- `user_id`
- `thread_id`
- `input_payload`
- `current_artifact_id`
- `retry_count`
- `created_at`
- `updated_at`
## 9.2 Workflow Artifact
建议字段:
- `id`
- `workflow_id`
- `artifact_type`
- `stage`
- `version`
- `title`
- `content`
- `metadata`
## 9.3 Workflow Event
建议字段:
- `id`
- `workflow_id`
- `event_type`
- `stage`
- `agent_name`
- `payload`
- `created_at`
## 9.4 Human Action
建议字段:
- `workflow_id`
- `action_type`
- `payload`
- `operator`
- `created_at`
---
## 10. API 草案
## 10.1 创建任务
`POST /api/ai-writing/workflows`
请求:
```json
{
"topic": "主题",
"articleType": "行业分析",
"audience": "管理层",
"tone": "专业克制",
"targetLength": 3000,
"constraints": ["避免口语化"],
"referenceScope": ["内网知识库", "项目复盘文档"]
}
```
返回:
```json
{
"workflowId": "wf_xxx",
"status": "idle"
}
```
## 10.2 启动任务
`POST /api/ai-writing/workflows/{id}/start`
## 10.3 查询任务
`GET /api/ai-writing/workflows/{id}`
## 10.4 订阅事件
`GET /api/ai-writing/workflows/{id}/events`
返回事件流示例:
```json
{
"type": "stage_changed",
"stage": "generating_outline",
"message": "正在生成提纲"
}
```
## 10.5 确认提纲
`POST /api/ai-writing/workflows/{id}/confirm-outline`
## 10.6 指定章节重写
`POST /api/ai-writing/workflows/{id}/rewrite-section`
## 10.7 确认终稿
`POST /api/ai-writing/workflows/{id}/approve-final`
---
## 11. LangGraph 实现建议
## 11.1 推荐 graph 节点
- `load_input`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
## 11.2 推荐 state
建议 state 字段:
- `workflow_id`
- `input_payload`
- `materials`
- `outline`
- `outline_confirmed`
- `draft_sections`
- `merged_draft`
- `review_result`
- `rewrite_targets`
- `final_article`
- `retry_count`
- `events`
## 11.3 条件边
建议条件边逻辑:
- 提纲未确认 -> 等待用户
- 编辑通过 -> 完成
- 编辑不通过且可重试 -> 重写
- 编辑不通过且超限 -> 人工介入
---
## 12. 可观测性要求
第一版就建议带上以下能力:
- 每个 workflow 的事件日志
- 每个 Agent 的输入输出摘要
- 每次重试原因
- 每个阶段耗时
- 每次模型调用 token 与错误日志
后续排问题会非常依赖这些数据。
---
## 13. 测试建议
## 13.1 单元测试
覆盖:
- stage 流转
- retry policy
- quality policy
- 事件构造
## 13.2 集成测试
覆盖:
- 创建任务到完成的主链路
- 提纲确认链路
- 审稿失败后重写链路
- 人工介入链路
## 13.3 Prompt 回归集
建议建立 10-20 个固定写作样例,做回归:
- 短文
- 长文
- 事实密集型文章
- 宣传型文章
- 分析型文章
---
## 14. 开发顺序建议
## Step 1:先做 domain 与 API 契约
先定:
- stage 枚举
- event 枚举
- artifact 结构
- human action payload
## Step 2:先做 fake orchestrator
先不接真实模型,只模拟事件流,把前后端联调跑通。
## Step 3:接 Researcher + Outliner
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
## Step 4:接 Writer + Editor
再接初稿和审稿重写。
## Step 5:接可观测性和缓存
最后再补生产化能力。
---
## 15. 风险控制建议
### 风险 1:直接复用现有线程流,导致协议污染
建议:
- workflow 用独立 route 和独立 event schema
### 风险 2:写作结果与右侧 artifact 模型不兼容
建议:
- 后端统一输出标准 markdown artifact
### 风险 3:弱模型频繁输出不合法 JSON
建议:
- 每个节点加 schema repair
- 加轻量重试
- 尽量减少复杂嵌套输出
### 风险 4:研究资料太多导致上下文爆炸
建议:
- 检索后先裁剪、摘要、分桶
---
## 16. 最终建议
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
建议 MVP 只保留:
1. Researcher
2. Outliner
3. Writer
4. Editor
5. Supervisor
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
@@ -0,0 +1,806 @@
# AI 写作执行方案
## 1. 总体原则
本项目的最终落地原则:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
3. 编排框架采用 `LangGraph`
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
6. 前后端通过 `SSE` 传递工作流事件
7. 第一版只做最小闭环,不做过多花哨扩展
一句话说明:
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
---
## 2. 项目边界
## 2.1 前端边界
继续使用当前页面:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
前端做的事情:
- 左侧增加“写作工作流模式”
- 展示多 Agent 执行进度与中间产物
- 提供提纲确认和指定章节重写
- 将初稿、终稿同步到右侧 artifact
前端不做的事情:
- 不重写右侧编辑器
- 不重写现有普通 chat 流程
- 不把 workflow 状态散落在多个旧 context 里
## 2.2 后端边界
后端采用独立新路线:
推荐目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
后端做的事情:
- 实现写作 workflow 状态机
- 实现 A/B/C Agent 与 Supervisor
- 接内网模型与内网检索
- 输出标准事件流和文章 artifact
后端不做的事情:
- 不直接改现有 DeerFlow 主流程
- 不把工作流逻辑塞到现有通用 thread 协议里
## 2.3 资料搜集边界
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
第一版要求:
- 研究员可以调用互联网搜索能力收集公开资料
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
第二版预留:
- 后续替换或并行接入内网搜索接口
- 后续接入内部知识库、文档库、FAQ、项目复盘库
- 前端和工作流层不因检索源变化而改协议
统一设计要求:
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
- `writing_graph` 只读取标准化后的素材结果
- API、事件流、artifact 结构不区分互联网资料和内网资料
推荐检索抽象:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
```
建议接口:
```python
class RetrievalProvider(Protocol):
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
class RetrievedDocument(BaseModel):
title: str
url: str | None
source_type: str
snippet: str
published_at: str | None = None
fetched_at: str
reliability: str | None = None
```
第一版路由策略:
- 默认 `internet`
- 配置项预留 `intranet`、`hybrid`
例如:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
```
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
---
## 3. 第一版产品目标
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
1. 输入主题、体裁、读者、风格、篇幅
2. 研究员搜集资料并输出素材卡片
3. 提纲 Agent 生成提纲
4. 用户确认提纲
5. Writer 生成初稿
6. Editor 给出问题清单与评分
7. 不达标则重写一次或两次
8. 终稿写入右侧 artifact
第一版成功标准:
- 不是“生成得像最强模型一样好”
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
## 3.1 第一版研究员目标
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
第一版输入:
- 用户主题
- 用户写作要求
- 检索源配置,第一版默认 `internet`
第一版输出至少包含:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
每条素材最少要带:
- 来源标题
- 来源 URL
- 摘要
- 可信度标签
- 被引用的原始片段或整理后的事实点
第一版 Researcher 的程序流程建议:
1. 根据主题自动生成 3-5 个检索 query
2. 调用互联网检索接口拿回候选文档
3. 做去重、裁剪、初筛
4. 让模型将结果整理成结构化素材卡片
5. 将结构化结果返回给 Outliner 和 Writer
第一版先不做:
- 深度网页 crawling
- 多轮自动追问式深研究
- 内网知识库接入
- 复杂引用格式生成
---
## 4. 最终工作流
## 4.1 Agent 角色
### `Researcher`
职责:
- 从配置的检索源检索信息
- 产出结构化素材卡片
- 标注来源、可信度、风险点
第一版说明:
- 默认走互联网公开资料检索
- 必须预留后续切换到内网检索源的接口
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
第一版检索源字段建议:
- `source_type = "internet"`
- 后续预留 `source_type = "intranet" | "hybrid"`
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
### `Outliner`
职责:
- 根据用户要求和研究结果生成提纲
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
### `Writer`
职责:
- 基于提纲和素材写初稿
- 按章节写,不一次生成整篇长文
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
### `Editor`
职责:
- 检查事实、结构、文风
- 给出评分与重写建议
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
### `Supervisor`
职责:
- 负责流程调度
- 负责通过 / 重写 / 人工介入判断
## 4.2 状态机
第一版统一采用:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
## 4.3 流程闭环
最小闭环固定为:
1. `Research`
2. `Outline`
3. `HumanConfirmOutline`
4. `Draft`
5. `Review`
6. `RewriteIfNeeded`
7. `Complete`
重试规则:
- 最多重写 2 次
- 达标则完成
- 超限则进入人工介入
## 4.4 LangGraph 推荐节点
第一版推荐节点:
- `load_input`
- `build_search_queries`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
其中和资料搜集直接相关的节点说明:
### `build_search_queries`
职责:
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
### `retrieve_materials`
职责:
- 调用检索 provider 获取候选文档
- 第一版默认走 `internet_search_client`
- 后续可切到 `intranet_search_client`
### `normalize_materials`
职责:
- 做去重
- 做摘要裁剪
- 做来源字段统一
- 输出标准 `RetrievedDocument[]`
---
## 5. 前端最终做法
## 5.1 目录建议
在现有 `open-canvas` 下新增:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
CanvasWorkflowPanel.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
```
## 5.2 左侧改造策略
`CanvasChatPanel` 只承担模式切换与容器职责:
- `chat` 模式:沿用现有普通聊天
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
新面板负责:
- 写作任务表单
- 进度时间线
- 事件列表
- 中间产物抽屉
- 提纲确认卡片
## 5.3 右侧衔接策略
继续复用现有 `CanvasArtifactPanel`。
同步规则:
- 提纲:可选同步到右侧预览
- 初稿:同步到右侧
- 重写稿:覆盖当前草稿
- 终稿:作为最终 artifact
## 5.4 前端状态设计
新增:
- `workflowMode`
- `workflowRunId`
- `workflowStage`
- `workflowStatus`
- `workflowEvents`
- `workflowArtifacts`
- `workflowPendingAction`
注意:
- workflow 状态放进新的 `WritingWorkflowContext`
- 不塞进现有 `CanvasGraphContext`
## 5.5 前端显示的资料信息
第一版左侧至少要能展示研究员产出的资料摘要:
- 来源标题
- 来源域名或 URL
- 资料摘要
- 可信度标签
- 是否被提纲或正文引用
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
注意:
- 第一版显示公开互联网来源即可
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
---
## 6. 后端最终做法
## 6.1 分层
推荐分层:
```text
api/
application/
domain/
infrastructure/
prompts/
tests/
```
## 6.2 核心模块
建议核心文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 6.3 检索层实现要求
第一版检索层必须独立成基础设施模块,不允许:
- 在 Agent prompt 中直接拼接“去网上找资料”
- 在 graph 节点中散落请求第三方搜索的代码
- 把互联网检索和内网检索混在一个无抽象的函数里
建议结构:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
material_normalizer.py
```
建议职责:
- `internet_search_client.py`
- 第一版实际使用
- 对接互联网搜索 API 或爬取网关
- `intranet_search_client.py`
- 第一版只保留接口和空实现或 mock
- 后续接企业内部接口
- `retriever_router.py`
- 根据配置选择 provider
- `material_normalizer.py`
- 统一清洗和标准化结果
## 6.4 Prompt 管理
统一独立存放:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
## 6.5 输出约束
每个 Agent 都必须:
- 优先输出 JSON / Pydantic schema
- 失败时允许轻量 repair 和一次重试
- 避免自由散文式输出
## 6.6 Researcher 的实现细则
Researcher 推荐由两部分组成:
1. 程序化检索
2. 模型化整理
推荐伪流程:
```python
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
queries = build_queries(topic, article_type)
raw_docs = []
for query in queries:
raw_docs.extend(provider.search(query=query, top_k=5))
docs = normalize_materials(raw_docs)
docs = dedupe_materials(docs)
docs = rank_materials(docs)
return llm_extract_material_cards(docs)
```
第一版建议:
- 先把 query 生成逻辑做简单
- `top_k` 先控制在 5-10
- 先做轻量去重和摘要裁剪
- 不做很重的网页全文抓取
---
## 7. 前后端协议
## 7.1 API
第一版统一提供:
- `POST /api/ai-writing/workflows`
- `POST /api/ai-writing/workflows/{id}/start`
- `GET /api/ai-writing/workflows/{id}`
- `GET /api/ai-writing/workflows/{id}/events`
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- `POST /api/ai-writing/workflows/{id}/approve-final`
## 7.2 SSE 事件
统一事件类型:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
## 7.3 配置项补充
建议增加以下配置项:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
enable_content_fetch: false
workflow:
max_revisions: 2
models:
researcher: your-default-model
outliner: your-default-model
writer: your-default-model
editor: your-default-model
```
说明:
- 第一版 `provider=internet`
- 第二版可切成 `intranet` 或 `hybrid`
---
## 8. 必须做 / 延后做 / 不建议现在做
## 8.1 必须做
### 架构
- 独立后端路线 `ai-writing-orchestrator`
- 前端独立 `workflow/` 目录
- LangGraph 状态机
- `Researcher / Outliner / Writer / Editor / Supervisor`
- 独立检索抽象层,第一版先落互联网 provider
### 协议
- 标准 workflow 状态枚举
- 标准 SSE 事件枚举
- 标准 artifact 结构
- JSON / Pydantic 输出约束
### 功能
- 启动写作任务
- 研究阶段
- 提纲阶段
- 提纲确认
- 初稿生成
- 审稿与最多 2 次重写
- 终稿同步到右侧
- 素材卡片展示
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
### UI
- 模式切换
- 时间线
- 事件列表
- 提纲确认卡片
- 右侧终稿显示
- 左侧素材卡片视图
## 8.2 延后做
- 章节级精准重写
- 初稿多版本树
- FactChecker
- StyleEditor
- ComplianceReviewer
- 自动评分看板
- 素材引用可视化高亮
- 更复杂的人机协作节点
- 内网知识库正式接入
- 混合检索 `hybrid`
- 网页全文抓取与深摘要
## 8.3 不建议现在做
- 直接接入现有 DeerFlow 主 agent 包
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
- 一开始就做 5 个以上 Agent
- 一开始就做很复杂的文章版本管理
- 一开始就把评分阈值和 prompt 写死得非常细
## 9. 最终开发顺序
## Step 1:先定契约
先一起拍板:
- stage 枚举
- event 枚举
- artifact 结构
- confirm / rewrite 的 payload
- retrieval provider 接口
- RetrievedDocument 结构
## Step 2:先做 fake workflow
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
## Step 3:先接互联网版 Researcher + Outliner
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
这一阶段要求:
- `Researcher` 先走互联网检索
- `retriever_router` 可配置
- `intranet_search_client` 先留接口空壳
## Step 4:接 Writer + Editor
再补初稿与审稿重写闭环。
## Step 5:接右侧 artifact
把初稿和终稿稳定写入右侧。
## Step 6:补生产化
- 缓存
- 日志
- 错误回放
- 配置化 prompt
## 10. 推荐任务拆分
## 前端
- `CanvasChatPanel` 模式切换
- `workflow/` 目录与类型
- 时间线 / 事件列表 / 确认卡片
- SSE 订阅 hook
- 右侧 artifact 映射
- 素材卡片列表展示
- 来源字段展示规范
## 后端
- workflow API
- LangGraph graph
- 5 个 Agent 模块
- Prompt 目录
- SSE 输出
- 仓储与日志
- `RetrievalProvider` 抽象
- `internet_search_client`
- `intranet_search_client` 预留接口
- `material_normalizer`
## Prompt / 算法
- Researcher prompt
- Outliner prompt
- Writer prompt
- Editor prompt
- JSON schema
- 失败样本回归
## 联调
- mock SSE
- 真模型接入
- artifact 同步校验
- 提纲确认链路验证
- 互联网资料来源字段联调
- 素材卡片到提纲引用链路联调
---
## 11. 开发过程中的约束
开发过程中统一采用下面这套口径:
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
- 技术底座:`LangGraph`
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
- 后端方式:独立 `ai-writing-orchestrator`
- 检索方式:第一版 `internet`,接口预留 `intranet`
- 通信方式:`SSE`
- 输出方式:`JSON Schema / Pydantic`
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
给开发团队的统一口径:
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
@@ -0,0 +1,472 @@
# AI 写作前端改造清单
## 1. 目标
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
当前核心文件:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
---
## 2. 改造原则
- 不改右侧主编辑器交互心智
- 不复制新页面
- 不破坏现有普通 chat 模式
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
- 所有 workflow 能力都应允许逐步灰度
---
## 3. 推荐前端结构
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
utils/
workflow-event.ts
```
说明:
- `open-canvas` 原有聊天、artifact、editor 目录不重构
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
---
## 4. 状态设计
## 4.1 新增的核心状态
建议新增:
- `workflowMode: "chat" | "writing_pipeline"`
- `workflowRunId: string | null`
- `workflowStage`
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
- `workflowEvents: WritingWorkflowEvent[]`
- `workflowArtifacts: WritingWorkflowArtifact[]`
- `workflowPendingAction: WritingPendingAction | null`
## 4.2 推荐类型定义
建议在 `workflow/types/workflow.ts` 中先定义:
```ts
export type WritingWorkflowStage =
| "idle"
| "collecting_materials"
| "generating_outline"
| "await_outline_confirmation"
| "writing_draft"
| "reviewing_draft"
| "rewriting_draft"
| "completed"
| "failed";
export type WritingWorkflowEventType =
| "workflow_started"
| "stage_changed"
| "agent_started"
| "agent_output_chunk"
| "artifact_generated"
| "human_confirmation_required"
| "retry_scheduled"
| "workflow_completed"
| "workflow_failed";
```
第一版把类型先收紧,后续再扩展。
---
## 5. 组件拆分建议
## 5.1 左侧主面板改造
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
建议改造为:
- `chat` 模式继续渲染现有消息列表 + 输入框
- `writing_pipeline` 模式渲染新的工作流视图
建议拆分出:
- `CanvasChatConversation.tsx`
- `CanvasChatInputBox.tsx`
- `CanvasWorkflowPanel.tsx`
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
## 5.2 新增组件职责
### `WritingWorkflowEntryForm.tsx`
负责采集:
- 主题
- 文章类型
- 目标读者
- 语气风格
- 目标篇幅
- 是否需要引用资料
- 禁写项
- 补充说明
### `WritingWorkflowTimeline.tsx`
负责显示阶段:
- 研究中
- 提纲生成中
- 等待确认提纲
- 正在写初稿
- 正在审稿
- 正在重写
- 已完成
### `WritingWorkflowEventList.tsx`
负责显示细粒度事件:
- 哪个 Agent 开始执行
- 当前在处理哪一步
- 当前产生了什么中间结果
- 是否重试
### `WritingWorkflowArtifactDrawer.tsx`
负责展示中间产物:
- 素材卡片
- 提纲
- 初稿
- 编辑意见
- 重写稿
### `WritingWorkflowConfirmCard.tsx`
负责展示用户确认点:
- 确认提纲
- 驳回提纲并附带修改意见
- 继续写稿
- 指定重写章节
---
## 6. Context 与 Hook 改造建议
## 6.1 `CanvasGraphContext`
当前它只维护:
- `messages`
- `artifact`
- `streamingArtifact`
- `isStreaming`
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
推荐做法:
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
- 新增 `WritingWorkflowContext`
## 6.2 `WritingWorkflowContext`
建议承载:
- 当前 workflow 运行状态
- 中间产物列表
- 待确认操作
- workflow 事件缓存
- 启动 / 取消 / 确认 / 重写等 action
## 6.3 `useCanvasThreadStream`
当前这个 hook 已经负责:
- 创建 thread
- 提交消息
- 接收 thread stream
- 将 `update_artifact` 同步到右侧
建议不要把“多 Agent workflow 状态机”也硬塞进去。
推荐方案:
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
- 新增 `useWritingWorkflow`
- 新增 `useWritingWorkflowEvents`
分工如下:
- `useCanvasThreadStream`:普通对话流
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
- `useWritingWorkflowEvents`:订阅 workflow 事件流
---
## 7. API 接入建议
## 7.1 前端 API 文件
新增:
- `workflow/api/writing-workflow.ts`
建议暴露方法:
- `createWorkflow(payload)`
- `startWorkflow(id)`
- `getWorkflow(id)`
- `subscribeWorkflowEvents(id, handlers)`
- `confirmOutline(id, payload)`
- `rewriteSection(id, payload)`
- `approveFinal(id)`
## 7.2 事件流形式
优先级建议:
1. `SSE`
2. 流式 HTTP chunk
3. 轮询兜底
理由:
- 事件驱动比整段消息流更适合 workflow UI
- 可以更方便映射到时间线和事件列表
---
## 8. 与右侧 Artifact 的衔接
## 8.1 现有优势
当前 `CanvasArtifactPanel` 已具备:
- 流式展示内容
- 最终 artifact 渲染
- 右侧编辑体验成熟
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
## 8.2 建议接法
工作流各阶段结果按两类处理:
- 中间产物:进入左侧 `workflowArtifacts`
- 可阅读正文:同步一份到右侧 `artifact`
推荐映射:
- 提纲:可选同步到右侧,供用户预览
- 初稿:同步到右侧
- 重写稿:覆盖或新增版本到右侧
- 终稿:作为最终右侧主版本
## 8.3 版本策略
建议右侧版本保留:
- `outline`
- `draft_v1`
- `draft_v2`
- `final`
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
---
## 9. 页面交互流程
## 9.1 用户首次进入
- 默认仍是现有 chat 模式
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
## 9.2 启动写作工作流
- 用户填写写作任务表单
- 点击“开始生成”
- 左侧切换到时间线 + 事件流视图
- 右侧进入 loading 或素材 / 提纲预览态
## 9.3 等待提纲确认
- 时间线停在 `await_outline_confirmation`
- 左侧出现确认卡片
- 用户可执行:
- 通过提纲
- 要求调整提纲
- 追加要求
## 9.4 生成初稿与审稿
- 左侧显示 Agent 事件
- 右侧展示初稿内容
- 审稿意见在左侧展示,同时高亮需要重写的章节
## 9.5 完成
- 左侧显示“终稿已完成”
- 右侧进入可继续局部 AI 编辑状态
---
## 10. 分步开发清单
## Step 1:类型与假数据
目标:
- 先把 workflow 的类型、mock 数据、组件骨架搭好
任务:
- 新建 `workflow/types/workflow.ts`
- 新建 `workflow/components/*`
- 用本地 mock 事件驱动时间线和事件流
验收:
- 不接后端也能在页面中演示完整流程
## Step 2:模式切换
目标:
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
任务:
- 拆出 `CanvasWorkflowPanel`
- 增加模式切换 UI
- 保证旧 chat 不回归
验收:
- 现有功能不受影响
- 新模式可进入表单页
## Step 3:接入 workflow API
目标:
- 从 mock 切到真实 API
任务:
- 接 `create/start/get/events`
- 实现基础错误态和重试态
验收:
- 前后端可联调跑一条主链路
## Step 4:接入确认节点
目标:
- 支持提纲确认和指定章节重写
任务:
- 做确认卡片
- 做动作回传
验收:
- 用户能在流程中介入
## Step 5:打通右侧版本
目标:
- 让 workflow 产物稳定进入右侧编辑器
任务:
- 约定 artifact 映射
- 接入版本切换或当前版本覆盖
验收:
- 终稿稳定显示在右侧
---
## 11. 建议修改点位
优先涉及文件:
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
建议新增,不建议重写原有:
- `open-canvas/workflow/api/writing-workflow.ts`
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
- `open-canvas/workflow/components/*`
---
## 12. 联调约定
前后端需要尽早统一:
- workflow id 命名
- stage 枚举
- event type 枚举
- artifact type 枚举
- user action payload
- 错误码与可恢复动作
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
---
## 13. 最终建议
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
第一版建议先保证三件事:
1. 左侧能看见阶段进展
2. 用户能确认提纲
3. 右侧能稳定收到初稿和终稿
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
@@ -0,0 +1,718 @@
# AI 写作弱模型多 Agent 编排需求文档
## 1. 背景与目标
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
- 结构松散,长文容易跑题
- 素材不足,内容空泛
- 事实依据弱,容易幻觉
- 润色能力有限,语言风格不统一
- 一次生成失败后缺少可控的纠偏机制
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
建议的角色分工:
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
- `Agent B 作家`:基于选题、素材和结构写初稿
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
---
## 2. 本次建设边界
### 2.1 前端边界
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
当前相关入口:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
前端本期只做以下增强:
- 在左侧聊天区增加“写作工作流模式”
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
### 2.2 后端边界
后端必须单开新路线,不影响其他同事当前功能开发。
建议原则:
- 不直接改现有 DeerFlow 主流程
- 不把多 Agent 逻辑硬塞进当前通用对话链路
- 以独立服务或独立模块形式建设
- 通过清晰 API 与当前前端页面对接
推荐落地位置:
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
---
## 3. 为什么多 Agent 对弱模型更合适
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
- 选题理解
- 信息检索
- 信息筛选
- 结构规划
- 长文写作
- 自我校验
- 风格润色
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
- 降低单次上下文复杂度
- 降低一次性长输出失败率
- 把“写得不好”变成“某一步做得不好”,便于重试
- 可以在关键节点插入规则校验与人工确认
- 可以用程序补足模型短板,而不是完全依赖模型智力
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
---
## 4. 可借鉴的开源项目
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
### 4.1 第一优先级:LangGraph
推荐原因:
- 天然适合把写作流程建模成状态图
- 支持多步骤、条件分支、重试、人工介入
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
- 对“弱模型 + 强流程控制”很友好
可借鉴点:
- `StateGraph` 的状态驱动编排
- 每一步单独定义输入、输出、失败处理
- 可视化地表达“通过 / 打回 / 重试 / 终止”
参考:
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
### 4.2 第二优先级:STORM
推荐原因:
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
- 对“先研究后写作”的思路非常契合
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
这和我们的目标高度一致,尤其适合借鉴:
- 提纲先行
- 素材引用先行
- 分模块写作
- 后置润色
参考:
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
### 4.3 第三优先级:Deep Research Agent
推荐原因:
- 明确支持本地模型与云模型混用
- 已经包含研究、写作、质量校验、缓存、重试等能力
- 很适合借鉴“研究阶段如何约束弱模型”
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
特别适合借鉴:
- 素材缓存
- 分阶段质量评分
- 节点失败重试
- 研究结果结构化存储
参考:
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
### 4.4 可选借鉴:CrewAI
推荐原因:
- 角色扮演表达自然
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
- Flow + Crew 的组织方式适合做 demo 和快速验证
不建议作为首选底座的原因:
- 更偏“Agent 角色编排体验”
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
适合借鉴:
- 角色定义写法
- Task 模板
- 多角色协作 prompt 结构
参考:
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
### 4.5 不建议新项目优先采用:AutoGen
原因:
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
- 官方建议新项目优先采用 `Microsoft Agent Framework`
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
参考:
- [microsoft/autogen](https://github.com/microsoft/autogen)
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
### 4.6 结论
综合本项目需求,建议技术选型优先级如下:
1. `LangGraph` 作为主编排框架
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
---
## 5. 目标产品形态
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
理想交互流程:
1. 用户输入主题、用途、风格、读者、篇幅要求
2. 系统先进入“研究阶段”
3. 系统给出资料卡片与候选提纲
4. 用户确认提纲或调整
5. 系统生成初稿
6. 严苛编辑给出问题清单
7. 系统自动重写问题段落
8. 系统输出终稿到右侧编辑区
9. 用户可继续局部改写、扩写、精简
核心体验目标:
- 左侧像“编辑部工作台”
- 右侧像“成文编辑器”
- 用户知道系统现在进行到哪一步
- 用户可以在关键节点接管,而不是只能等待最终结果
---
## 6. 多 Agent 流程设计
## 6.1 建议的最小可用流程 MVP
第一版不要一上来做很复杂,建议先做 4 步闭环:
1. `Research`
- 输入:主题、用户要求、内网资料源
- 输出:素材卡片列表、可引用事实列表、风险点列表
2. `Outline`
- 输入:用户要求 + 素材卡片
- 输出:结构化提纲
3. `Draft`
- 输入:提纲 + 素材卡片
- 输出:初稿
4. `ReviewAndRewrite`
- 输入:初稿 + 用户要求 + 素材卡片
- 输出:问题清单、改写稿、终稿
MVP 终止条件:
- 编辑评分达标则结束
- 不达标最多重写 2 次
- 超过次数则返回“需要人工介入”
## 6.2 第二阶段增强流程
在 MVP 跑通后,再增加:
- `Planner`:根据文章类型动态规划流程
- `SectionWriter`:按章节分别写作,避免长文崩坏
- `FactChecker`:逐段核对素材来源
- `StyleEditor`:统一语气、标题风格、句式节奏
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
## 6.3 建议的状态机
建议将流程状态固定为:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
这样前端和后端都可以围绕统一状态通信。
---
## 7. Agent 角色定义建议
## 7.1 Agent A 研究员
职责:
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
- 输出结构化素材,而不是长篇自然语言
- 对每条素材标注来源、可信度、适用角度
输出格式建议:
- `facts[]`
- `quotes[]`
- `examples[]`
- `risks[]`
- `source_refs[]`
约束建议:
- 禁止直接生成整篇文章
- 不做风格化表达
- 每条结论必须可回溯到来源
## 7.2 Agent B 作家
职责:
- 严格基于提纲和素材写稿
- 按章节写,而不是一次写完整篇
- 对每段附带素材引用 ID,便于后续审核
约束建议:
- 无素材不发挥
- 每段只服务一个小节目标
- 段落长度控制
- 输出结构化草稿数据,而不仅是纯文本
## 7.3 Agent C 严苛编辑
职责:
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
- 输出明确的“问题清单”和“改写指令”
- 必要时打回指定章节,而不是整篇推翻
输出格式建议:
- `issues[]`
- `severity`
- `rewrite_instructions[]`
- `accept/reject`
编辑原则建议:
- 优先挑事实问题,再挑结构问题,最后才是文风问题
- 问题描述必须可执行,不能只说“写得不够好”
## 7.4 Supervisor
职责:
- 负责流程启动、状态流转、失败重试、记录日志
- 判断是否需要回到上一步
- 控制最大重试次数
- 对外提供统一 API 与流式事件
---
## 8. 前端需求
## 8.1 保持当前页面结构不变
继续沿用当前左右布局:
- 左侧:聊天 + 流程进度 + 中间产物
- 右侧:最终文章编辑与局部 AI 改写
不建议再复制一套新页面,以免造成维护分叉。
## 8.2 左侧新增 UI 模块
建议在 `CanvasChatPanel` 基础上新增:
- 写作任务表单
- 工作流阶段时间线
- Agent 执行日志面板
- 中间产物抽屉
- 人工确认卡片
建议新增的前端概念:
- `workflowMode`: `chat | writing_pipeline`
- `workflowStage`
- `workflowRunId`
- `workflowEvents[]`
- `workflowArtifacts[]`
## 8.3 需要展示的中间产物
至少支持展示:
- 素材卡片
- 提纲
- 初稿
- 编辑问题单
- 重写结果
- 最终定稿
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
## 8.4 人工介入节点
至少提供两个确认点:
- 提纲确认
- 初稿确认或编辑后重跑
后续可扩展:
- 指定重写某个章节
- 锁定某段不允许再改
- 替换某条素材后重新生成
---
## 9. 后端需求
## 9.1 架构原则
后端新路线必须满足:
- 与现有通用对话链路解耦
- 与现有 Agent 运行时解耦
- 独立维护 Prompt、状态机、数据结构
- 可以单独部署、单独回滚、单独压测
## 9.2 推荐模块划分
建议后端拆分为以下模块:
- `workflow_orchestrator`
- `agent_registry`
- `material_retriever`
- `outline_generator`
- `draft_writer`
- `editor_reviewer`
- `rewrite_engine`
- `workflow_store`
- `stream_event_adapter`
## 9.3 API 建议
建议最小 API:
- `POST /api/ai-writing/workflows`
- 创建写作任务
- `POST /api/ai-writing/workflows/{id}/start`
- 启动流程
- `GET /api/ai-writing/workflows/{id}`
- 查询当前状态
- `GET /api/ai-writing/workflows/{id}/events`
- 获取事件流或轮询事件
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- 用户确认提纲
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- 指定章节重写
- `POST /api/ai-writing/workflows/{id}/approve-final`
- 确认终稿
## 9.4 流式事件建议
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
前端只要订阅事件,就能把左侧工作台做出来。
---
## 10. 弱模型优化策略
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
## 10.1 强约束输出格式
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
原因:
- 便于程序消费
- 便于后续 Agent 接续
- 便于失败重试
- 便于质量评估
## 10.2 长文拆成章节生成
不要让弱模型一次生成整篇长文,建议:
- 先出提纲
- 再按章节逐段写
- 最后统一润色
## 10.3 把检索和写作分离
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
## 10.4 程序兜底而不是模型兜底
程序需要承担:
- 去重
- 引用格式化
- 章节拼接
- 长度控制
- 评分阈值判断
- 重试路由
## 10.5 小步快跑、失败可回退
每步都要能独立失败、独立重试、独立回放。
## 10.6 引入可量化评分
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
- 相关性
- 事实性
- 结构完整度
- 语言流畅度
- 风格一致性
系统据此决定是否进入下一步。
---
## 11. 分阶段实施计划
## Phase 1:需求冻结与技术验证
目标:
- 确认文章类型范围
- 确认内网资料来源
- 选定主框架
- 跑通最小多 Agent demo
交付物:
- 技术选型说明
- 角色与状态机设计
- 1 个主题的端到端 demo
验收标准:
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
## Phase 2:后端新路线搭建
目标:
- 建立独立 orchestrator
- 建立 workflow state store
- 建立事件流输出
- 建立可配置 prompt 模板
交付物:
- 独立服务目录
- 基础 API
- 状态机运行骨架
验收标准:
- 不依赖现有主流程改造即可独立跑通
## Phase 3:前端接入当前页面
目标:
- 在 `CanvasChatPanel` 接入工作流模式
- 增加阶段展示与人工确认
- 中间产物可视化
交付物:
- 左侧工作流时间线
- 中间产物展示面板
- 流程控制按钮
验收标准:
- 用户可以在当前页面完成一次全流程写作
## Phase 4:质量优化
目标:
- 强化素材检索质量
- 提高提纲质量
- 引入章节级重写
- 引入评分与回退机制
交付物:
- 质量评估规则
- 重试策略
- Prompt 调优包
验收标准:
- 弱模型效果明显优于单轮直出
## Phase 5:生产化
目标:
- 加缓存
- 加日志
- 加监控
- 加失败告警
- 加审计记录
交付物:
- 可观测性方案
- 运行日志与回放
- 配置中心或 prompt 管理机制
验收标准:
- 可长期维护并可定位问题
---
## 12. 团队协作与隔离方案
为了不影响其他同事,建议采用以下协作原则:
- 新后端目录独立开发
- 新接口使用独立路由前缀
- 前端只在 `open-canvas` 内增量开发
- 不修改现有公共线程协议,必要时增加新事件类型
- Prompt、Agent 配置、评分规则独立存放
推荐分工:
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
---
## 13. 风险与规避
### 风险 1:Agent 太多,成本和耗时暴涨
规避:
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
- 长文按章节写,失败只重写局部
### 风险 2:弱模型在自由对话场景中失控
规避:
- 全流程结构化
- 每步只做一件事
- 强约束输入输出格式
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
规避:
- 先把资料源治理好
- 对素材做来源、可信度、去重处理
### 风险 4:前后端协议频繁变化影响联调
规避:
- 先定义事件模型和 artifact 模型
- 先 mock 数据,再并行开发
### 风险 5:影响现有功能稳定性
规避:
- 新路线、新接口、新状态机
- 不侵入现有主链路
---
## 14. 推荐结论
本项目推荐的总体策略:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
3. 主框架优先采用 `LangGraph`
4. 流程设计借鉴 `STORM`
5. 质量控制借鉴 `Deep Research Agent`
6. 角色设计与任务模板可参考 `CrewAI`
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
一句话总结:
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
---
## 15. 参考链接
- [LangGraph](https://github.com/langchain-ai/langgraph)
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
- [STORM](https://github.com/stanford-oval/storm)
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
- [CrewAI](https://github.com/crewAIInc/crewAI)
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
- [AutoGen](https://github.com/microsoft/autogen)
File diff suppressed because it is too large. Load diff
+256 -204
View File
@@ -1,260 +1,312 @@
# Open Canvas 集成进度文档
> 更新时间:2026-05-16
> 最后更新:2026-05-16
> 当前分支:dhk-1
---
## 一、整体进度概览
### Canvas 系统(双栏写作/编码工作区)
| 阶段 | 名称 | 状态 |
|------|------|------|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 前端阶段四 | API 适配层 | ✅ 已完成 |
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
| 阶段七 | 版本历史导航 | ✅ 已完成 |
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
**当前状态**:全部 9 个阶段已完成 🎉
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
| 步骤 | 名称 | 状态 |
|------|------|------|
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
**当前状态:全部功能已完成 🎉**
---
## 二、已完成工作详情
### 2.1 前端路由接入
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
```tsx
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 在 PageLayout 内部添加:
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
```
子路由:
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
### 2.2 前端目录结构(已建立,26 个文件)
### 2.1 前端核心文件结构
```
frontend-web/src/open-canvas/
api/
client.ts # stub,Phase 5 实现
models.ts # stub,Phase 5 实现
threads.ts # stub,Phase 5 实现
runs.ts # stub,Phase 5 实现
components/
layout/
CanvasShell.tsx # 顶层布局壳,flex-col
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
chat/
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
artifact/
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
contexts/
CanvasThreadContext.tsx # threadId、modelName
CanvasGraphContext.tsx # messages、artifact、isStreaming
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
hooks/
useCanvasStream.ts # stub,Phase 5 实现
useCanvasArtifact.ts # stub,Phase 6 实现
useCanvasSelection.ts # stub,Phase 7 实现
useCanvasQuickActions.ts # stub,Phase 8 实现
pages/
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
routes/
OpenCanvasRoutes.tsx # React Router 子路由
types/
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
selection.ts # CanvasTextSelection、CanvasCodeSelection
quick-action.ts # QuickAction、QuickActionId
utils/
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
selection.ts # stub
thread.ts # stub
stream.ts # stub
quick-actions.ts # stub
constants/
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
├── api/
│ ├── client.ts # LangGraph SDK 客户端实例
│ ├── models.ts # 模型列表 API
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
│ └── threads.ts # createCanvasThread、getCanvasThread
├── components/
│ ├── artifact/
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
│ ├── chat/
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
│ ├── layout/
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
│ └── selectors/
│ └── CanvasModelSelector.tsx # 模型下拉选择器
├── contexts/
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
│ ├── CanvasThreadContext.tsx # threadId、modelName
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
├── editor/
│ ├── TiptapEditor.tsx # EditorContent 包装器
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
├── hooks/
│ ├── useCanvasArtifact.ts # artifact 初始化工具
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
│ ├── useCanvasSelection.ts # 文字/代码选区状态
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
│ ├── useCanvasThread.ts # 线程加载工具
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
├── pages/
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
│ └── CanvasThreadPage.tsx # Provider 装配层
├── routes/
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
├── types/
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
│ ├── model.ts # CanvasModel
│ ├── quick-action.ts # QuickAction、QuickActionId
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
│ └── thread.ts # CanvasMessage、CanvasThreadValues
└── utils/
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
├── selection.ts # buildMessageWithSelection
├── stream.ts # 流式工具
└── thread.ts # 线程工具
```
### 2.3 核心类型定义
### 2.2 后端核心文件结构
```ts
// types/artifact.ts
export type CanvasArtifact = {
currentIndex: number; // 从 1 开始
contents: CanvasArtifactContent[];
};
export type CanvasArtifactText = {
index: number; type: "text"; title: string; fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number; type: "code"; title: string;
language: ProgrammingLanguageOptions; code: string;
};
```
offline-backend-20260512/backend/
├── packages/harness/deerflow/agents/
│ ├── canvas_agent/
│ │ ├── __init__.py
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
└── app/gateway/
├── app.py # 注册 writing router
└── routers/
└── writing.py # POST /api/writing/rewrite(SSE 流式)
```
### 2.4 Context 结构
**langgraph.json** 注册了两个 graph:
- `lead_agent` → 主聊天 Agent
- `canvas_agent` → Canvas 写作 Agent
```tsx
// CanvasThreadPage.tsx 的装配顺序
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
### 2.3 关键 Context 层级
```
CanvasThreadPage
├── CanvasThreadProvider # threadId、modelName
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
│ │ └── CanvasShell
│ │ └── CanvasResizableLayout
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
│ │ └── CanvasArtifactPanel
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
```
---
## 三、已解决的技术难题
## 三、关键架构决策与注意事项
### 3.1 react-resizable-panels v4.6.2 API 差异
### 3.1 流式架构:单例流(重要)
此版本与旧版 API 不同,必须使用:
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
```tsx
// ✅ 正确写法
import type { GroupImperativeHandle } from "react-resizable-panels";
const layoutRef = useRef<GroupImperativeHandle>(null);
<ResizablePanelGroup
orientation="horizontal" // ← 不是 direction
groupRef={layoutRef} // ← 不是 ref
>
<ResizablePanel id="chat" ... /> // ← 必须有 id
<ResizablePanel id="artifact" ... /> // ← 必须有 id
</ResizablePanelGroup>
// 切换折叠
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
```
### 3.2 Flex 高度链(防止底部内容被裁剪)
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
```
PageLayout(h-screen)
└── main(flex-1)
└── CanvasShell div(h-full flex flex-col)
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
└── ResizablePanelGroup(h-full)
└── 面板内容(h-full)
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
```
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
### 3.2 artifact 内容注入机制(重要)
### 3.3 折叠/展开按钮
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
```tsx
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
);
}, [isChatCollapsed]);
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
```typescript
// utils/quick-actions.ts
export function buildQuickActionMessage(id, content) {
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
}
// core/messages/utils.ts
export function stripArtifactTag(content: string): string {
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
}
```
### 3.3 ThinkTagParser(AI 改写思考过程解析)
后端支持两种思考格式:
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
### 3.4 版本历史:本地切换
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
### 3.5 Writing Editor 流程(三阶段状态机)
```
idle → menu → preview → idle
↑ ↓
openAiMenu closeAll
```
- **idle**:正常编辑
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
---
## 四、下一步:后端 ThreadState 扩展
## 四、已解决的技术难题
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
```python
# 新增 TypedDict(建议单独定义)
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
# 在 ThreadState 中新增字段
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
highlighted_text: Optional[str] # 用户选中的文本片段
highlighted_code: Optional[str] # 用户选中的代码片段
canvas_mode: Optional[Literal["text", "code"]]
quick_action_id: Optional[str] # 快捷动作 id
web_search_enabled: bool # 默认 False
web_search_results: Optional[list[dict]]
```
**验收标准**:
- 旧线程无 `artifact` 字段时不报错
- `pnpm typecheck` 无新增错误(前端字段已对齐)
- `make test` 全量回归通过
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
---
## 五、关键文件位置
| 文件 | 用途 |
| 问题 | 解法 |
|------|------|
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
---
## 六、架构决策记录
## 五、当前已知限制(非 bug,设计取舍)
| 决策 | 内容 |
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
---
## 六、快速开始下一次工作
### 启动服务
```bash
# 后端(从 offline-backend-20260512/backend/)
make dev # 启动 Gateway API on port 8001
# 前端(从 frontend-web/)
pnpm dev # 启动 Vite on port 5174
```
### 访问路径
- 主页:`http://localhost:5174`
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
### 类型检查
```bash
cd frontend-web && pnpm typecheck
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
```
---
## 七、如需继续开发的潜在方向
| 方向 | 描述 | 优先级 |
|------|------|------|
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
---
## 八、关键文件速查
| 文件 | 说明 |
|------|------|
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
---
## 七、开始新对话的推荐提示词
## 九、开始新对话的推荐提示词
```
我们在集成 open-canvas 到 DeerFlow 项目。
参考进度文档:frontend-web/docs/openCanvas-progress.md
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
详细进度见:frontend-web/docs/openCanvas-progress.md
当前全部功能已完成,包括:
- Canvas 双栏布局、canvas_agent、版本历史
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
- 快捷动作(通过 CanvasStreamContext 统一流)
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
```
+109
View File
@@ -0,0 +1,109 @@
# AI 智能写作 — 手工测试清单
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
---
## T1 — 启动写作任务
| # | 步骤 | 预期结果 |
|---|------|----------|
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
---
## T2 — SSE 进度流(时间轴)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
---
## T3 — PAUSE-1 素材确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
---
## T4 — PAUSE-2 大纲确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
---
## T5 — PAUSE-3 草稿确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
---
## T6 — PAUSE-4 审核结果确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
---
## T7 — 错误与边界场景
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
---
## T8 — 跨阶段状态一致性
| # | 步骤 | 预期结果 |
|---|------|----------|
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
---
## 后端单元测试(补充确认)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
```
两个测试文件均应全部通过(0 failed)。
npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-placeholder
+188
View File
@@ -0,0 +1,188 @@
# 素材专家检索 —— 内网接口对接说明
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
涉及的代码文件,以及常见错误的排查方式。
---
## 1. 检索来源切换
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
配置位置:`offline-backend-20260512/backend/config.yaml`
```yaml
ai_writing:
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
search_provider: internet
# 以下两项仅在 search_provider=intranet 时生效
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples
```
| `search_provider` 取值 | 含义 |
|---|---|
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
| `tavily` | Tavily 互联网检索 |
| `demo` | 内置演示素材,仅用于本地调试 |
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
---
## 2. 内网接口规格
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
- **方法**:`POST`
- **请求体**:
```json
{
"query": "检索关键词",
"knowledge_base_name": "samples",
"ts": "yes"
}
```
- **响应体**:一个数组,每个元素结构如下:
```json
[
{
"page_content": "正文片段",
"metadata": {
"m_title": "标题",
"content": "内容",
"recUuid": "原文id",
"m_publish":"发布时间",
"source1": "来源"
}
}
]
```
我们需要的字段都在 `metadata` 里。
### 字段映射
后端把内网响应映射为统一的 `SearchResult` 结构:
| 内网响应字段 | SearchResult 字段 | 说明 |
|--------------------------|--------------------|------|
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
| `metadata.content` | `content` | 内容,截断到 500 字 |
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
| `metadata.m_publish` | `published_at` | 发布时间 |
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
| — | `source_type` | 固定为 `"intranet"` |
---
## 3. 对接逻辑与涉及文件
### 后端
| 文件 | 作用 |
|------|------|
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
调用链:
```
ai_writing.py(_make_config 注入 ai_writing_config)
→ researcher_node
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
→ IntranetSearchProvider.search() # POST search_docs_es
→ SearchResult[] # 字段映射
```
### 前端
| 文件 | 作用 |
|------|------|
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
「查看原文」链接拼接规则:
```
INTRANET_SOURCE_URL_BASE + recUuid
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
```
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
---
## 4. 错误处理与排查
### 4.1 内网检索返回空 / 调不通
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
**接口报错不影响写作流程继续**。
> 容错相关代码:
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
排查步骤:
1. **确认网络可达**:在后端所在机器执行
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
用户可以选择「重新搜索」或「无数据继续写作」。
### 4.2 字段缺失 / 响应结构变化
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
单条数据字段缺失不会导致整批失败。
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
### 4.3 「查看原文」按钮不显示
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
### 4.4 原文链接打不开 / 地址不对
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
若内网原文详情页地址有变,只改这一个常量即可。
---
## 5. 联调上线步骤
1. 确认后端机器能访问 `10.106.57.180:7863`。
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
3. 后端配置热加载生效(或重启后端)。
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
+689 -1
View File
@@ -35,6 +35,13 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17",
"@tiptap/core": "^3.23.4",
"@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
@@ -74,6 +81,7 @@
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
@@ -4262,6 +4270,417 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.4.tgz",
"integrity": "sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.4.tgz",
"integrity": "sha512-7YjSibNlPcy9eGK+tHt5G/Njr7nPxl+rZ3rCC6TwtLIRLSHPnoGDsfFOgTPkXxaQcE1a/VQwemnYfWc3kdIjDQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.4.tgz",
"integrity": "sha512-3L9tnZ12i+98u5df2nV2zGu/sc3rhI87E3ocn1YYAO8PJUAgZnMwdet8JawCrS1uut5sRKlxo3SXEmdNfRVm/w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.4.tgz",
"integrity": "sha512-EPTpL/IFp/aTGZErBq/Mc3dKznj6G/qNEkVYWjueOn1oKApyT0P6WVHGvu/vpMdErhzmoGDuFPPGVS6T8Upx2Q==",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.4.tgz",
"integrity": "sha512-mXB2KZOz1R+E6VNTZ3vzdAk7ZDGKjPmsJEZIQg1B5qRycTKg49/rCCkLA2QnqAwX6BzS3mLLH1RWE2W0oXD7vg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.4.tgz",
"integrity": "sha512-C0TeRipMycUEBnV+Mzx6eLp/yZb6Vi/waP3Tkb0lO5/ikg7LWLB7AlmMunjIXEUcR/pJHID/aEh5PfJFpysUDg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.4.tgz",
"integrity": "sha512-UEU1w/85CSNKktbhESnIRmtjKcH7DeschReZA8err1wAnYLTKzid5ucnJSJ25iRg2V5Fnuws5gnPT5CVgdfXCQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.4.tgz",
"integrity": "sha512-YC4G6VkxT629rlqUTwD6XvOpxjvghn7fxrK4RbyKVJY2C6E1vgmX0won1Ast6v+qTE6iONOMS6f6VyPxSGjg4w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.4.tgz",
"integrity": "sha512-ujJQUIENk0RwVFCh5g/TOSEv1a7Pnam/cjHmSUqHWUNZkYS9aOqjm+JfURJPCinRS2oHvo3AARul5mkKgDJYcA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.4.tgz",
"integrity": "sha512-eAc72bKM26yIPx0jsU8qdjE71vFNVu5R9jGbdItBMFc0SPLS4qY8g+8RJ+iWoLwbcSEpgooLS9D9sLfdAU+Tvw==",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.4.tgz",
"integrity": "sha512-RuyvOlIGP6UpVOc0Lw0L63jKLtYM49CNhPV2OMSfwwwbBZ3pJGos2/SqpYg71d3sn+qpsAopS4Pfr8iPZog73A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.4.tgz",
"integrity": "sha512-ODlpZCi7n136BH9luM09EFL8Pg+bbRCd0tzCQM5BKMXRkLitYZA8Gl/f5DLmGJ50wzFsDPXK2Br2g9UvZK7COg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.4.tgz",
"integrity": "sha512-8W9Hqi0J69Xbqg08nPf4xRMJXMccaKFAgUE1tvy5PAWJSQxOMwkKQXgZXxwe+80sOMUnV8qveBqUy/ODMPgAxQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.4.tgz",
"integrity": "sha512-EA4kK8ywZ4dQNOdxeZbplmDDs5T5LjMgHpqxRwukj9wwKiILOK5E3fcKm1fCKh9Q02w96jax6YVccHwmgJP3sQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.4.tgz",
"integrity": "sha512-jUAHi+HZlg47BzgVIy6y/UH5vev7vPQ95jddhB5K3hC122kvWFMXlken7UOnqzbxNcHs2+4Oi/ZJirYMpT4P5w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.4.tgz",
"integrity": "sha512-XjxltY7MomwfTs6jmN6Bw5bb/upb34lpyqv2RiXppFTK25Br7ipksRjUpWpB4/csZeg30qwrLGVKxCol38ffrw==",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.4.tgz",
"integrity": "sha512-yuauDm6qW/7q+ZO0YJBKQEGdnUm6DDTJM8AMp9bMZrT4jRf/zyUtNcZ91QEfFvBcyVuI+10PIOXtNPevhQ741Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.4.tgz",
"integrity": "sha512-Q/JXosShD5oyDwukE6igdrZD2lb0ZgyoQTHYchk0pzU4frClFbn3RI1wKP+XeqKLhdO6KH2WZ9rERGH7PtDi7Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.4.tgz",
"integrity": "sha512-9FezifCfuoc0o+5K6l4QNOOfelqxnDGg/f9oL1D/LFZPC54bPxpWWft9QCWOqyqZgyLCLjbCjciAlbgkrFUmmw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.4.tgz",
"integrity": "sha512-+3ofyssYnOTa1+nFWEmCAY1ngn8nAV1xo25JnNNC87NMU9WkSgr93jB7/uUJP0uui1C2dBLlaup3XXm108yarw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.4.tgz",
"integrity": "sha512-KbhXjCFzWphvFn5VU7E4dtmYDm+bssI1i0+CnXPWCXkjdaaX88ck68Xp1fKz8/bbI/CqlgiNDO/3TvqgtZ6woQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.4.tgz",
"integrity": "sha512-yHtAZkFR9M2AQmCi555w4ns1BBCqwRyYDYMtd10DBvqPX7T3TmGerPdUfI6sLr74GxnZ5zHOnOYdwAbeG5JzNw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.4.tgz",
"integrity": "sha512-Vnq5vW801zPbu1LtKeA5k4R241jY+hRjXeijYwIPxy15KzIiipY12518HiCf6/8kkRbMxgOfdYg9X4BRV3HV3g==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.4.tgz",
"integrity": "sha512-q9kxver/MR18p66aWZHSPycnr9hcBFyVGeGj8gf+BQCzn5hpvtSYTfLvk1nq8GFhygdQ9/e3f7B5ovrm/jnpvw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.4.tgz",
"integrity": "sha512-F1ocPT10LV+seky25R1TMCRdc/Iof99jLcDSYDGr6mNEDY4ct2RvOeSM8aDdYq6CkH+vXt3i3JDeRwV23KzswQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.4.tgz",
"integrity": "sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.4.tgz",
"integrity": "sha512-+C5ngcoza47n3MjtjVBqBEBICPC0McdbwzJ+X6SSCviCLoqnSYanv5mIX9HWG0Q4fJ4BkdNM3VibZUxQaTbKyQ==",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.4.tgz",
"integrity": "sha512-mb5aIY9PuLreOVLExqs+8BAI20I/8+jCUBfEIqheuFY2GRRuBiwczejSlYuADfVDBbPVN5uPw4UMADCaH5wueQ==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/extension-floating-menu": "^3.23.4"
},
"peerDependencies": {
"@tiptap/core": "3.23.4",
"@tiptap/pm": "3.23.4",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.4",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.4.tgz",
"integrity": "sha512-3VhU+NO6/ec9DMj/5Ej0nzARSq42cXnqW+QHCmTL3FNXkXQz+tw1KlfruT5GGJ3M0RssjWjRC0a39N/4S3qxeA==",
"dependencies": {
"@tiptap/core": "^3.23.4",
"@tiptap/extension-blockquote": "^3.23.4",
"@tiptap/extension-bold": "^3.23.4",
"@tiptap/extension-bullet-list": "^3.23.4",
"@tiptap/extension-code": "^3.23.4",
"@tiptap/extension-code-block": "^3.23.4",
"@tiptap/extension-document": "^3.23.4",
"@tiptap/extension-dropcursor": "^3.23.4",
"@tiptap/extension-gapcursor": "^3.23.4",
"@tiptap/extension-hard-break": "^3.23.4",
"@tiptap/extension-heading": "^3.23.4",
"@tiptap/extension-horizontal-rule": "^3.23.4",
"@tiptap/extension-italic": "^3.23.4",
"@tiptap/extension-link": "^3.23.4",
"@tiptap/extension-list": "^3.23.4",
"@tiptap/extension-list-item": "^3.23.4",
"@tiptap/extension-list-keymap": "^3.23.4",
"@tiptap/extension-ordered-list": "^3.23.4",
"@tiptap/extension-paragraph": "^3.23.4",
"@tiptap/extension-strike": "^3.23.4",
"@tiptap/extension-text": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4",
"@tiptap/extensions": "^3.23.4",
"@tiptap/pm": "^3.23.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tokenlens/core": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@tokenlens/core/-/core-1.3.0.tgz",
@@ -4606,6 +5025,20 @@
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="
},
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="
},
"node_modules/@types/markdown-it": {
"version": "13.0.9",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
"dependencies": {
"@types/linkify-it": "^3",
"@types/mdurl": "^1"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4614,6 +5047,11 @@
"@types/unist": "*"
}
},
"node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -4641,7 +5079,6 @@
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -4668,6 +5105,11 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@@ -4910,6 +5352,11 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -5986,6 +6433,14 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
@@ -6867,6 +7322,19 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -6931,6 +7399,38 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -7224,6 +7724,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@@ -7945,6 +8450,11 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -8176,6 +8686,160 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
},
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
},
"node_modules/prosemirror-model": {
"version": "1.25.6",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.6.tgz",
"integrity": "sha512-RIm+e9BiqAaJ1mRECv3vR3C+VG8ELoTTI+47tVudGi82yLnFOx3G/p/iSPK1HmHQdKhkkrJ68NJqxh7S+FBVmQ==",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"engines": {
"node": ">=6"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -8608,6 +9272,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
},
"node_modules/roughjs": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
@@ -8907,6 +9576,20 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
"dependencies": {
"@types/markdown-it": "^13.0.7",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-markdown": "^1.11.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.1"
}
},
"node_modules/tokenlens": {
"version": "1.3.1",
"license": "MIT",
@@ -8968,6 +9651,11 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+108 -100
View File
@@ -1,100 +1,108 @@
{
"name": "deer-flow-frontend-vite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"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",
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^14.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
},
"packageManager": "pnpm@10.26.2"
}
{
"name": "deer-flow-frontend-vite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17",
"@tiptap/core": "^3.23.4",
"@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"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",
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^14.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
},
"packageManager": "pnpm@10.26.2"
}
@@ -94,3 +94,7 @@ export function useArtifacts() {
}
return context;
}
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
@@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
@@ -201,8 +201,7 @@ function ToolCall({
isLoading?: boolean;
}) {
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
const artifacts = useArtifactsOptional();
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -329,43 +328,43 @@ function ToolCall({
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
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;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
return;
}
artifacts.select(artifactPath, true);
artifacts.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={() => {
if (!artifactPath || !artifacts) {
return;
}
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -412,6 +411,17 @@ function ToolCall({
icon={ListTodoIcon}
></ChainOfThoughtStep>
);
} else if (name === "update_artifact") {
const title = typeof args.title === "string" ? args.title : undefined;
const type = args.type as string | undefined;
const label = title ?? (type === "code" ? "生成代码" : "生成文档");
return (
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
<ChainOfThoughtSearchResult>
{type === "code" ? "代码文件" : "Markdown 文档"}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else {
const description: string | undefined = (args as { description: string })
?.description;
@@ -31,6 +31,7 @@ import {
extractContentFromMessage,
extractReasoningContentFromMessage,
parseUploadedFiles,
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
@@ -202,7 +203,7 @@ function MessageContent_({
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : "";
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
@@ -0,0 +1,57 @@
"use client";
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getCanvasLangGraphBaseURL } from "../config";
import { sanitizeRunStreamOptions } from "./stream-mode";
/**
* Dedicated LangGraph client for the Canvas page.
*
* Keeping this separate from getAPIClient() means:
* - Canvas API calls can be pointed at a different server via VITE_CANVAS_LANGGRAPH_BASE_URL
* - Canvas-specific HTTP-level logic (headers, retries, middleware) lives here
* - Changes here never touch the main page's API path
*/
function createCanvasClient(): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getCanvasLangGraphBaseURL(),
onRequest(_url, init) {
const headers = new Headers(init.headers ?? {});
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return { ...init, headers };
},
});
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) =>
originalRunStream(
threadId,
assistantId,
sanitizeRunStreamOptions(payload),
)) as typeof client.runs.stream;
const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = ((threadId, runId, options) =>
originalJoinStream(
threadId,
runId,
sanitizeRunStreamOptions(options),
)) as typeof client.runs.joinStream;
return client;
}
let _canvasClient: LangGraphClient | null = null;
export function getCanvasAPIClient(): LangGraphClient {
if (!_canvasClient) {
_canvasClient = createCanvasClient();
}
return _canvasClient;
}
+30
View File
@@ -1,5 +1,18 @@
import { env } from "@/env";
/**
* 内网知识库「查看原文」详情页地址前缀。
* 素材的 recUuid 直接拼接在其后即为完整原文链接。
*/
export const INTRANET_SOURCE_URL_BASE =
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid=";
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
export function buildIntranetSourceUrl(recUuid?: string | null): string {
if (!recUuid) return "";
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
}
function getBaseOrigin() {
if (typeof window !== "undefined") {
return window.location.origin;
@@ -18,6 +31,23 @@ export function getBackendBaseURL() {
}
}
/**
* Canvas page has its own LangGraph base URL so canvas-specific modifications
* (different server, different route prefix, etc.) can be made independently
* without touching the main page's API path.
* Falls back to the main LangGraph URL when VITE_CANVAS_LANGGRAPH_BASE_URL is unset.
*/
export function getCanvasLangGraphBaseURL(): string {
if (env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL) {
return new URL(
env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL,
getBaseOrigin(),
).toString();
}
// Fall back to the main LangGraph URL (same server, different assistantId)
return getLangGraphBaseURL();
}
export function getLangGraphBaseURL(isMock?: boolean) {
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
return new URL(
+45 -38
View File
@@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
@@ -44,11 +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.type !== "assistant:setup-agent-approval"
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
) {
return last;
}
@@ -70,23 +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 if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
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);
@@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) {
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function isSetupAgentApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function 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 [];
@@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string {
.trim();
}
/** Strip injected <artifact ...>...</artifact> blocks from human message display. */
export function stripArtifactTag(content: string): string {
return content
.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "")
.trim();
}
export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
+4
View File
@@ -4,6 +4,7 @@ function readEnv(
key:
| "VITE_BACKEND_BASE_URL"
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue {
@@ -14,6 +15,9 @@ function readEnv(
export const env = {
NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"),
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_BASE_URL"),
// Canvas page uses its own LangGraph base URL so it can be independently
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
@@ -0,0 +1,69 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface ArticleType {
id: string;
key: string;
label: string;
description: string | null;
prompt_hint: string | null;
default_word_count: number;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateArticleTypeRequest {
key: string;
label: string;
description?: string;
prompt_hint?: string;
default_word_count?: number;
sort_order?: number;
}
export interface UpdateArticleTypeRequest {
key?: string;
label?: string;
description?: string | null;
prompt_hint?: string | null;
default_word_count?: number;
sort_order?: number;
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
export async function listArticleTypes(): Promise<ArticleType[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
return resp.json();
}
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
return resp.json();
}
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
return resp.json();
}
export async function deleteArticleType(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
}
@@ -0,0 +1,73 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing';
export interface AIWritingSessionSummary {
id: string;
user_id: string | null;
title: string;
user_intent: string | null;
status: string;
draft_title: string | null;
draft_markdown: string | null;
completed_interventions: CompletedInterventionRecord[] | null;
review_result: Record<string, unknown> | null;
transcript: AIWritingTranscript | null;
created_at: string;
updated_at: string;
}
export interface CompletedInterventionRecord {
pausePoint: string;
action: string;
timestamp: string;
}
/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */
export interface AIWritingTranscript {
progressEvents: ProgressEvent[];
completedInterventions: CompletedIntervention[];
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
return resp.json();
}
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`);
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
return resp.json();
}
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
return resp.json();
}
export async function deleteAIWritingSession(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
}
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */
export async function saveAIWritingTranscript(
id: string,
transcript: AIWritingTranscript,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcript }),
});
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`);
}
@@ -0,0 +1,208 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type {
AIWritingRequest,
ProgressEvent,
ProgressEventType,
UserInterventionPayload,
} from '../types/ai-writing';
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
/** 启动写作流程,返回 session_id */
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
const resp = await apiFetch(`${BASE()}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_intent: req.userIntent,
article_type: req.articleType,
target_audience: req.targetAudience,
word_count_target: req.wordCountTarget,
model_name: req.modelName ?? '',
}),
});
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
const data = await resp.json();
return data.session_id;
}
const SSE_EVENT_TYPES: ProgressEventType[] = [
'connected', 'step_start', 'step_done', 'research_progress',
'materials_ready', 'outline_chunk', 'outline_ready',
'draft_chunk', 'draft_ready',
'review_chunk', 'review_item_done', 'review_ready',
'await_user', 'resumed', 'notice', 'done', 'error',
];
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
return {
...m,
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
publishedAt: m.published_at ?? m.publishedAt ?? '',
sourceType: m.source_type ?? m.sourceType ?? 'internet',
recUuid: m.recUuid ?? m.rec_uuid ?? '',
};
}
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
return {
...s,
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
keyPoints: s.key_points ?? s.keyPoints ?? [],
materialIds: s.material_ids ?? s.materialIds ?? [],
};
}
function normalizeOutline(o: unknown): unknown {
if (!o || typeof o !== 'object') return o;
const obj = o as Record<string, unknown>;
const sections = Array.isArray(obj.sections)
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
: [];
return { ...obj, sections };
}
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
return { ...i };
}
function normalizeReviewResult(r: unknown): unknown {
if (!r || typeof r !== 'object') return r;
const obj = r as Record<string, unknown>;
return {
...obj,
factScore: obj.fact_score ?? obj.factScore ?? 0,
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
languageScore: obj.language_score ?? obj.languageScore ?? 0,
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
revisionNotes: Array.isArray(obj.revision_notes)
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
: Array.isArray(obj.revisionNotes)
? obj.revisionNotes
: [],
};
}
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
const out: Record<string, unknown> = { ...parsed };
// 通用字段
if ('agent_name' in out) { out.agentName = out.agent_name; }
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
// materials 数组(materials_ready 和 await_user 里都可能有)
if (Array.isArray(out.materials)) {
out.materials = out.materials.map((m: unknown) =>
normalizeMaterial(m as Record<string, unknown>)
);
}
// outline
if (out.outline) out.outline = normalizeOutline(out.outline);
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
if ('revision_count' in out) out.revisionCount = out.revision_count;
// review_ready
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
// review_item_done (streaming per-category result from sse_queue)
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
out.reviewItemIssues = out.issues.map((x: unknown) =>
normalizeReviewIssue(x as Record<string, unknown>)
);
}
return out as Partial<ProgressEvent>;
}
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
signal?: AbortSignal,
): () => void {
let stopped = false;
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
.then(async (response) => {
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentType = '';
let currentData = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
currentData = line.slice(6).trim();
} else if (line === '' && currentType && currentData) {
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
try {
const parsed = JSON.parse(currentData);
const normalized = normalizeEvent(parsed);
onEvent({
type: currentType as ProgressEventType,
sessionId,
message: '',
...normalized,
} as ProgressEvent);
} catch {
// malformed JSON — skip
}
}
currentType = '';
currentData = '';
}
}
}
reader.releaseLock();
})
.catch(() => {});
return () => { stopped = true; };
}
/** 提交用户干预决策,恢复暂停的流程 */
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: payload.action,
approved_material_ids: payload.approvedMaterialIds ?? [],
extra_keywords: payload.extraKeywords ?? [],
edited_outline: payload.editedOutline ?? null,
outline_feedback: payload.outlineFeedback ?? '',
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
selected_issue_indices: payload.selectedIssueIndices ?? null,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
+106
View File
@@ -0,0 +1,106 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
export type RewriteTone =
| "professional"
| "casual"
| "readable"
| "academic"
| "expressive";
export interface RewriteRequest {
threadId?: string | null;
selection: { from: number; to: number; text: string };
action: RewriteAction;
tone?: RewriteTone;
modelName?: string;
surroundingContext?: { before?: string; after?: string };
customInstruction?: string;
followUpInstruction?: string;
previousResult?: string;
}
export type RewriteStreamEvent =
| { type: "thinking"; chunk: string }
| { type: "text"; chunk: string }
| { type: "done" }
| { type: "error"; message: string };
// ── SSE parser ────────────────────────────────────────────────────────────────
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trimEnd();
if (trimmed.startsWith("event: ")) {
currentEvent = trimmed.slice(7);
} else if (trimmed.startsWith("data: ")) {
yield { event: currentEvent, data: trimmed.slice(6) };
currentEvent = "";
}
// empty line resets event field (SSE spec)
else if (trimmed === "") {
currentEvent = "";
}
}
}
} finally {
reader.releaseLock();
}
}
// ── Streaming entry point ─────────────────────────────────────────────────────
export async function* streamRewrite(
req: RewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<RewriteStreamEvent> {
const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
signal,
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
const msg = (body?.detail as string | undefined) ?? (body?.message as string | undefined) ?? `HTTP ${res.status}`;
throw new Error(msg);
}
if (!res.body) throw new Error("No response body");
for await (const { event, data } of parseSse(res.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === "thinking") {
yield { type: "thinking", chunk: parsed.chunk as string };
} else if (event === "text") {
yield { type: "text", chunk: parsed.chunk as string };
} else if (event === "done") {
yield { type: "done" };
return;
} else if (event === "error") {
yield { type: "error", message: parsed.message as string };
return;
}
} catch {
// skip malformed frame
}
}
}
@@ -0,0 +1,59 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
return (
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
<div className="relative">
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
<FileText className="w-8 h-8 text-muted-foreground/40" />
</div>
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<PenLine className="w-3.5 h-3.5 text-primary/60" />
</div>
</div>
<div className="text-center space-y-1.5 max-w-[200px]">
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
完成写作流程后,AI 生成的内容将实时出现在这里
</p>
</div>
</div>
);
}
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
if (!artifact) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
const content = getCurrentArtifactContent(artifact);
return (
<div className="h-full flex flex-col overflow-hidden border-l">
{/* 标题栏 */}
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{content?.title ?? '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
import { useEffect, useState } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
}
interface ModelOption {
name: string;
displayName: string;
}
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
// 初始化时加载文章类型
useEffect(() => {
refreshTypes();
}, [refreshTypes]);
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
useEffect(() => {
if (articleTypes.length === 0) return;
const keys = articleTypes.map((t) => t.key);
if (!keys.includes(articleType)) {
const first = articleTypes[0];
setArticleType(first.key);
setWordCountInput(String(first.default_word_count));
}
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
// 切换文章类型时自动填入该类型的默认字数
function handleArticleTypeChange(key: string) {
setArticleType(key);
const found = articleTypes.find((t) => t.key === key);
if (found) {
setWordCountInput(String(found.default_word_count));
}
}
useEffect(() => {
apiFetch(`${getBackendBaseURL()}/api/models`)
.then((r) => r.json())
.then((data) => {
const list: ModelOption[] = (data.models ?? []).map(
(m: { name: string; display_name?: string }) => ({
name: m.name,
displayName: m.display_name || m.name,
}),
);
setModels(list);
if (list.length > 0 && !modelName) {
setModelName(list[0].name);
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleWordCountBlur() {
const num = parseInt(wordCountInput, 10);
if (isNaN(num) || num < 100) {
setWordCountInput('800');
} else {
setWordCountInput(String(num));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
modelName: modelName || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
error: { label: '错误', color: 'text-destructive' },
};
function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} 天前`;
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
function commitRename() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== session.title) onRename(trimmed);
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
}
return (
<div
className={cn(
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? onSelect : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
<Check className="w-3 h-3" />
</button>
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
<X className="w-3 h-3" />
</button>
</div>
) : (
<p
className="text-xs font-medium truncate leading-snug"
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
title="双击重命名"
>
{session.title}
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
</div>
{/* 删除按钮,hover 时显示 */}
{!editing && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
);
}
interface AIWritingHistoryDropdownProps {
sessions: AIWritingSessionSummary[];
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
}
export function AIWritingHistoryDropdown({
sessions,
selectedId,
loading,
onSelect,
onDelete,
onRename,
onRefresh,
}: AIWritingHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
function handleOpen() {
setOpen((v) => {
if (!v) onRefresh();
return !v;
});
}
return (
<div ref={containerRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleOpen}
title="查看历史记录"
>
<History className="size-3" />
历史
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold">写作历史</span>
<button
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
onClick={onRefresh}
title="刷新"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{/* 列表 */}
<div className="max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<div className="text-center py-6 text-xs text-muted-foreground">
{loading ? '加载中…' : '暂无历史记录'}
</div>
) : (
sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,131 @@
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import { AIWritingTimeline } from './AIWritingTimeline';
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
draft_confirm: '草稿确认',
review_confirm: '编辑意见处理',
};
const ACTION_LABELS: Record<string, string> = {
approve: '已批准',
re_search: '重新搜索',
re_outline: '重新规划',
finalize: '直接定稿',
to_editor: '提交审核',
user_revise: '手动修改',
accept_review: '接受意见',
force_finalize: '强制定稿',
};
const PAUSE_ICONS: Record<string, React.ElementType> = {
material_confirm: Search,
outline_confirm: PenLine,
draft_confirm: FileText,
review_confirm: ClipboardEdit,
};
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
const ts = record.timestamp
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-3 h-3 text-primary" />
</div>
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
</div>
<div className="pb-4 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-primary font-medium">
{ACTION_LABELS[record.action] ?? record.action}
</span>
</div>
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
</div>
</div>
);
}
interface AIWritingHistoryViewProps {
session: AIWritingSessionSummary;
}
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const interventions = session.completed_interventions ?? [];
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: '';
return (
<div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
</div>
</div>
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
currentPause={null}
/>
) : (
/* 旧会话无时间线快照时,回退到精简阶段列表 */
<div className="px-4 py-3 space-y-4">
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent } from '../../utils/artifact';
export function AIWritingPageHeader() {
const navigate = useNavigate();
const { artifact } = useCanvasGraphContext();
const currentContent = getCurrentArtifactContent(artifact);
return (
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate('/page/canvas')}
title="返回 Canvas 列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<PenLine className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">AI 智能写作</span>
{currentContent?.title && (
<>
<Separator orientation="vertical" className="h-4" />
<span className="text-sm text-muted-foreground truncate">
{currentContent.title}
</span>
</>
)}
</div>
);
}
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWriting';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
import { AIWritingHistoryView } from './AIWritingHistoryView';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
revising: '按意见修改中…',
done: '写作完成 ✓',
error: '出错了',
};
export function AIWritingPanel() {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const [autoScroll, setAutoScroll] = useState(true);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isRunning, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
}, [
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.progressEvents.length,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
setHistorySession(session);
// Push historical draft to the right panel
if (session.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: session.draft_title ?? session.title,
fullMarkdown: session.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact]);
const handleNewSession = useCallback(() => {
setHistorySession(null);
reset();
}, [reset]);
const showNewButton = historySession !== null || state.status !== 'idle';
// History view mode
if (historySession) {
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
</Button>
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={historySession.id}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
<AIWritingHistoryView session={historySession} />
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建
</Button>
)}
{isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
</Button>
)}
{state.isPaused && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(isDone || isError) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
onSubmit={submitIntervention}
disabled={false}
/>
) : undefined
}
/>
)}
{/* 大纲流式思考框 */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
)}
{/* 编辑审核流式结果 */}
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{/* 修改完成摘要 */}
{state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
)}
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
</div>
)}
</div>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
{isRunning && !autoScroll && (
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
<button
onClick={scrollToBottom}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
>
<ChevronsDown className="size-3.5" />
回到底部
</button>
</div>
)}
</div>{/* 相对定位容器结束 */}
{/* 状态栏 */}
{!isIdle && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
interface AIWritingTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: React.ReactNode;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'materials_ready',
'outline_ready',
'draft_ready',
'review_ready',
'await_user',
'resumed',
'notice',
'done',
'error',
]);
export function AIWritingTimeline({
events,
completedInterventions,
currentPause,
currentInterventionSlot,
}: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
let awaitUserIdx = 0;
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
let slot: React.ReactNode = null;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 渲染折叠历史卡
slot = <CompletedInterventionCard record={completedRecord} />;
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
return (
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
</TimelineStep>
);
})}
</div>
);
}
@@ -0,0 +1,233 @@
import { useState } from 'react';
import {
CheckCircle2, ChevronRight, ChevronDown,
ExternalLink, ListTree, FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config';
import type { CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
interface Props {
record: CompletedIntervention;
}
export function CompletedInterventionCard({ record }: Props) {
const [expanded, setExpanded] = useState(false);
if (record.pausePoint === 'material_confirm') {
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'outline_confirm') {
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'draft_confirm') {
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'review_confirm') {
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
return null;
}
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const count = record.materials?.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1 font-medium">
{count === 0 && record.action !== 're_search' ? (
'已在无素材的情况下继续写作'
) : (
<>
已确认{' '}
<span className="text-muted-foreground font-normal">{count} 条素材</span>
</>
)}
</span>
{count > 0 && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.materials && record.materials.length > 0 && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.materials.map((m) => {
const pct = Math.round(m.relevanceScore * 100);
const barColor =
m.relevanceScore >= 0.8 ? 'bg-green-500'
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
: 'bg-red-400';
return (
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="truncate max-w-[100px]">{m.source}</span>
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
</div>
{m.recUuid && (
<button type="button"
onClick={() => window.open(buildIntranetSourceUrl(m.recUuid), '_blank', 'noopener,noreferrer')}
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
原文 <ExternalLink className="size-2.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="flex-1 font-medium">
已确认大纲
{record.outline?.title && (
<span className="ml-1 text-muted-foreground font-normal">
《{record.outline.title}》
</span>
)}
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
</span>
{record.outline && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.outline && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<OutlineCard outline={record.outline} readOnly />
</div>
)}
</div>
);
}
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
const wordCount = record.draftWordCount ?? 0;
const hasReview = !!record.reviewResult;
const actionLabel =
record.action === 'finalize' ? '已确认定稿' :
record.action === 'user_revise' ? '已要求修改' :
'已提交编辑审核';
const actionColor =
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
<span className="text-muted-foreground flex-1 truncate">
{title}
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
</span>
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<ReviewCard review={record.reviewResult} />
</div>
)}
</div>
);
}
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const isForce = record.action === 'force_finalize';
const hasReview = !!record.reviewResult;
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
{hasReview && record.reviewResult && (
<span className="text-muted-foreground tabular-nums shrink-0">
{record.reviewResult.overallScore} / 100
</span>
)}
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftStreamingViewProps {
title: string;
sections: StreamingDraftSection[];
}
function TypewriterCursor() {
return (
<span
aria-hidden
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
style={{ verticalAlign: 'text-bottom' }}
/>
);
}
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
const [open, setOpen] = useState(true);
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (open && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [thinkingText, open]);
if (!thinkingText) return null;
return (
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
<span className="ml-auto text-amber-400">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
{open && (
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
>
{thinkingText}
{!isDone && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
);
}
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [sections]);
const activeSectionIdx = sections.reduce(
(max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max),
-1,
);
if (sections.length === 0) return null;
return (
<div className="px-4 pb-4">
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
{title && (
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
)}
{sections.map((sec) => (
<div key={sec.sectionIndex} className="mb-4">
<h2 className={cn(
'text-sm font-semibold mb-1.5',
sec.isDone ? 'text-foreground' : 'text-purple-500',
)}>
{sec.sectionTitle}
</h2>
{/* 该章节的思考过程(可折叠) */}
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,535 @@
import { useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmCard
draftTitle={draftTitle}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.join('\n');
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
interface MaterialConfirmCardProps {
materials: AIMaterial[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
return (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
</p>
</div>
</div>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set())}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
/>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleConfirm} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
</div>
);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
</p>
</div>
</div>
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}} />
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
/>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
interface OutlineConfirmCardProps {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [showReplan, setShowReplan] = useState(false);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
async function handleReplan() {
setSubmitting(true);
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
</p>
</div>
</div>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{showReplan && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
<textarea
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
{showReplan ? (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReplan} disabled={disabled || submitting}
>
<RefreshCw className="size-3.5" />提交重规划
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新规划…
</Button>
)}
</div>
</div>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
interface DraftConfirmCardProps {
draftTitle: string;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
</div>
</div>
{/* 编辑审核结果(若已审核,支持勾选意见) */}
{reviewResult && (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
{/* 用户自定义修改意见 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
<textarea
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
{showNotes ? (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
>
<PenLine className="size-3.5" />按我的意见修改
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
>
<PenLine className="size-3.5" />给出修改意见…
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
</div>
</div>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
interface ReviewConfirmCardProps {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
await onSubmit({
action: 'accept_review',
userRevisionNotes: combined || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult ? (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { cn } from '@/lib/utils';
import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
const CATEGORY_COLOR: Record<string, string> = {
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
};
export function IssueList({ issues, selectedIndices, onToggle }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const indexed = issues.map((issue, i) => ({ issue, index: i }));
const major = indexed.filter(({ issue }) => issue.severity === 'major');
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
const selectable = selectedIndices !== undefined && onToggle !== undefined;
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup
label="必须修改"
labelClass="text-red-500"
items={major}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
{minor.length > 0 && (
<IssueGroup
label="建议修改"
labelClass="text-yellow-500"
items={minor}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
</div>
);
}
interface IssueGroupProps {
label: string;
labelClass: string;
items: { issue: ReviewIssue; index: number }[];
selectable: boolean;
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{items.map(({ issue, index }) => {
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
return (
<div
key={index}
className={cn(
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
selectable ? 'cursor-pointer' : '',
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
)}
onClick={selectable ? () => onToggle?.(index) : undefined}
>
<div className="flex items-center gap-1.5">
{selectable && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle?.(index)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-primary cursor-pointer"
/>
)}
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
</div>
<p className={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
{issue.issue}
</p>
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialItemProps {
material: AIMaterial;
checked: boolean;
onToggle: (id: string) => void;
}
function relevanceColor(score: number): string {
if (score >= 0.8) return 'bg-green-500';
if (score >= 0.5) return 'bg-orange-400';
return 'bg-red-400';
}
function relevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
)}
>
{/* 头部行 */}
<div className="flex items-start gap-2 px-2.5 py-2">
{/* Checkbox */}
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(material.id)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
/>
{/* 标题 + 展开按钮 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
{expanded
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
</button>
</div>
{/* 元信息行 */}
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
{material.publishedAt && (
<>
<span className="text-muted-foreground/40">·</span>
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
</>
)}
{/* 相关度色条 */}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
{pct}%
</span>
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
</div>
</div>
{/* 展开内容 */}
{expanded && (
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
{material.recUuid && (
<button
type="button"
onClick={() =>
window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')
}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
import type { AIMaterial } from '../../types/ai-writing';
import { MaterialItem } from './MaterialItem';
interface MaterialsCardProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
>
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1">
素材列表
<span className="ml-1 text-muted-foreground font-normal">
(共 {materials.length} 条,已选 {selectedIds.size} 条)
</span>
</span>
{expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />}
</button>
{/* 列表 */}
{expanded && (
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
{materials.length === 0 ? (
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
) : (
materials.map((m) => (
<MaterialItem
key={m.id}
material={m}
checked={selectedIds.has(m.id)}
onToggle={onToggle}
/>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
}
export function MaterialsToolbar({
materials,
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
}
return (
<div className="space-y-2">
{/* 全选/取消(无素材时隐藏) */}
{materials.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={allSelected ? onDeselectAll : onSelectAll}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
)}
{/* 补充关键词 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { ListTree, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
interface OutlineCardProps {
outline: AIOutline;
onChange?: (updated: AIOutline) => void;
readOnly?: boolean;
}
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const editable = !readOnly && !!onChange;
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== outline.title) {
onChange?.({ ...outline, title: trimmed });
} else {
setTitleDraft(outline.title);
}
}
function updateSection(i: number, updated: OutlineSectionType) {
const sections = [...outline.sections];
sections[i] = updated;
onChange?.({ ...outline, sections });
}
function removeSection(i: number) {
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function reorderSections(from: number, to: number) {
if (from === to) return;
const sections = [...outline.sections];
const [moved] = sections.splice(from, 1);
sections.splice(to, 0, moved);
onChange?.({ ...outline, sections });
}
function addSection() {
onChange?.({
...outline,
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
});
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 头部 */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? '展开' : '收起'}
</button>
</div>
{!collapsed && (
<div className="px-3 py-2.5 space-y-2">
{/* 文章标题 */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">标题:</span>
{editingTitle && editable ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
/>
) : (
<span
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
onClick={() => editable && setEditingTitle(true)}
>
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
</span>
)}
</div>
{/* 章节列表 */}
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
isDragging={dragIndex === i}
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => setOverIndex(i)}
onDrop={() => {
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
setDragIndex(null);
setOverIndex(null);
}}
onDragEnd={() => {
setDragIndex(null);
setOverIndex(null);
}}
/>
))}
</div>
{/* 添加章节 */}
{editable && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
onClick={addSection}
>
<Plus className="size-3" />
添加章节
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
interface OutlineSectionProps {
section: OutlineSectionType;
index: number;
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
// Drag-and-drop
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
onDragEnter?: () => void;
onDrop?: () => void;
onDragEnd?: () => void;
}
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
}: OutlineSectionProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== section.sectionTitle) {
onChange({ ...section, sectionTitle: trimmed });
} else {
setTitleDraft(section.sectionTitle);
}
}
function updateKeyPoint(i: number, value: string) {
const next = [...section.keyPoints];
next[i] = value;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
}
return (
<div
draggable={!readOnly}
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
className={cn(
'rounded border bg-background text-xs transition-all duration-150 select-none',
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
)}
>
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && (
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
{expanded
? <ChevronDown className="size-3.5 text-muted-foreground" />
: <ChevronRight className="size-3.5 text-muted-foreground" />}
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 要点列表 */}
{expanded && (
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
) : (
<input
value={kp}
onChange={(e) => updateKeyPoint(i, e.target.value)}
placeholder="输入要点…"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加要点
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react';
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface OutlineStreamingViewProps {
text: string;
thinkingText?: string;
}
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) {
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="px-4 pb-2 space-y-2">
{/* 思考过程区(仅在有思考内容时展示) */}
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{/* 思考仍在进行时显示光标 */}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{/* 正文流式区 */}
{text && (
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-purple-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,74 @@
import { cn } from '@/lib/utils';
import type { ReviewResult } from '../../types/ai-writing';
import { ScoreBar } from './ScoreBar';
import { IssueList } from './IssueList';
interface ReviewCardProps {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: ReviewCardProps) {
const isPassed = review.verdict === 'pass';
return (
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
{/* 头部:综合评分 + 轮次 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-2">
<span className="font-medium">编辑审核结果</span>
{revisionCount !== undefined && revisionCount > 0 && (
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
)}
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
isPassed
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
)}
>
{isPassed ? '通过' : '打回'}
</span>
<span className="font-semibold tabular-nums">
{review.overallScore}
<span className="text-muted-foreground font-normal"> / 100</span>
</span>
</div>
</div>
{/* 分项评分 */}
<div className="px-3 py-2.5 space-y-2 border-b">
<ScoreBar label="事实准确性" score={review.factScore} />
<ScoreBar label="逻辑结构" score={review.logicScore} />
<ScoreBar label="语言文字" score={review.languageScore} />
<p className="text-muted-foreground/60 text-[10px]">
通过线:{review.passThreshold} 分
</p>
</div>
{/* 修改意见(支持勾选) */}
{review.revisionNotes.length > 0 && (
<div className="px-3 py-2.5">
<div className="flex items-center justify-between mb-2">
<p className="text-muted-foreground font-medium">修改意见</p>
{selectedIndices !== undefined && onToggleIssue && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
<IssueList
issues={review.revisionNotes}
selectedIndices={selectedIndices}
onToggle={onToggleIssue}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useRef } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
const CATEGORY_META: Record<
'fact' | 'logic' | 'language',
{ label: string; order: number }
> = {
fact: { label: '事实准确性', order: 0 },
logic: { label: '逻辑结构', order: 1 },
language: { label: '语言文字', order: 2 },
};
const CATEGORIES = (['fact', 'logic', 'language'] as const);
function ScoreBar({ score }: { score: number }) {
const color =
score >= 80 ? 'bg-green-400' :
score >= 60 ? 'bg-amber-400' :
'bg-red-400';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
);
}
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
)}
</div>
);
}
// 流式打字机文本块(审核进行中的分类显示)
function StreamingText({ item }: { item: StreamingReviewItem }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [item.streamingText, item.thinkingText]);
const hasContent = item.streamingText || item.thinkingText;
if (!hasContent) {
return (
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
);
}
return (
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
>
{item.thinkingText && (
<span className="italic text-amber-600/70 dark:text-amber-400/60">
{item.thinkingText}
</span>
)}
{item.streamingText}
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
);
}
interface ReviewStreamingViewProps {
items: StreamingReviewItem[];
}
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
if (items.length === 0) return null;
const doneItems = items.filter((it) => it.isDone);
const allDone = doneItems.length === 3;
const overallScore =
doneItems.length > 0
? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length)
: 0;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
{/* 标题栏 */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
编辑正在审核…
</span>
{!allDone && (
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-orange-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
)}
{allDone && (
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
综合 {overallScore}
</span>
)}
</div>
{/* 逐项审核结果 */}
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
const item = items.find((it) => it.category === cat);
const isDone = item?.isDone ?? false;
const isStreaming = item != null && !item.isDone;
const isPending = item == null;
return (
<div key={cat} className="px-3 py-2 space-y-1.5">
{/* 分类行 */}
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : isStreaming ? (
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
) : (
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className={cn(
'text-xs font-medium',
isPending ? 'text-muted-foreground' : 'text-foreground',
)}>
{meta.label}
</span>
</div>
{/* 审核进行中:流式打字机文本 */}
{isStreaming && item && <StreamingText item={item} />}
{/* 审核完成:得分 + 问题列表 */}
{isDone && item && (
<>
<ScoreBar score={item.score} />
{item.issues.length > 0 ? (
<div className="space-y-1 mt-1">
{item.issues.slice(0, 3).map((iss, i) => (
<IssueRow key={i} issue={iss} />
))}
{item.issues.length > 3 && (
<p className="text-[11px] text-muted-foreground pl-2">
+{item.issues.length - 3} 条意见…
</p>
)}
</div>
) : (
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
无问题
</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,79 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
return (
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
<span className="text-muted-foreground tabular-nums">
{newWordCount.toLocaleString()} 字
{wordDiff !== 0 && (
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
({wordDiff > 0 ? '+' : ''}{wordDiff})
</span>
)}
</span>
</div>
<div className="px-3 py-2.5 space-y-2">
{/* 已修改 */}
{revisedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{/* 已跳过 */}
{skippedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium flex items-center gap-1">
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
<p className="text-muted-foreground text-center py-1">已完成修改</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { cn } from '@/lib/utils';
interface ScoreBarProps {
label: string;
score: number;
className?: string;
}
export function ScoreBar({ label, score, className }: ScoreBarProps) {
const clipped = Math.min(100, Math.max(0, score));
const color =
clipped >= 80
? 'bg-green-500'
: clipped >= 60
? 'bg-yellow-500'
: 'bg-red-500';
const verdict =
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
return (
<div className={cn('space-y-1', className)}>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-medium tabular-nums">
{clipped}
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${clipped}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { AlertCircle, AlertTriangle, CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ProgressEvent } from '../../types/ai-writing';
type StepStatus = 'done' | 'running' | 'paused' | 'waiting' | 'error' | 'warning';
interface TimelineStepProps {
event: ProgressEvent;
isLast?: boolean;
children?: React.ReactNode;
}
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
if (event.type === 'error') return 'error';
if (event.type === 'notice') return 'warning';
if (event.type === 'await_user') return 'paused';
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
if (event.type === 'step_start') return isLast ? 'running' : 'done';
return 'done';
}
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event, isLast ?? true);
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
const isAlert = event.type === 'error' || event.type === 'notice';
const isError = event.type === 'error';
return (
<div className="flex gap-3">
{/* 竖线 + 图标 */}
<div className="flex flex-col items-center">
<StatusIcon status={status} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* 内容 */}
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
{event.agentName && (
<span
className={cn(
'text-xs font-semibold',
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
)}
>
{event.agentName}
</span>
)}
{isAlert ? (
<div
className={cn(
'mt-1 rounded-md border px-2.5 py-1.5',
isError
? 'border-red-200 bg-red-50/60 dark:border-red-900/40 dark:bg-red-950/20'
: 'border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-950/20',
)}
>
<p
className={cn(
'text-xs font-medium',
isError
? 'text-red-600 dark:text-red-400'
: 'text-amber-600 dark:text-amber-500',
)}
>
{event.title || event.message || (isError ? '出错了' : '提示')}
</p>
{event.detail && (
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground break-words">
{event.detail}
</p>
)}
</div>
) : (
<span className="ml-1.5 text-xs text-muted-foreground">{event.message}</span>
)}
{children}
</div>
</div>
);
}
function StatusIcon({ status }: { status: StepStatus }) {
switch (status) {
case 'done':
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
case 'running':
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
case 'paused':
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
case 'error':
return <AlertCircle className="size-4 text-red-500 shrink-0 mt-0.5" />;
case 'warning':
return <AlertTriangle className="size-4 text-amber-500 shrink-0 mt-0.5" />;
default:
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
}
}
@@ -0,0 +1,232 @@
import { cn } from '@/lib/utils';
import type { WritingStatus } from '../../types/ai-writing';
interface WritingSkeletonCardProps {
status: WritingStatus;
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
researchProgress?: string;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
function SkeletonLine({ className }: { className?: string }) {
return (
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
);
}
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
return (
<span className="inline-flex gap-1 items-center">
{[0, 1, 2].map((i) => (
<span
key={i}
className={cn('size-1.5 rounded-full animate-bounce', color)}
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
);
}
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
return (
<div className="space-y-3 p-4">
{/* 标题行:展示素材收集专家的实时进度 */}
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
<ThinkingDots color="bg-blue-400" />
<span>{researchProgress || '素材收集专家搜索中…'}</span>
</div>
{/* 关键词标签占位 */}
<div className="flex gap-1.5 flex-wrap">
{[60, 80, 50].map((w, i) => (
<div
key={i}
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
style={{ width: `${w}px` }}
/>
))}
</div>
{/* 素材卡片占位 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
{/* 标题 */}
<div className="flex items-start gap-2">
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
</div>
{/* 来源 + 时间 + 相关度 */}
<div className="flex items-center gap-2">
<SkeletonLine className="w-20 h-2" />
<SkeletonLine className="w-16 h-2" />
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
style={{ width: `${55 + i * 8}%` }}
/>
</div>
</div>
{/* 摘要行 */}
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
</div>
))}
</div>
);
}
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
</div>
{/* 文章标题占位 */}
<SkeletonLine className="w-3/5 h-4 mx-auto" />
{/* 章节列表 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-2 pl-1">
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
<SkeletonLine className="w-3/5 h-2" />
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
</div>
</div>
))}
</div>
);
}
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
function DraftSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家写作中…</span>
</div>
{/* 标题占位 */}
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{/* 章节内容 */}
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-3/4 h-2" />
</div>
))}
</div>
);
}
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
function ReviewSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
<ThinkingDots color="bg-orange-400" />
<span>编辑审核中…</span>
</div>
{/* 综合评分占位 */}
<div className="flex items-center justify-between">
<SkeletonLine className="w-24 h-3" />
<SkeletonLine className="w-12 h-4" />
</div>
{/* 分项评分条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
<div key={label} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">{label}</span>
<SkeletonLine className="w-8 h-3" />
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
style={{ width: `${55 + i * 10}%` }}
/>
</div>
</div>
))}
{/* 意见条目占位 */}
<div className="space-y-2 pt-1 border-t">
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
</div>
</div>
);
}
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
function RevisingSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家按意见修改中…</span>
</div>
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
</div>
))}
</div>
);
}
// ── 状态映射 ──────────────────────────────────────────────────────────────────
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
// 实际运行状态
writing_outline: OutlineSkeleton,
writing_draft: DraftSkeleton,
reviewing: ReviewSkeleton,
revising: RevisingSkeleton,
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<MaterialsSkeleton researchProgress={researchProgress} />
</div>
);
}
const Skeleton = SKELETON_MAP[status];
if (!Skeleton) return null;
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<Skeleton />
</div>
);
}
@@ -87,6 +87,20 @@ export function CanvasArtifactPanel() {
);
}
// While streaming but no partial content yet — show a loading placeholder
// so the template picker doesn't flash during generation.
if (isStreaming && !artifact) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
<div className="text-center space-y-1">
<p className="text-sm font-medium text-foreground">AI 正在生成…</p>
<p className="text-xs">内容将在右侧显示</p>
</div>
</div>
);
}
if (!artifact) {
return (
<div className="h-full bg-muted/10">
@@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import type { Extension } from "@codemirror/state";
import type { ViewUpdate } from "@codemirror/view";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
@@ -1,18 +1,171 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import type { CanvasArtifactText } from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import { WritingToolbar } from "./WritingToolbar";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
isStreaming?: boolean;
}
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection();
interface PanelPos {
x: number;
y: number;
}
if (!content.fullMarkdown) {
/** Phase of the AI rewrite flow */
type FlowPhase = "idle" | "menu" | "preview";
interface LastAction {
action: RewriteAction;
tone?: RewriteTone;
customInstruction?: string;
}
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
const scrollRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const autoScrollRef = useRef(true);
// ── AI rewrite flow state ──────────────────────────────────────────────
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const handleEditorChange = useCallback(
(markdown: string) => {
if (!artifact) return;
setArtifact({
...artifact,
contents: artifact.contents.map((c) =>
c.index === artifact.currentIndex && c.type === "text"
? { ...c, fullMarkdown: markdown }
: c,
),
});
},
[artifact, setArtifact],
);
const editor = useWritingEditor({
markdown: content.fullMarkdown,
onChange: handleEditorChange,
editable: !isStreaming,
placeholder: "开始写作…",
});
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } = useWritingRewrite(editor);
// ── Streaming auto-scroll ──────────────────────────────────────────────
useEffect(() => {
if (!isStreaming) {
autoScrollRef.current = true;
setShowScrollBtn(false);
return;
}
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [isStreaming, content.fullMarkdown]);
const handleScroll = useCallback(() => {
if (!isStreaming) return;
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn(!atBottom);
}, [isStreaming]);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
// ── AI rewrite flow handlers ───────────────────────────────────────────
/** Compute anchor position from current DOM selection */
const computePos = useCallback((): PanelPos => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (rect.width || rect.height) {
return { x: rect.left, y: rect.bottom + 6 };
}
}
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
}, []);
const openAiMenu = useCallback(() => {
reset();
setPanelPos(computePos());
setPhase("menu");
}, [reset, computePos]);
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
setPhase("idle");
reset();
}, [reset, editor]);
const handleAction = useCallback(
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
lastActionRef.current = { action, tone, customInstruction };
rewrite(action, tone, customInstruction);
setPhase("preview");
},
[rewrite],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
const { action, tone, customInstruction } = lastActionRef.current;
rewrite(action, tone, customInstruction);
}, [rewrite]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
const { action, tone } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone);
},
[rewriteFollowUp],
);
const handleReplace = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
const handleInsert = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
// ── Empty state ────────────────────────────────────────────────────────
if (!content.fullMarkdown && !isStreaming) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">
@@ -22,125 +175,146 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
);
}
return (
<div
className="h-full overflow-y-auto"
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
>
<div className="max-w-3xl mx-auto px-8 py-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-4 leading-7 text-sm text-foreground/90">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
{children}
</ol>
),
li: ({ children }) => (
<li className="leading-7">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
if (isBlock) {
return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
table: ({ children }) => (
<div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">{children}</thead>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
{children}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-foreground/80">{children}</em>
),
}}
// ── Streaming mode ────────────────────────────────────────────────────
if (isStreaming) {
return (
<div className="relative h-full flex flex-col overflow-hidden">
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{content.fullMarkdown}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
return isBlock ? (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
}}
>
{content.fullMarkdown}
</ReactMarkdown>
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
// ── Edit mode ─────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
{/* Phase: AI action menu */}
{phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
error={error}
onAction={handleAction}
onAbort={reset}
onRetry={handleRetry}
onClose={closeAll}
/>
)}
{/* Phase: result preview */}
{phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
originalText={writingSelection?.text ?? ""}
result={result}
thinking={thinking}
error={error}
action={lastActionRef.current?.action ?? "polish"}
tone={lastActionRef.current?.tone}
onReplace={handleReplace}
onInsert={handleInsert}
onRetry={handleRetry}
onAbort={reset}
onClose={closeAll}
onFollowUp={handleFollowUp}
/>
)}
</div>
);
}
@@ -0,0 +1,288 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
Sparkles,
Wand2,
Expand,
Shrink,
Mic2,
AlertCircle,
RefreshCw,
ChevronRight,
X,
Square,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
// ── Types ─────────────────────────────────────────────────────────────────────
interface MenuPos {
x: number;
y: number;
}
// ── Tone options ───────────────────────────────────────────────────────────────
const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [
{ value: "professional", label: "更专业" },
{ value: "casual", label: "更口语化" },
{ value: "readable", label: "更易读" },
{ value: "academic", label: "更学术" },
{ value: "expressive", label: "更有文采" },
];
// ── Action rows ────────────────────────────────────────────────────────────────
const ACTION_ROWS: {
action: RewriteAction;
label: string;
icon: React.ElementType;
}[] = [
{ action: "polish", label: "润色", icon: Wand2 },
{ action: "expand", label: "扩写", icon: Expand },
{ action: "shorten", label: "缩写", icon: Shrink },
{ action: "tone", label: "改语气", icon: Mic2 },
];
// ── Main component ─────────────────────────────────────────────────────────────
interface WritingAiMenuProps {
pos: MenuPos;
status: RewriteStatus;
error: string | null;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void;
onAbort: () => void;
onRetry: () => void;
onClose: () => void;
}
export function WritingAiMenu({
pos,
status,
error,
onAction,
onAbort,
onRetry,
onClose,
}: WritingAiMenuProps) {
const [toneOpen, setToneOpen] = useState(false);
const [instruction, setInstruction] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// autoFocus is unreliable when the menu opens during a mousedown event sequence;
// explicit focus via setTimeout(0) runs after the browser finishes that sequence.
useEffect(() => {
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, []);
const isLoading = status === "loading";
const isError = status === "error";
// Measure actual height after render and compute safe position before browser paint
const menuWidth = 240;
const containerRef = useRef<HTMLDivElement>(null);
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const h = el.getBoundingClientRect().height;
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8);
const spaceBelow = window.innerHeight - pos.y - 8;
const spaceAbove = pos.y - 8;
let safeY: number;
if (spaceBelow >= h) {
safeY = pos.y + 4;
} else if (spaceAbove >= h) {
safeY = pos.y - h - 4;
} else if (spaceBelow >= spaceAbove) {
safeY = Math.max(8, window.innerHeight - h - 8);
} else {
safeY = 8;
}
setComputedPos({ left: safeX, top: safeY });
}, [pos.x, pos.y]);
const fireAction = (action: RewriteAction, tone?: RewriteTone) => {
onAction(action, tone, instruction.trim() || undefined);
};
const handleInputEnter = () => {
const trimmed = instruction.trim();
onAction("polish", undefined, trimmed || undefined);
};
return createPortal(
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 4),
width: menuWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
AI 改写
</span>
<button
type="button"
onClick={onClose}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* ── Loading state ──────────────────────────────────────── */}
{isLoading && (
<div className="flex flex-col items-center gap-4 px-4 py-6">
{/* Bouncing dots */}
<div className="flex items-center gap-2">
{[0, 160, 320].map((delay) => (
<span
key={delay}
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
style={{ animationDelay: `${delay}ms` }}
/>
))}
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
</div>
{/* Stop button — breathing animation */}
<button
type="button"
onClick={onAbort}
className={cn(
"animate-breathe-stop",
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
"border border-red-300/70 bg-red-50/60 text-red-600",
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
)}
>
<Square className="h-3 w-3 fill-current" />
停止生成
</button>
</div>
)}
{/* ── Error state ─────────────────────────────────────────── */}
{isError && (
<div className="px-3 py-3 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error ?? "改写失败"}
</p>
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />
重试
</button>
</div>
)}
{/* ── Idle state: input + action list ─────────────────────── */}
{!isLoading && !isError && (
<>
{/* Custom instruction input */}
<div className="px-2.5 pt-2.5 pb-2">
<input
ref={inputRef}
type="text"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInputEnter();
}
}}
placeholder="告诉 AI 你想要什么…"
className={cn(
"w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors",
"bg-muted/50 border border-input focus:border-primary/60",
"placeholder:text-muted-foreground/50",
)}
/>
</div>
{/* Divider */}
<div className="border-t border-border/50 mx-1" />
{/* Preset actions */}
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen;
return (
<div key={action}>
<button
type="button"
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else {
fireAction(action);
}
}}
className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
isTonesOpen && "bg-accent text-accent-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span>
{isTone && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTonesOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
key={value}
type="button"
onClick={() => fireAction("tone", value)}
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{toneLabel}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
</>,
document.body,
);
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/react";
import {
Sparkles,
Bold,
Italic,
Underline,
Heading1,
Copy,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
// ── Types ────────────────────────────────────────────────────────────────────
interface MenuPosition {
x: number;
y: number; // top of selection
bottom: number; // bottom of selection (used to flip menu below when near top)
}
// ── Sub-components ───────────────────────────────────────────────────────────
interface BubbleButtonProps {
onClick: () => void;
active?: boolean;
className?: string;
children: React.ReactNode;
}
function BubbleButton({ onClick, active, className, children }: BubbleButtonProps) {
return (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // keep editor focused
onClick();
}}
className={cn(
"inline-flex h-7 items-center gap-1 px-2 text-xs font-medium rounded transition-colors",
"hover:bg-black/8 dark:hover:bg-white/10",
active && "bg-black/10 dark:bg-white/15",
className,
)}
>
{children}
</button>
);
}
function BubbleSep() {
return <div className="w-px h-4 bg-black/15 dark:bg-white/25 mx-0.5 shrink-0" />;
}
// ── Main component ───────────────────────────────────────────────────────────
interface WritingBubbleMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [position, setPosition] = useState<MenuPosition | null>(null);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [computedStyle, setComputedStyle] = useState<React.CSSProperties>({
position: "fixed", left: 0, top: 0, zIndex: 9999, visibility: "hidden",
});
useLayoutEffect(() => {
if (!position || !menuRef.current) return;
const { width: mw, height: mh } = menuRef.current.getBoundingClientRect();
// X: center on selection, clamp to viewport
const rawLeft = position.x - mw / 2;
const safeLeft = Math.max(8, Math.min(rawLeft, window.innerWidth - mw - 8));
// Y: prefer above; flip below if too close to top
let safeTop: number;
if (position.y - mh - 8 >= 8) {
safeTop = position.y - mh - 8;
} else {
safeTop = Math.min(position.bottom + 8, window.innerHeight - mh - 8);
}
setComputedStyle({ position: "fixed", left: safeLeft, top: safeTop, zIndex: 9999, visibility: "visible" });
}, [position]);
// Track selection → compute menu position
useEffect(() => {
const updatePosition = () => {
const { from, to, empty } = editor.state.selection;
if (empty || editor.isActive("codeBlock")) {
setPosition(null);
return;
}
// Use the native DOM selection for pixel coordinates
const domSel = window.getSelection();
if (!domSel || domSel.rangeCount === 0) {
setPosition(null);
return;
}
const range = domSel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) {
setPosition(null);
return;
}
// Store selection for AI rewrite
const text = editor.state.doc.textBetween(from, to, "\n");
if (text.trim()) {
setWritingSelection({ from, to, text });
}
setPosition({
x: rect.left + rect.width / 2,
y: rect.top,
bottom: rect.bottom,
});
};
editor.on("selectionUpdate", updatePosition);
editor.on("blur", () => setPosition(null));
return () => {
editor.off("selectionUpdate", updatePosition);
editor.off("blur", () => setPosition(null));
};
}, [editor, setWritingSelection]);
const handleAiRewrite = useCallback(() => {
const { from, to, empty } = editor.state.selection;
if (!empty) {
editor.commands.setAiHighlight(from, to);
}
onAiRewrite();
}, [onAiRewrite, editor]);
const handleCopy = useCallback(() => {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, "\n");
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [editor]);
if (!position) return null;
return createPortal(
<div
ref={menuRef}
style={computedStyle}
className="animate-in fade-in zoom-in-95 duration-100"
>
<div className="flex items-center gap-0 rounded-lg px-1 py-1 shadow-lg ring-1 ring-black/8 bg-popover text-foreground dark:bg-neutral-900 dark:ring-white/10">
{/* AI 改写 — primary */}
<BubbleButton
onClick={handleAiRewrite}
className="text-amber-500 hover:text-amber-600 font-semibold"
>
<Sparkles className="h-3.5 w-3.5" />
AI 改写
</BubbleButton>
<BubbleSep />
{/* Inline format */}
<BubbleButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
>
<Bold className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
>
<Italic className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
>
<Underline className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Heading */}
<BubbleButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
active={editor.isActive("heading", { level: 1 })}
>
<Heading1 className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Copy */}
<BubbleButton onClick={handleCopy}>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</BubbleButton>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
const REWRITABLE_TYPES = new Set(["paragraph", "heading", "blockquote"]);
const BTN_SIZE = 24;
const BTN_GAP = 6; // gap between button right edge and text left edge
// Extra zone around the button that still counts as "near" (bridges the gap)
const HOVER_MARGIN = BTN_GAP + 8;
// Delay before hiding the button (ms) — long enough to cross the gap at slow speed
const HIDE_DELAY = 380;
interface HoverState {
top: number;
left: number;
height: number;
nodeFrom: number;
nodeTo: number;
text: string;
}
interface WritingParagraphMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingParagraphMenu({ editor, onAiRewrite }: WritingParagraphMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [hover, setHover] = useState<HoverState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep a ref in sync so the document-level handler always reads fresh state
const hoverRef = useRef<HoverState | null>(null);
hoverRef.current = hover;
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
}, []);
const scheduleHide = useCallback(() => {
clearHideTimer();
hideTimerRef.current = setTimeout(() => setHover(null), HIDE_DELAY);
}, [clearHideTimer]);
// Document-level mousemove: cancel hide when cursor is in the button's vicinity.
// This bridges the gap between the editor's text edge and the button.
useEffect(() => {
const handleDocMove = (e: MouseEvent) => {
const h = hoverRef.current;
if (!h) return;
const btnLeft = h.left - BTN_SIZE - BTN_GAP;
const btnTop = h.top + h.height / 2 - BTN_SIZE / 2;
const inZone =
e.clientX >= btnLeft - HOVER_MARGIN &&
e.clientX <= h.left + HOVER_MARGIN && // extend slightly into text side
e.clientY >= btnTop - HOVER_MARGIN &&
e.clientY <= btnTop + BTN_SIZE + HOVER_MARGIN;
if (inZone) clearHideTimer();
};
document.addEventListener("mousemove", handleDocMove);
return () => document.removeEventListener("mousemove", handleDocMove);
}, [clearHideTimer]);
useEffect(() => {
const editorDom = editor.view.dom as HTMLElement;
const handleMouseMove = (e: MouseEvent) => {
const pos = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
if (!pos) { scheduleHide(); return; }
const $pos = editor.state.doc.resolve(pos.pos);
const depth = Math.min(1, $pos.depth);
if (depth === 0) { scheduleHide(); return; }
const nodeStart = $pos.before(depth);
const node = editor.state.doc.nodeAt(nodeStart);
if (!node || !REWRITABLE_TYPES.has(node.type.name)) {
scheduleHide();
return;
}
const text = node.textContent.trim();
if (!text) { scheduleHide(); return; }
const domNode = editor.view.nodeDOM(nodeStart);
if (!domNode || !(domNode instanceof HTMLElement)) {
scheduleHide();
return;
}
const rect = domNode.getBoundingClientRect();
clearHideTimer();
setHover({
top: rect.top,
left: rect.left,
height: rect.height,
nodeFrom: nodeStart + 1,
nodeTo: nodeStart + node.nodeSize - 1,
text,
});
};
editorDom.addEventListener("mousemove", handleMouseMove);
editorDom.addEventListener("mouseleave", scheduleHide);
return () => {
editorDom.removeEventListener("mousemove", handleMouseMove);
editorDom.removeEventListener("mouseleave", scheduleHide);
clearHideTimer();
};
}, [editor, clearHideTimer, scheduleHide]);
const handleClick = useCallback(() => {
if (!hover) return;
editor.chain().focus().setTextSelection({ from: hover.nodeFrom, to: hover.nodeTo }).run();
editor.commands.setAiHighlight(hover.nodeFrom, hover.nodeTo);
setWritingSelection({ from: hover.nodeFrom, to: hover.nodeTo, text: hover.text });
onAiRewrite();
}, [hover, editor, setWritingSelection, onAiRewrite]);
if (!hover) return null;
const btnTop = hover.top + hover.height / 2 - BTN_SIZE / 2;
const btnLeft = Math.max(4, hover.left - BTN_SIZE - BTN_GAP);
return createPortal(
<button
type="button"
style={{
position: "fixed",
top: btnTop,
left: btnLeft,
width: BTN_SIZE,
height: BTN_SIZE,
zIndex: 9998,
}}
onMouseEnter={clearHideTimer}
onMouseLeave={scheduleHide}
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center rounded-md",
"bg-background border border-border shadow-sm",
"text-muted-foreground hover:text-amber-500 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30",
"transition-all duration-150",
"animate-in fade-in zoom-in-90 duration-100",
)}
title="AI 改写此段落"
>
<Sparkles className="h-3.5 w-3.5" />
</button>,
document.body,
);
}
@@ -0,0 +1,307 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
RefreshCw, X, ArrowRightLeft, ArrowDownToLine,
Loader2, ChevronDown, Brain, SendHorizontal, Square,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
const ACTION_LABEL: Record<RewriteAction, string> = {
polish: "润色",
expand: "扩写",
shorten: "缩写",
tone: "改语气",
};
const TONE_LABEL: Record<RewriteTone, string> = {
professional: "更专业",
casual: "更口语化",
readable: "更易读",
academic: "更学术",
expressive: "更有文采",
};
interface MenuPos { x: number; y: number }
interface WritingRewritePreviewProps {
pos: MenuPos;
status: RewriteStatus;
originalText: string;
result: string | null;
thinking: string | null;
error: string | null;
action: RewriteAction;
tone?: RewriteTone;
onReplace: () => void;
onInsert: () => void;
onRetry: () => void;
onAbort: () => void;
onClose: () => void;
onFollowUp: (instruction: string) => void;
}
export function WritingRewritePreview({
pos,
status,
result,
thinking,
error,
action,
tone,
onReplace,
onInsert,
onRetry,
onAbort,
onClose,
onFollowUp,
}: WritingRewritePreviewProps) {
const isLoading = status === "loading";
const isError = status === "error";
const isSuccess = status === "success" && result !== null;
const hasResult = result !== null && result.length > 0;
// ── Follow-up input ─────────────────────────────────────────────────────
const [followUpInput, setFollowUpInput] = useState("");
const followUpInputRef = useRef<HTMLInputElement>(null);
// Auto-focus the follow-up input when generation finishes
useEffect(() => {
if (status === "success") {
setTimeout(() => followUpInputRef.current?.focus(), 50);
}
}, [status]);
const handleFollowUpSubmit = () => {
const instruction = followUpInput.trim();
if (!instruction || isLoading) return;
setFollowUpInput("");
onFollowUp(instruction);
};
// ── Thinking expand / collapse ──────────────────────────────────────────
const [thinkingOpen, setThinkingOpen] = useState(false);
const prevStatusRef = useRef<RewriteStatus>(status);
// Auto-expand when thinking content first streams in during loading
useEffect(() => {
if (isLoading && thinking) setThinkingOpen(true);
}, [isLoading, thinking]);
// Auto-collapse when generation finishes (loading → success/error)
useEffect(() => {
const prev = prevStatusRef.current;
prevStatusRef.current = status;
if (prev === "loading" && status !== "loading") {
setThinkingOpen(false);
}
}, [status]);
// ── Position ────────────────────────────────────────────────────────────
const panelWidth = 480;
const [computedPos, setComputedPos] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
const MARGIN = 8;
const GAP = 6;
const MIN_HEIGHT = 160;
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
const spaceBelow = window.innerHeight - pos.y - MARGIN;
const spaceAbove = pos.y - MARGIN;
if (spaceBelow >= spaceAbove) {
// More space below — anchor top at selection bottom, grow downward
setComputedPos({
left: safeX,
top: pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
});
} else {
// More space above — anchor bottom at selection top, grow upward
setComputedPos({
left: safeX,
bottom: window.innerHeight - pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
});
}
}, [pos.x, pos.y]);
const actionLabel = tone
? `${ACTION_LABEL[action]} · ${TONE_LABEL[tone]}`
: ACTION_LABEL[action];
return createPortal(
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
...(computedPos?.bottom !== undefined
? { bottom: computedPos.bottom }
: { top: computedPos?.top ?? (pos.y + 6) }),
maxHeight: computedPos?.maxHeight,
width: panelWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="flex flex-col rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
<span className="text-xs font-semibold text-foreground">{actionLabel}</span>
<div className="flex items-center gap-1">
{isLoading && (
<button
type="button"
onClick={onAbort}
className="rounded p-1 hover:bg-red-100 dark:hover:bg-red-950/40 text-red-500 hover:text-red-600 transition-colors"
title="停止生成"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
)}
<button
type="button"
onClick={onRetry}
disabled={isLoading}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
title="重试"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Body */}
<div className="p-3 space-y-2 overflow-y-auto flex-1 min-h-0">
{/* Thinking — collapsible with animation */}
{thinking && (
<div className="rounded-md border border-amber-200/60 bg-amber-50/40 dark:border-amber-800/40 dark:bg-amber-950/20 overflow-hidden">
{/* Toggle button */}
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100/40 dark:hover:bg-amber-900/20 transition-colors"
>
<Brain className="h-3 w-3 shrink-0" />
<span className="flex-1 text-left">思考过程</span>
{isLoading && (
<span className="text-[9px] text-amber-600/70 dark:text-amber-500/60 mr-1">
思考中…
</span>
)}
<ChevronDown
className={cn(
"h-3 w-3 transition-transform duration-300",
thinkingOpen && "rotate-180",
)}
/>
</button>
{/* Animated content — grid-rows trick for smooth height transition */}
<div
style={{ gridTemplateRows: thinkingOpen ? "1fr" : "0fr" }}
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
>
<div className="overflow-hidden">
<div className="px-3 pb-2.5 pt-1.5 text-[11px] text-amber-800/80 dark:text-amber-300/70 leading-relaxed whitespace-pre-wrap border-t border-amber-200/40 dark:border-amber-800/30 max-h-44 overflow-y-auto">
{thinking}
{isLoading && (
<span className="inline-block w-0.5 h-3 bg-amber-500 animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
</div>
</div>
)}
{/* Result area */}
{/* Loading — no text yet */}
{isLoading && !hasResult && (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
AI 正在生成…
</div>
)}
{/* Error */}
{isError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive leading-relaxed">
{error ?? "改写失败,请重试"}
</div>
)}
{/* Result text — show while streaming and after success */}
{hasResult && (
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{result}
{isLoading && (
<span className="inline-block w-0.5 h-3.5 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
)}
</div>
{/* Footer actions — only after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-muted/20">
<Button size="sm" className="flex-1 h-7 text-xs gap-1.5" onClick={onReplace}>
<ArrowRightLeft className="h-3 w-3" />
替换原文
</Button>
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs gap-1.5" onClick={onInsert}>
<ArrowDownToLine className="h-3 w-3" />
插入后面
</Button>
</div>
)}
{/* Follow-up chat input — shown after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-background">
<input
ref={followUpInputRef}
type="text"
value={followUpInput}
onChange={(e) => setFollowUpInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleFollowUpSubmit();
}
}}
placeholder="继续优化,如:更简洁一些、语气更正式…"
className="flex-1 text-xs bg-muted/50 rounded-md px-2.5 py-1.5 outline-none border border-input focus:border-primary/50 transition-colors placeholder:text-muted-foreground/50"
/>
<button
type="button"
onClick={handleFollowUpSubmit}
disabled={!followUpInput.trim()}
className="shrink-0 rounded-md p-1.5 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
<SendHorizontal className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</>,
document.body,
);
}
@@ -0,0 +1,182 @@
import type { Editor } from "@tiptap/react";
import {
Bold,
Italic,
Underline,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Pilcrow,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
interface ToolbarButtonProps {
onClick: () => void;
active?: boolean;
disabled?: boolean;
tooltip: string;
children: React.ReactNode;
}
function ToolbarButton({ onClick, active, disabled, tooltip, children }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // prevent editor losing focus
onClick();
}}
disabled={disabled}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
active && "bg-accent text-accent-foreground font-semibold",
)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
);
}
interface WritingToolbarProps {
editor: Editor | null;
}
export function WritingToolbar({ editor }: WritingToolbarProps) {
if (!editor) return null;
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
<ToolbarButton
tooltip="正文"
active={editor.isActive("paragraph")}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="一级标题"
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="二级标题"
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="三级标题"
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3 className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Lists */}
<ToolbarButton
tooltip="无序列表"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="有序列表"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Blocks */}
<ToolbarButton
tooltip="引用"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="代码块"
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
<Code className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Inline marks */}
<ToolbarButton
tooltip="加粗 (Ctrl+B)"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="斜体 (Ctrl+I)"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="下划线 (Ctrl+U)"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="删除线"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Model selector — pushed to the right */}
<div className="ml-auto">
<CanvasModelSelector />
</div>
</div>
);
}
@@ -1,16 +1,47 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import {
CheckIcon,
ChevronDownIcon,
GraduationCapIcon,
LightbulbIcon,
RocketIcon,
SendHorizonal,
Sparkles,
Square,
X,
ZapIcon,
} from "lucide-react";
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
@@ -22,174 +53,15 @@ import {
// Types
// ---------------------------------------------------------------------------
type ClarificationOption = {
id: string;
label: string;
description?: string;
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
const MODE_LABELS: Record<CanvasMode, string> = {
flash: "快速",
thinking: "推理",
pro: "深入",
ultra: "超级",
};
type ClarificationPayload = {
question?: string;
context?: string | null;
options?: ClarificationOption[];
allow_custom?: boolean;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripThinkBlocks(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
const c = msg.additional_kwargs?.clarification;
if (typeof c !== "object" || c === null) return null;
return c as ClarificationPayload;
}
function getTextContent(msg: CanvasMessage): string {
return typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
}
// ---------------------------------------------------------------------------
// ClarificationCard
// ---------------------------------------------------------------------------
function ClarificationCard({
message,
disabled,
onSubmit,
}: {
message: CanvasMessage;
disabled: boolean;
onSubmit: (answer: string) => void;
}) {
const payload = getClarificationPayload(message)!;
const [customAnswer, setCustomAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const options = (payload.options ?? []).filter(
(o) => typeof o.label === "string" && o.label.trim(),
);
const busy = disabled || submitted;
function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || busy) return;
setSubmitted(true);
onSubmit(trimmed);
}
const fallback = stripThinkBlocks(getTextContent(message));
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 || fallback}
</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={busy}
onClick={() => submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-sm">{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={(e) => setCustomAnswer(e.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={busy}
className="min-h-20 resize-none text-sm"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={busy || !customAnswer.trim()}
onClick={() => submitAnswer(customAnswer)}
>
<SendHorizonal className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// AiMessageContent — renders a single AI message bubble
// ---------------------------------------------------------------------------
function AiMessageContent({
message,
isActiveStream,
isStreaming,
onClarificationSubmit,
}: {
message: CanvasMessage;
isActiveStream: boolean;
isStreaming: boolean;
onClarificationSubmit: (answer: string) => void;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
const clarification = getClarificationPayload(message);
if (clarification) {
return (
<ClarificationCard
message={message}
disabled={isStreaming}
onSubmit={onClarificationSubmit}
/>
);
}
const displayText = stripThinkBlocks(getTextContent(message));
if (!displayText) return null;
return (
<MarkdownContent
content={displayText}
isLoading={isActiveStream}
rehypePlugins={rehypePlugins}
className="text-sm"
/>
);
}
// ---------------------------------------------------------------------------
// ChatWelcome
// ---------------------------------------------------------------------------
@@ -212,20 +84,47 @@ function ChatWelcome() {
);
}
// ---------------------------------------------------------------------------
// ModeIcon
// ---------------------------------------------------------------------------
function ModeIcon({
mode,
className,
}: {
mode: CanvasMode;
className?: string;
}) {
if (mode === "flash") return <ZapIcon className={className} />;
if (mode === "thinking") return <LightbulbIcon className={className} />;
if (mode === "pro") return <GraduationCapIcon className={className} />;
return <RocketIcon className={cn(className, "text-[#dabb5e]")} />;
}
// ---------------------------------------------------------------------------
// CanvasChatPanel
// ---------------------------------------------------------------------------
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const { thread, sendMessage, threadId } = useCanvasStreamContext();
const { modelName, setModelName } = useCanvasThreadContext();
const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const { models } = useModels();
const [input, setInput] = useState("");
const [mode, setMode] = useState<CanvasMode>("pro");
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection);
const isStreaming = thread.isLoading;
const hasMessages = thread.messages.length > 0;
const selectedModel = useMemo(
() => models.find((m) => m.name === modelName) ?? models[0],
[models, modelName],
);
const supportsThinking = selectedModel?.supports_thinking ?? false;
// Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact);
@@ -252,14 +151,13 @@ export function CanvasChatPanel() {
return null;
})();
// Scroll to bottom whenever messages change or streaming state updates
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
async function handleSend() {
if (isStreaming) {
await thread.stop();
return;
}
const text = input.trim();
if (!text || isStreaming) return;
if (!text) return;
setInput("");
const finalText = hasSelection
@@ -267,7 +165,7 @@ export function CanvasChatPanel() {
: text;
clearSelection();
await sendMessage(finalText);
await sendMessage(finalText, { mode });
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
@@ -277,136 +175,232 @@ export function CanvasChatPanel() {
}
}
// Index of the last AI message — used to decide which message gets streaming animation
const lastAiIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.type !== "human") return i;
}
return -1;
})();
const handleClarificationSubmit = useCallback(
async (answer: string) => {
await sendMessage(answer, { mode });
},
[sendMessage, mode],
);
// Whether the last AI message has any visible text (not just think blocks / empty)
const lastAiHasVisibleContent = (() => {
if (lastAiIndex < 0) return false;
const msg = messages[lastAiIndex]!;
if (getClarificationPayload(msg)) return true;
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
})();
const handleModelSelect = useCallback(
(name: string) => {
setModelName(name);
setModelDialogOpen(false);
// If selected model doesn't support thinking, fall back to flash
const model = models.find((m) => m.name === name);
if (!model?.supports_thinking && mode === "thinking") {
setMode("flash");
}
},
[models, mode, setModelName],
);
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
// BaseStream<CanvasThreadValues> — structurally compatible for message rendering
// since MessageList only reads thread.messages, isLoading, and isThreadLoading.
const threadForList = thread as unknown as BaseStream<AgentThreadState>;
return (
<div className="h-full flex flex-col bg-background">
{/* Message list */}
<div className="flex-1 overflow-y-auto min-h-0">
{hasMessages ? (
<div className="p-4 space-y-4">
{messages.map((msg, i) => {
const isHuman = msg.type === "human";
return (
<div
key={msg.id ?? i}
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
>
{!isHuman && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
)}
{isHuman ? (
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
{getTextContent(msg)}
</div>
) : (
<div className="max-w-[85%] min-w-0">
<AiMessageContent
message={msg}
isActiveStream={isStreaming && i === lastAiIndex}
isStreaming={isStreaming}
onClarificationSubmit={(answer) => void sendMessage(answer)}
/>
</div>
)}
{isHuman && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3 h-3" />
</div>
)}
</div>
);
})}
{isStreaming && !lastAiHasVisibleContent && (
<div className="flex gap-2 justify-start">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">正在生成…</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{hasMessages || isStreaming || thread.isThreadLoading ? (
<MessageList
className="size-full"
threadId={threadId ?? "canvas"}
thread={threadForList}
paddingBottom={0}
onClarificationSubmit={handleClarificationSubmit}
/>
) : (
<ChatWelcome />
)}
</div>
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2 shrink-0">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Input area */}
<div className="border-t p-3 shrink-0">
<div className="flex gap-2 items-end">
<div className="p-3 shrink-0">
<div className="rounded-2xl border border-border/70 bg-background shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] overflow-hidden transition-shadow focus-within:shadow-[0_2px_16px_-4px_rgba(0,0,0,0.14)] dark:shadow-none dark:focus-within:ring-1 dark:focus-within:ring-border">
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2.5">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Textarea */}
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
hasSelection
? "针对选中内容发出指令…"
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
: "发消息…"
}
className="min-h-[40px] max-h-[160px] resize-none text-sm"
rows={1}
className="border-0 shadow-none rounded-none resize-none text-sm min-h-[60px] max-h-[160px] focus-visible:ring-0 px-3 pt-3 pb-2"
rows={2}
disabled={isStreaming}
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()}
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 pb-2 pt-1 border-t border-border/40">
{/* Mode selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60"
>
<ModeIcon mode={mode} className="h-3 w-3" />
<span>{MODE_LABELS[mode]}</span>
<ChevronDownIcon className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
推理模式
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "flash" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("flash")}
>
<ZapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">快速</span>
<span className="text-xs opacity-70">低延迟,即时响应</span>
</div>
{mode === "flash" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
{supportsThinking && (
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "thinking" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("thinking")}
>
<LightbulbIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">推理</span>
<span className="text-xs opacity-70">思维链推导</span>
</div>
{mode === "thinking" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "pro" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("pro")}
>
<GraduationCapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">深入</span>
<span className="text-xs opacity-70">深度分析与研究</span>
</div>
{mode === "pro" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "ultra" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("ultra")}
>
<RocketIcon className={cn("h-3.5 w-3.5", mode === "ultra" && "text-[#dabb5e]")} />
<div className="flex flex-col flex-1">
<span className={cn("font-medium", mode === "ultra" && "text-[#dabb5e]")}>超级</span>
<span className="text-xs opacity-70">全力以赴,最强推理</span>
</div>
{mode === "ultra" && <CheckIcon className="h-3.5 w-3.5 ml-auto text-[#dabb5e]" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Model selector */}
{models.length > 0 && (
<ModelSelector open={modelDialogOpen} onOpenChange={setModelDialogOpen}>
<ModelSelectorTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 max-w-[120px]"
>
<ModelSelectorName className="truncate text-xs font-normal">
{selectedModel?.display_name ?? "选择模型"}
</ModelSelectorName>
<ChevronDownIcon className="h-3 w-3 shrink-0 opacity-60" />
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent title="选择模型">
<ModelSelectorInput placeholder="搜索模型…" />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{m.model}
</span>
</div>
{m.name === modelName ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
)}
</Button>
{/* Spacer */}
<div className="flex-1" />
{/* Send / Stop button */}
<Button
size="icon"
variant={isStreaming ? "secondary" : "default"}
className={cn(
"h-7 w-7 shrink-0 rounded-full",
isStreaming && "text-muted-foreground",
)}
onClick={() => void handleSend()}
disabled={!isStreaming && !input.trim()}
>
{isStreaming ? (
<Square className="h-3 w-3 fill-current" />
) : (
<SendHorizonal className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
@@ -6,17 +6,23 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
type LeftMode = "chat" | "writing";
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
useEffect(() => {
layoutRef.current?.setLayout(
@@ -25,6 +31,7 @@ export function CanvasResizableLayout() {
}, [isChatCollapsed]);
return (
<CanvasStreamProvider>
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
@@ -37,9 +44,29 @@ export function CanvasResizableLayout() {
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
className="overflow-hidden flex flex-col"
>
<CanvasChatPanel />
{/* 模式切换标签栏 */}
<div className="flex shrink-0 border-b bg-muted/30">
{(["chat", "writing"] as LeftMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLeftMode(mode)}
className={cn(
"flex-1 py-1.5 text-xs font-medium transition-colors",
leftMode === mode
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode === "chat" ? "对话" : "AI 写作"}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -54,5 +81,6 @@ export function CanvasResizableLayout() {
</ResizablePanel>
</ResizablePanelGroup>
</div>
</CanvasStreamProvider>
);
}
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
@@ -42,9 +43,11 @@ export function CanvasShell() {
}, [threadId, setMessages, setArtifact, setThreadId]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,24 @@
import { createContext, useContext, type ReactNode } from "react";
import { useCanvasThreadStream } from "../hooks/useCanvasThreadStream";
type CanvasStreamContextValue = ReturnType<typeof useCanvasThreadStream>;
const CanvasStreamContext = createContext<CanvasStreamContextValue | null>(null);
export function CanvasStreamProvider({ children }: { children: ReactNode }) {
const value = useCanvasThreadStream();
return (
<CanvasStreamContext.Provider value={value}>
{children}
</CanvasStreamContext.Provider>
);
}
export function useCanvasStreamContext() {
const ctx = useContext(CanvasStreamContext);
if (!ctx)
throw new Error(
"useCanvasStreamContext must be used within CanvasStreamProvider",
);
return ctx;
}
@@ -10,6 +10,7 @@ import type {
CanvasCodeSelection,
CanvasSelectionState,
CanvasTextSelection,
WritingSelection,
} from "../types/selection";
export interface CanvasUIContextValue {
@@ -17,6 +18,8 @@ export interface CanvasUIContextValue {
isEditing: boolean;
isWebSearchOpen: boolean;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
@@ -25,6 +28,7 @@ export interface CanvasUIContextValue {
/** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void;
setWritingSelection: (s: WritingSelection | null) => void;
}
const EMPTY_SELECTION: CanvasSelectionState = {
@@ -40,6 +44,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
useState<WritingSelection | null>(null);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null });
@@ -56,12 +62,14 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isEditing,
isWebSearchOpen,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
setWritingSelection,
}}
>
{children}
@@ -0,0 +1,79 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
interface HighlightRange {
from: number;
to: number;
}
const aiHighlightKey = new PluginKey<HighlightRange | null>("aiHighlight");
// Augment Tiptap's Commands interface so TS knows about our custom commands
declare module "@tiptap/core" {
interface Commands<ReturnType> {
aiHighlight: {
setAiHighlight: (from: number, to: number) => ReturnType;
clearAiHighlight: () => ReturnType;
};
}
}
export const AiHighlightExtension = Extension.create({
name: "aiHighlight",
addCommands() {
return {
setAiHighlight:
(from: number, to: number) =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, { from, to }));
return true;
},
clearAiHighlight:
() =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, null));
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<HighlightRange | null>({
key: aiHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(aiHighlightKey);
// explicit set / clear via command
if (meta !== undefined) return meta as HighlightRange | null;
// map positions through doc changes so highlight survives edits
if (tr.docChanged && prev) {
return {
from: tr.mapping.map(prev.from),
to: tr.mapping.map(prev.to),
};
}
return prev;
},
},
props: {
decorations(state) {
const range = aiHighlightKey.getState(state);
if (!range) return DecorationSet.empty;
const { from, to } = range;
const docSize = state.doc.content.size;
if (from >= to || from < 0 || to > docSize) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.inline(from, to, { class: "ai-rewrite-highlight" }),
]);
},
},
}),
];
},
});
@@ -0,0 +1,11 @@
import type { Editor } from "@tiptap/react";
import { EditorContent } from "@tiptap/react";
interface TiptapEditorProps {
editor: Editor | null;
className?: string;
}
export function TiptapEditor({ editor, className }: TiptapEditorProps) {
return <EditorContent editor={editor} className={className} />;
}
@@ -0,0 +1,23 @@
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { AiHighlightExtension } from "./AiHighlightExtension";
export const buildExtensions = (placeholder = "开始写作…") => [
StarterKit.configure({
// Disable hard break so Enter creates new paragraph
hardBreak: false,
}),
Underline,
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
}),
Markdown.configure({
html: false,
transformCopiedText: true,
transformPastedText: true,
}),
AiHighlightExtension,
];
@@ -0,0 +1,422 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'research_progress':
// 素材收集专家实时进度:仅更新最新提示文案
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const chunkText = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
};
}
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = {
category, isDone: false,
streamingText: '', thinkingText: '',
score: 0, issues: [],
};
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
// 兜底:没收到任何 chunk 直接来 done
items.push({
category, isDone: true,
streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error': {
// error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案
const msg = event.detail
? `${event.title ?? '出错了'}:${event.detail}`
: (event.message ?? '未知错误');
return { ...base, status: 'error', error: msg };
}
// notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态
case 'notice':
return base;
default:
return base;
}
});
// 草稿实时同步到右侧 Artifact 面板
if (event.type === 'draft_ready' && event.draftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [
{
index: 0,
type: 'text',
title: event.draftTitle ?? '草稿',
fullMarkdown: event.draftMarkdown,
},
],
});
}
},
[setArtifact],
);
const startWriting = useCallback(
async (request: AIWritingRequest) => {
if (
state.status !== 'idle' &&
state.status !== 'done' &&
state.status !== 'error'
)
return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching' });
try {
const sessionId = await startWritingSession(request);
setState((prev) => ({ ...prev, sessionId }));
unsubRef.current = subscribeToProgress(
sessionId,
handleEvent,
abortRef.current.signal,
);
} catch (err) {
setState((prev) => ({
...prev,
status: 'error',
error: String(err),
}));
}
},
[state.status, handleEvent],
);
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// Optimistic next-status so the correct skeleton shows immediately after
// the user clicks confirm, without waiting for the first SSE chunk.
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
}
try {
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
status: nextStatus,
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
import {
listAIWritingSessions,
renameAIWritingSession,
deleteAIWritingSession,
} from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
export function useAIWritingSessions() {
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listAIWritingSessions();
setSessions(data);
} catch {
// silently ignore — history is non-critical
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
try {
const updated = await renameAIWritingSession(id, title);
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
} catch {
// ignore
}
}, []);
const remove = useCallback(async (id: string) => {
try {
await deleteAIWritingSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore
}
}, []);
return { sessions, loading, refresh, rename, remove };
}
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -1,13 +1,13 @@
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../contexts/CanvasStreamContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream();
const { sendMessage } = useCanvasStreamContext();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
@@ -15,7 +15,7 @@ export function useCanvasQuickActions() {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
const message = buildQuickActionMessage(actionId);
const message = buildQuickActionMessage(actionId, content);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
@@ -0,0 +1,254 @@
// Canvas-specific variant of useThreadStream (core/threads/hooks.ts).
// Copied and adapted for canvas_agent so canvas writing-mode interactions
// can be customized independently from the main lead_agent flow.
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { getCanvasAPIClient } from "@/core/api/canvas-client";
import { useUpdateSubtask } from "@/core/tasks/context";
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
import type { StreamingArtifactPartial } from "../api/runs";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasThreadValues } from "../types/thread";
import { getCurrentArtifactContent } from "../utils/artifact";
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) return error;
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) return message;
}
return "Request failed.";
}
/**
* Canvas-specific stream hook — a direct copy of the main agent's useThreadStream,
* configured for canvas_agent. Modify sendMessage here to inject canvas-specific
* context (writing_artifact_path, canvas_mode, highlighted text, etc.).
*/
export function useCanvasThreadStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const { artifact, setArtifact, setIsStreaming, setStreamingArtifact } = useCanvasGraphContext();
const updateSubtask = useUpdateSubtask();
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const sendInFlightRef = useRef(false);
const prevMsgCountRef = useRef(0);
const [onStreamThreadId, setOnStreamThreadId] = useState<string | null>(
() => threadId,
);
const threadIdRef = useRef<string | null>(threadId ?? null);
useEffect(() => {
const normalized = threadId ?? null;
setOnStreamThreadId(normalized);
threadIdRef.current = normalized;
if (!normalized) {
sendInFlightRef.current = false;
prevMsgCountRef.current = 0;
setOptimisticMessages([]);
}
}, [threadId]);
const thread = useStream<CanvasThreadValues>({
client: getCanvasAPIClient(),
assistantId: CANVAS_AGENT_ID,
threadId: onStreamThreadId,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
setOnStreamThreadId(meta.thread_id);
threadIdRef.current = meta.thread_id;
setThreadId(meta.thread_id);
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
void getCanvasAPIClient()
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
.catch(() => {});
},
onUpdateEvent(data) {
const updates: Array<Partial<CanvasThreadValues> | null> = Object.values(
data || {},
);
for (const update of updates) {
if (update?.artifact) {
setArtifact(update.artifact as CanvasArtifact);
}
}
},
onCustomEvent(event: unknown) {
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "llm_retry" &&
"message" in event &&
typeof (event as { message: unknown }).message === "string"
) {
toast((event as { message: string }).message);
}
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
},
});
// Keep CanvasGraphContext.isStreaming in sync so the artifact panel and
// quick-action bar can react to streaming state.
useEffect(() => {
setIsStreaming(thread.isLoading);
}, [thread.isLoading, setIsStreaming]);
// Detect update_artifact tool calls from streaming messages to populate
// streamingArtifact so the right panel shows content before the final
// state update fires via onUpdateEvent.
useEffect(() => {
if (!thread.isLoading) {
setStreamingArtifact(null);
return;
}
const msgs = thread.messages;
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg.type !== "ai") continue;
const toolCalls = (msg as AIMessage).tool_calls;
if (!toolCalls?.length) continue;
const call = toolCalls.find((tc) => tc.name === "update_artifact");
if (!call?.args) continue;
const args = call.args as Record<string, unknown>;
const partial: StreamingArtifactPartial = {
type: args.type as "text" | "code" | undefined,
title: typeof args.title === "string" ? args.title : undefined,
content: typeof args.content === "string" ? args.content : undefined,
language: typeof args.language === "string" ? args.language : undefined,
};
if (partial.title !== undefined || partial.content !== undefined) {
setStreamingArtifact(partial);
return;
}
}
}, [thread.messages, thread.isLoading, setStreamingArtifact]);
// Clear optimistic messages once the server stream begins delivering real ones.
useEffect(() => {
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
/**
* Canvas writing-mode sendMessage — copied from ChatPage's handleSubmit flow.
* Modify here to inject writing_artifact_path, canvas_mode, highlighted
* text/code, or any other canvas-specific context the agent needs.
*/
const sendMessage = useCallback(
async (text: string, extraContext?: Record<string, unknown>) => {
const trimmed = text.trim();
if (!trimmed || sendInFlightRef.current) return;
sendInFlightRef.current = true;
prevMsgCountRef.current = thread.messages.length;
setOptimisticMessages([
{
type: "human",
id: `opt-human-${Date.now()}`,
content: [{ type: "text", text: trimmed }],
additional_kwargs: {},
},
]);
// Inject current artifact content into the human message so the LLM can see
// what to edit. create_agent only passes state.messages to the LLM — state.artifact
// is NOT automatically visible. Skip injection when the caller already embedded
// the artifact (e.g. quick actions build the full message themselves).
const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
const artifactContext = currentContent
? {
canvas_artifact_title: currentContent.title,
canvas_artifact_type: currentContent.type,
}
: {};
const messageText =
currentContent && !trimmed.includes("<artifact")
? (() => {
const body =
currentContent.type === "code"
? currentContent.code
: currentContent.fullMarkdown;
const tag =
currentContent.type === "code"
? `language="${currentContent.language}"`
: "";
return `${trimmed}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
})()
: trimmed;
try {
await thread.submit(
{
messages: [
{ type: "human", content: [{ type: "text", text: messageText }] },
],
},
{
threadId: threadIdRef.current ?? undefined,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
...(modelName ? { configurable: { model_name: modelName } } : {}),
},
context: {
...extraContext,
...artifactContext,
thread_id: threadIdRef.current ?? "",
writing_mode: true,
},
},
);
} catch {
setOptimisticMessages([]);
} finally {
sendInFlightRef.current = false;
}
},
[thread, modelName, artifact],
);
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
return { thread: mergedThread, sendMessage, threadId };
}
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useEditor } from "@tiptap/react";
import { buildExtensions } from "../editor/TiptapExtensions";
interface UseWritingEditorOptions {
markdown: string;
onChange?: (markdown: string) => void;
editable?: boolean;
placeholder?: string;
}
export function useWritingEditor({
markdown,
onChange,
editable = true,
placeholder,
}: UseWritingEditorOptions) {
const prevMarkdownRef = useRef<string>(markdown);
const editor = useEditor({
extensions: buildExtensions(placeholder),
editable,
content: markdown,
onUpdate({ editor }) {
if (!onChange) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
onChange(md);
},
});
// Sync external markdown changes (e.g. after streaming completes)
useEffect(() => {
if (!editor) return;
if (markdown === prevMarkdownRef.current) return;
prevMarkdownRef.current = markdown;
const { from, to } = editor.state.selection;
editor.commands.setContent(markdown, { emitUpdate: false });
const docSize = editor.state.doc.content.size;
if (from <= docSize && to <= docSize) {
editor.commands.setTextSelection({ from, to });
}
}, [editor, markdown]);
// Sync editable flag
useEffect(() => {
if (!editor) return;
editor.setEditable(editable);
}, [editor, editable]);
return editor;
}
@@ -0,0 +1,206 @@
import { useCallback, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import {
streamRewrite,
type RewriteAction,
type RewriteRequest,
type RewriteTone,
} from "../api/rewrite";
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
export type RewriteStatus = "idle" | "loading" | "success" | "error";
// ── <think>…</think> tag stripper ────────────────────────────────────────────
// Handles tags split across multiple streaming chunks.
class ThinkTagParser {
private buf = "";
private inside = false;
push(chunk: string): { thinking: string; text: string } {
this.buf += chunk;
let thinking = "";
let text = "";
// eslint-disable-next-line no-constant-condition
while (true) {
if (!this.inside) {
const idx = this.buf.indexOf("<think>");
if (idx !== -1) {
text += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 7);
this.inside = true;
} else {
// hold last 6 chars to guard against a partial opening tag
const safe = Math.max(0, this.buf.length - 6);
text += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
} else {
const idx = this.buf.indexOf("</think>");
if (idx !== -1) {
thinking += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 8);
this.inside = false;
} else {
// hold last 7 chars to guard against a partial closing tag
const safe = Math.max(0, this.buf.length - 7);
thinking += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
}
}
return { thinking, text };
}
flush(): { thinking: string; text: string } {
const out = {
thinking: this.inside ? this.buf : "",
text: this.inside ? "" : this.buf,
};
this.buf = "";
return out;
}
}
// ─────────────────────────────────────────────────────────────────────────────
export function useWritingRewrite(editor: Editor | null) {
const { writingSelection } = useCanvasUIContext();
const { threadId, modelName } = useCanvasThreadContext();
const [status, setStatus] = useState<RewriteStatus>("idle");
const [result, setResult] = useState<string | null>(null);
const [thinking, setThinking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Shared streaming runner — state setters from useState are stable references
const runStream = useCallback(
async (req: RewriteRequest, abort: AbortController) => {
const parser = new ThinkTagParser();
const applyParsed = (parsed: { thinking: string; text: string }) => {
if (parsed.thinking) setThinking((p) => (p ?? "") + parsed.thinking);
if (parsed.text) setResult((p) => (p ?? "") + parsed.text);
};
try {
let terminated = false;
for await (const event of streamRewrite(req, abort.signal)) {
if (abort.signal.aborted) break;
if (event.type === "thinking") {
setThinking((p) => (p ?? "") + event.chunk);
} else if (event.type === "text") {
applyParsed(parser.push(event.chunk));
} else if (event.type === "done") {
applyParsed(parser.flush());
setStatus("success");
terminated = true;
break;
} else if (event.type === "error") {
setError(event.message ?? "改写失败,请重试");
setStatus("error");
terminated = true;
break;
}
}
if (!terminated && !abort.signal.aborted) {
applyParsed(parser.flush());
setStatus("success");
}
} catch (e) {
if (abort.signal.aborted) return;
setError(e instanceof Error ? e.message : "改写失败,请重试");
setStatus("error");
}
},
[], // eslint-disable-line react-hooks/exhaustive-deps
);
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
if (!writingSelection || !editor) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
customInstruction: customInstruction || undefined,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, modelName, editor, runStream],
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
if (!writingSelection || !editor) return;
const prevResult = result;
if (!prevResult) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
followUpInstruction: instruction,
previousResult: prevResult,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, modelName, editor, result, runStream],
);
const reset = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus("idle");
setResult(null);
setThinking(null);
setError(null);
}, []);
return { status, result, thinking, error, rewrite, rewriteFollowUp, reset };
}
@@ -0,0 +1,74 @@
import { useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<AIWritingPageHeader />
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,431 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const req: CreateArticleTypeRequest = {
key: trimmedKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="标识 (key)" required>
<input
className={INPUT_CLASS}
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
value={form.key}
onChange={(e) => setField('key', e.target.value)}
disabled={!!editingId}
/>
{editingId && (
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
)}
</FormField>
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
{type.key}
</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
))}
</div>
{/* AI 智能写作入口 */}
<button
onClick={() => navigate('ai-writing')}
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
</p>
</div>
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
</button>
<div className="text-center">
<Button
variant="ghost"
@@ -2,11 +2,15 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -0,0 +1,221 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
export interface AIMaterial {
id: string;
title: string;
content: string;
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
url?: string;
recUuid?: string;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
export interface OutlineSection {
sectionTitle: string;
keyPoints: string[];
materialIds: string[];
}
export interface AIOutline {
title: string;
sections: OutlineSection[];
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
factScore: number;
logicScore: number;
languageScore: number;
overallScore: number;
passThreshold: number;
revisionNotes: ReviewIssue[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_chunk'
| 'review_item_done'
| 'review_ready'
| 'await_user'
| 'resumed'
| 'notice'
| 'done'
| 'error';
export interface ProgressEvent {
type: ProgressEventType;
sessionId: string;
agentName?: string;
message: string;
// notice(非致命告警)/ error:标题 + 真实原因
title?: string;
detail?: string;
// materials_ready
materials?: AIMaterial[];
keywords?: string[];
materialSummary?: string;
// outline_ready
outline?: AIOutline;
// draft_ready / draft_chunk
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pauseContext?: Record<string, unknown>;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
extraKeywords?: string[];
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean;
// 流式过程文本(正文 / 思考分开)
streamingText: string;
thinkingText: string;
// 完成后的结构化结果
score: number;
issues: ReviewIssue[];
}
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
export interface RevisionSummary {
revisedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
newWordCount: number;
}
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
export interface StreamingDraftSection {
sectionIndex: number;
sectionTitle: string;
text: string; // 正文内容(非思考)
thinkingText: string; // 思考内容(<think>...</think> 内)
isDone: boolean;
}
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
action: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
outline?: AIOutline;
// draft_confirm
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult;
// review_confirm
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
error: string | null;
// 流式草稿
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
revisionSummary: RevisionSummary | null;
// 待修改上下文(用于生成摘要)
pendingRevisionContext: {
submittedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
}
@@ -13,3 +13,10 @@ export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null;
};
/** Tiptap editor selection — used by AI rewrite flow */
export type WritingSelection = {
from: number;
to: number;
text: string;
};
@@ -0,0 +1,51 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
return paragraphs
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({
type: "paragraph",
content: p ? [{ type: "text", text: p }] : [],
}));
}
/**
* Replace the selection range with the new text.
* Restores focus and places cursor after the inserted content.
*/
export function applyReplace(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { from, to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, nodes)
.run();
}
/**
* Insert the new text as new paragraph(s) after the selection end.
* Original text is preserved.
*/
export function applyInsert(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor.chain().focus().insertContentAt(to, nodes).run();
}
@@ -1,16 +1,34 @@
import type { CanvasArtifactContent } from "../types/artifact";
import type { QuickActionId } from "../types/quick-action";
import { isCodeArtifact } from "./artifact";
const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
export function buildQuickActionMessage(id: QuickActionId): string {
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
/**
* Builds the human message for a quick action.
* The current artifact content is always included so the agent knows what to edit —
* LangGraph's create_agent only passes state.messages to the LLM, not state.artifact.
*/
export function buildQuickActionMessage(
id: QuickActionId,
content: CanvasArtifactContent,
): string {
const instruction = QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
const artifactContent = isCodeArtifact(content) ? content.code : content.fullMarkdown;
const tag = isCodeArtifact(content) ? `language="${content.language}"` : "";
return `${instruction}
<artifact ${tag}>
${artifactContent}
</artifact>`;
}
@@ -0,0 +1,12 @@
/**
* 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径:
* 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
* 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
*/
export function countWords(text: string): number {
if (!text) return 0;
const cjk = (text.match(/[一-鿿]/g) ?? []).length;
const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length;
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
return cjk + cjkPunct + alnumRuns;
}
+180
View File
@@ -566,6 +566,186 @@
}
}
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
.tiptap-writing-editor .ProseMirror {
outline: none;
min-height: 320px;
caret-color: var(--primary);
padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
}
.tiptap-writing-editor .ProseMirror p {
margin-bottom: 1rem;
line-height: 1.8;
font-size: 0.9375rem; /* 15px */
color: var(--foreground);
text-indent: 2em; /* 段落首行缩进 */
}
.tiptap-writing-editor .ProseMirror h1 {
font-size: 1.625rem;
font-weight: 700;
letter-spacing: -0.025em;
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--foreground);
line-height: 1.25;
}
.tiptap-writing-editor .ProseMirror h1:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.015em;
margin-top: 1.75rem;
margin-bottom: 0.625rem;
color: var(--foreground);
line-height: 1.35;
}
.tiptap-writing-editor .ProseMirror h3 {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--foreground);
line-height: 1.4;
}
.tiptap-writing-editor .ProseMirror h2:first-child,
.tiptap-writing-editor .ProseMirror h3:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror strong {
font-weight: 600;
}
.tiptap-writing-editor .ProseMirror em {
font-style: italic;
}
.tiptap-writing-editor .ProseMirror s {
text-decoration: line-through;
opacity: 0.6;
}
.tiptap-writing-editor .ProseMirror ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror li {
line-height: 1.8;
margin-bottom: 0.25rem;
}
.tiptap-writing-editor .ProseMirror li > p {
margin-bottom: 0.25rem;
text-indent: 0; /* 列表项内段落不缩进 */
}
.tiptap-writing-editor .ProseMirror blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
margin: 1.25rem 0;
font-style: italic;
font-size: 0.9375rem;
color: var(--muted-foreground);
}
.tiptap-writing-editor .ProseMirror pre {
background: var(--muted);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin: 1rem 0 1.25rem;
overflow-x: auto;
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem; /* 13px */
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
line-height: 1.7;
}
.tiptap-writing-editor .ProseMirror code {
background: var(--muted);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.8125rem;
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.tiptap-writing-editor .ProseMirror a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: opacity 0.15s;
}
.tiptap-writing-editor .ProseMirror a:hover {
opacity: 0.75;
}
/* Placeholder */
.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child {
text-indent: 0; /* 占位文案不缩进 */
}
.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
float: left;
height: 0;
opacity: 0.6;
}
/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
.tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #f87171 35%, transparent);
}
/* AI rewrite — highlight the selected range while the menu / preview is open */
.ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 30%, transparent);
border-radius: 2px;
transition: background 0.2s ease;
}
.dark .ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 18%, transparent);
}
:root {
--container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144);
@@ -22,6 +22,7 @@ from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.deps import langgraph_runtime
from app.gateway.routers import (
ai_writing,
agents,
artifacts,
assistants_compat,
@@ -44,6 +45,7 @@ from app.gateway.routers import (
thread_shares,
threads,
uploads,
writing,
)
from deerflow.config import app_config as deerflow_app_config
from deerflow.config.app_config import apply_logging_level
@@ -672,6 +674,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
# Writing rewrite API is mounted at /api/writing
app.include_router(writing.router)
# AI Writing multi-agent API is mounted at /api/ai-writing
app.include_router(ai_writing.router)
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
@@ -126,6 +126,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.thread_share_store = make_thread_share_store(sf)
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -234,6 +242,16 @@ def get_thread_share_store(request: Request):
return val
def get_ai_writing_session_store(request: Request):
"""Return the AI writing session store, or None when not configured."""
return getattr(request.app.state, "ai_writing_session_store", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -1,3 +1,3 @@
from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads
from . import ai_writing, artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads"]
__all__ = ["ai_writing", "artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
@@ -0,0 +1,640 @@
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
POST /api/ai-writing/start 启动写作流程,返回 session_id
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
GET /api/ai-writing/{id}/state 查询当前流程状态
GET /api/ai-writing/sessions 列出用户的会话历史
GET /api/ai-writing/sessions/{id} 获取单个历史会话
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
DELETE /api/ai-writing/sessions/{id} 删除会话
GET /api/ai-writing/article-types 列出所有文章类型
POST /api/ai-writing/article-types 新增文章类型
PATCH /api/ai-writing/article-types/{id} 更新文章类型
DELETE /api/ai-writing/article-types/{id} 删除文章类型
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from langgraph.types import Command
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai-writing", tags=["ai-writing"])
# ── 请求/响应模型 ─────────────────────────────────────────────────────────────
class StartWritingRequest(BaseModel):
user_intent: str
article_type: str = "analysis"
target_audience: str = "普通读者"
word_count_target: int = 800
model_name: str = ""
class StartWritingResponse(BaseModel):
session_id: str
message: str = "写作流程已启动"
class ResumeRequest(BaseModel):
action: str
pause_point: str = ""
approved_material_ids: list[str] = Field(default_factory=list)
extra_keywords: list[str] = Field(default_factory=list)
edited_outline: Optional[dict] = None
outline_feedback: str = ""
user_revision_notes: str = ""
override_verdict: bool = False
selected_issue_indices: Optional[list[int]] = None
class RenameSessionRequest(BaseModel):
title: str
class SaveTranscriptRequest(BaseModel):
# 前端组装的完整对话时间线(progressEvents + completedInterventions)
transcript: dict
class ArticleTypeRequest(BaseModel):
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int = 800
sort_order: int = 0
class UpdateArticleTypeRequest(BaseModel):
key: Optional[str] = None
label: Optional[str] = None
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: Optional[int] = None
sort_order: Optional[int] = None
class ArticleTypeResponse(BaseModel):
id: str
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int
sort_order: int
created_at: str
updated_at: str
class SessionResponse(BaseModel):
id: str
user_id: Optional[str] = None
title: str
user_intent: Optional[str] = None
status: str
draft_title: Optional[str] = None
draft_markdown: Optional[str] = None
completed_interventions: Optional[list] = None
review_result: Optional[dict] = None
transcript: Optional[dict] = None
created_at: str
updated_at: str
# ── 内部状态 ──────────────────────────────────────────────────────────────────
# session_id → asyncio.Queue
_event_queues: dict[str, asyncio.Queue] = {}
# session_id → model_name(resume 时复用)
_session_model_names: dict[str, str] = {}
# session_id → AIWritingSessionRepository(for DB saves)
_session_repos: dict[str, object] = {}
# session_id → user_id (str | None, already resolved)
_session_user_ids: dict[str, str | None] = {}
# session_id → accumulated intervention records
_session_interventions: dict[str, list] = {}
# session_id → True if session ended with error
_session_error_flags: dict[str, bool] = {}
# session_id → valid_article_types(list[dict] with key/label)
_session_article_types: dict[str, list[dict]] = {}
# session_id → pending await_user event tuple for SSE reconnect re-delivery
# asyncio.Queue is consume-once; if the old SSE generator dequeued the await_user event
# before the client disconnected, the event is lost from the queue. We save a copy here
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _ai_writing_config() -> dict:
"""读取 config.yaml 的 ai_writing 段(含素材检索 provider 配置)。"""
try:
from deerflow.config import get_app_config
aw = getattr(get_app_config(), "ai_writing", None)
if isinstance(aw, dict):
return aw
return dict(aw) if aw else {}
except Exception:
return {}
def _make_config(session_id: str, model_name: str = "", valid_article_types: list[dict] | None = None) -> dict:
return {
"configurable": {
"thread_id": session_id,
"model_name": model_name or _session_model_names.get(session_id, ""),
"ai_writing_config": _ai_writing_config(),
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
"sse_queue": _event_queues.get(session_id),
"sse_session_id": session_id,
# 动态文章类型列表,intent_parser 从此处读取有效枚举
"valid_article_types": valid_article_types or _session_article_types.get(session_id),
}
}
def _truncate_title(text: str, max_len: int = 50) -> str:
text = text.strip()
return text[:max_len] + ("…" if len(text) > max_len else "")
# ── 路由 ──────────────────────────────────────────────────────────────────────
@router.post("/start", response_model=StartWritingResponse)
async def start_writing(body: StartWritingRequest, request: Request):
"""启动写作流程,返回 session_id。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user
session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_model_names[session_id] = body.model_name
_session_interventions[session_id] = []
# 加载动态文章类型列表,注入给 intent_parser 节点
article_type_store = get_article_type_store(request)
valid_article_types: list[dict] | None = None
if article_type_store is not None:
try:
records = await article_type_store.list()
if records:
valid_article_types = [{"key": r["key"], "label": r["label"]} for r in records]
except Exception:
logger.exception("ai_writing: failed to load article types for session=%s", session_id)
_session_article_types[session_id] = valid_article_types or []
# 持久化会话记录(DB 可能未配置,则跳过)
repo = get_ai_writing_session_store(request)
user_id = await get_current_user(request)
if repo is not None:
try:
title = _truncate_title(body.user_intent)
await repo.create(
id=session_id,
title=title,
user_intent=body.user_intent,
user_id=user_id,
)
_session_repos[session_id] = repo
_session_user_ids[session_id] = user_id
except Exception:
logger.exception("ai_writing: failed to create session record: session=%s", session_id)
initial_state = {
"user_intent": body.user_intent,
"article_type": body.article_type,
"target_audience": body.target_audience,
"word_count_target": body.word_count_target,
"revision_count": 0,
"max_revisions": 3,
"status": "researching",
"drafts": [],
"progress_events": [],
"material_package": None,
"current_outline": None,
"current_draft": None,
"review_result": None,
"last_user_intervention": None,
}
config = _make_config(session_id, body.model_name, valid_article_types=valid_article_types)
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH))
return StartWritingResponse(session_id=session_id)
@router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
return []
user_id = await get_current_user(request)
return await repo.list(user_id=user_id)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str, request: Request):
"""获取单个历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
return record
@router.patch("/sessions/{session_id}", response_model=SessionResponse)
async def rename_session(session_id: str, body: RenameSessionRequest, request: Request):
"""重命名会话标题。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, title=body.title.strip())
if updated is None:
raise HTTPException(status_code=404, detail="Session not found")
return updated
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, request: Request):
"""删除历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
deleted = await repo.delete(session_id, user_id=user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
return {"success": True}
@router.put("/sessions/{session_id}/transcript")
async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request):
"""保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
return {"success": False}
user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript)
if updated is None:
raise HTTPException(status_code=404, detail="Session not found")
return {"success": True}
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
return []
return await store.list()
@router.post("/article-types", response_model=ArticleTypeResponse, status_code=201)
async def create_article_type(body: ArticleTypeRequest, request: Request):
"""新增文章类型。key 必须全局唯一。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
existing = await store.get_by_key(body.key)
if existing is not None:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
return await store.create(
key=body.key,
label=body.label,
description=body.description,
prompt_hint=body.prompt_hint,
default_word_count=body.default_word_count,
sort_order=body.sort_order,
)
@router.patch("/article-types/{article_type_id}", response_model=ArticleTypeResponse)
async def update_article_type(article_type_id: str, body: UpdateArticleTypeRequest, request: Request):
"""更新文章类型字段(只传需要修改的字段)。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
# 如果修改了 key,检查唯一性
if body.key is not None:
existing = await store.get_by_key(body.key)
if existing is not None and existing["id"] != article_type_id:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
_UNSET = object.__new__(object)
kwargs = {}
for field in ("key", "label", "description", "prompt_hint", "default_word_count", "sort_order"):
val = getattr(body, field)
if val is not None:
kwargs[field] = val
updated = await store.update(article_type_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Article type not found")
return updated
@router.delete("/article-types/{article_type_id}")
async def delete_article_type(article_type_id: str, request: Request):
"""删除文章类型。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
deleted = await store.delete(article_type_id)
if not deleted:
raise HTTPException(status_code=404, detail="Article type not found")
return {"success": True}
@router.get("/{session_id}/stream")
async def stream_progress(session_id: str):
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
return StreamingResponse(
_sse_generator(session_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/{session_id}/resume")
async def resume_writing(session_id: str, body: ResumeRequest, request: Request):
"""用户提交干预决策,恢复暂停的流程。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
from app.gateway.deps import get_current_user
if session_id not in _event_queues:
raise HTTPException(status_code=404, detail="Session not found")
config = _make_config(session_id)
intervention = {
"action": body.action,
"approvedMaterialIds": body.approved_material_ids,
"extraKeywords": body.extra_keywords,
"editedOutline": body.edited_outline,
"outlineFeedback": body.outline_feedback,
"userRevisionNotes": body.user_revision_notes,
"overrideVerdict": body.override_verdict,
"selectedIssueIndices": body.selected_issue_indices,
}
# Record this intervention for session history
record = {
"pausePoint": body.pause_point,
"action": body.action,
"timestamp": datetime.now(UTC).isoformat(),
}
if session_id in _session_interventions:
_session_interventions[session_id].append(record)
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
completed_interventions=_session_interventions[session_id],
)
except Exception:
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
# Clear pending pause so SSE reconnect won't re-inject the stale await_user event
_session_pending_pause.pop(session_id, None)
asyncio.create_task(
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH)
)
return {"message": "已提交,流程恢复中"}
@router.get("/{session_id}/state")
async def get_state(session_id: str):
"""查询当前流程状态快照。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
config = _make_config(session_id)
snapshot = await AI_WRITING_GRAPH.aget_state(config)
if not snapshot or not snapshot.values:
raise HTTPException(status_code=404, detail="Session not found")
return snapshot.values
# ── 内部辅助 ──────────────────────────────────────────────────────────────────
async def _stream_graph_segment(
session_id: str,
input_or_command,
config: dict,
graph,
):
"""运行一个 Graph 片段(初始运行或 resume),将输出推入队列。
遇到 interrupt 时停止(不关闭队列),等待下次 resume。
Graph 结束时关闭队列(放入 None 哨兵)。
"""
queue = _event_queues.get(session_id)
if queue is None:
return
reached_end = False
is_error = False
try:
async for chunk in graph.astream(input_or_command, config=config, stream_mode="updates"):
node_name = next(iter(chunk))
node_output = chunk[node_name]
# interrupt() 触发
if node_name == "__interrupt__":
# node_output 是 (Interrupt(...),) 元组
interrupt_list = node_output if isinstance(node_output, (list, tuple)) else [node_output]
for intr in interrupt_list:
interrupt_data = intr.value if hasattr(intr, "value") else intr
event_tuple = ("await_user", interrupt_data)
await queue.put(event_tuple)
# Save for SSE reconnect re-delivery: if the old generator consumed this
# event before the client disconnected, a new connection would block forever.
_session_pending_pause[session_id] = event_tuple
return # 等待 resume,不关闭队列
if not isinstance(node_output, dict):
continue
# 推送 progress_events
for evt in node_output.get("progress_events") or []:
await queue.put((_event_type(evt), {**evt, "session_id": session_id}))
# 素材就绪
if pkg := node_output.get("material_package"):
await queue.put(("materials_ready", {
"session_id": session_id,
"keywords": pkg.get("keywords", []),
"materials": pkg.get("materials", []),
"summary": pkg.get("summary", ""),
}))
# 大纲就绪
if outline := node_output.get("current_outline"):
await queue.put(("outline_ready", {"session_id": session_id, "outline": outline}))
# 草稿就绪 — 保存到 DB
if draft := node_output.get("current_draft"):
draft_title = draft.get("title", "")
draft_markdown = draft.get("full_markdown", "")
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft_title,
"markdown": draft_markdown,
"revision_count": draft.get("revision_count", 0),
}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="draft_ready",
title=draft_title or _truncate_title(
next(iter(_session_interventions.get(session_id, [{}])), {}).get("action", "草稿"),
),
draft_title=draft_title,
draft_markdown=draft_markdown,
)
except Exception:
logger.exception("ai_writing: failed to save draft: session=%s", session_id)
# 编辑审核就绪 — 保存到 DB
if review := node_output.get("review_result"):
await queue.put(("review_ready", {"session_id": session_id, "review_result": review}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="review_ready",
review_result=review,
)
except Exception:
logger.exception("ai_writing: failed to save review: session=%s", session_id)
# 状态 done:Graph 结束
if node_output.get("status") == "done":
reached_end = True
# astream 正常结束 = Graph 执行到 END
reached_end = True
await queue.put(("done", {"session_id": session_id, "message": "写作完成"}))
except Exception as e:
logger.exception("ai_writing graph segment failed: session=%s", session_id)
await queue.put(("error", {"session_id": session_id, "message": str(e)}))
is_error = True
reached_end = True
finally:
if reached_end:
# 更新最终状态到 DB
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="error" if is_error else "done",
)
except Exception:
logger.exception("ai_writing: failed to update final status: session=%s", session_id)
await queue.put(None) # 哨兵,通知 SSE 生成器关闭
_event_queues.pop(session_id, None)
_session_model_names.pop(session_id, None)
_session_repos.pop(session_id, None)
_session_user_ids.pop(session_id, None)
_session_interventions.pop(session_id, None)
_session_error_flags.pop(session_id, None)
_session_article_types.pop(session_id, None)
_session_pending_pause.pop(session_id, None)
def _event_type(evt: dict) -> str:
t = evt.get("type", "progress")
# 过滤掉已在上方单独推送的事件类型,避免重复
if t in ("materials_ready", "outline_ready", "draft_ready", "review_ready", "await_user",
"draft_chunk", "outline_chunk"):
return "step_done"
return t
async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
"""从队列消费事件,推送为 SSE 帧。"""
# 等待队列创建(最多 5 秒)
for _ in range(50):
if session_id in _event_queues:
break
await asyncio.sleep(0.1)
else:
yield _sse("error", {"message": "Session not found"})
return
queue = _event_queues[session_id]
yield _sse("connected", {"session_id": session_id})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists.
# This covers the case where the old SSE generator dequeued the await_user event and
# yielded it to a disconnecting client — the event is then lost from the consume-once queue.
if queue.empty() and session_id in _session_pending_pause:
await queue.put(_session_pending_pause[session_id])
while True:
item = await queue.get()
if item is None:
break
event_type, data = item
yield _sse(event_type, data)
await asyncio.sleep(0)
@@ -0,0 +1,191 @@
"""Writing rewrite API — SSE streaming endpoint.
POST /api/writing/rewrite
Returns text/event-stream with frames:
event: thinking data: {"chunk": "..."}
event: text data: {"chunk": "..."}
event: done data: {}
event: error data: {"message": "..."}
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/writing", tags=["writing"])
# ── Prompts ───────────────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """你是一位专业的中文写作助手,擅长润色、扩写、缩写和调整文风。
规则:
- 只输出改写后的正文,不加任何前缀、解释或括号说明。
- 保持原文的语言(中文输入 → 中文输出,英文输入 → 英文输出)。
- 改写结果应与原文长度相近,除非任务要求扩写或缩写。
- 不要添加 markdown 标题或特殊格式,除非原文本身包含。
"""
_ACTION_INSTRUCTIONS: dict[str, str] = {
"polish": "请对以下文字进行润色,使其表达更流畅、自然,同时保留原意。",
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。",
}
_TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。",
"readable": "目标风格:通俗易懂、易于阅读。",
"academic": "目标风格:学术严谨、逻辑清晰。",
"expressive": "目标风格:生动有文采、富有感染力。",
}
# ── Request model ─────────────────────────────────────────────────────────────
class SelectionInfo(BaseModel):
from_: int = Field(..., alias="from", description="ProseMirror start offset")
to: int = Field(..., description="ProseMirror end offset")
text: str = Field(..., description="Selected plain text")
model_config = {"populate_by_name": True}
class SurroundingContext(BaseModel):
before: str = Field(default="", description="Text before the selection")
after: str = Field(default="", description="Text after the selection")
class RewriteRequest(BaseModel):
thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone")
tone: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
custom_instruction: str | None = Field(default=None, alias="customInstruction")
follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
previous_result: str | None = Field(default=None, alias="previousResult")
model_config = {"populate_by_name": True}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
btype = block.get("type", "")
if btype in ("thinking", "thinking_delta"):
thinking += block.get("thinking", "")
elif btype in ("text", "text_delta"):
text += block.get("text", "")
elif btype == "reasoning":
thinking += block.get("content", "")
# DeepSeek / OpenAI o-series: reasoning in additional_kwargs
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
r = kwargs.get("reasoning_content") or ""
if isinstance(r, str):
thinking = r
return thinking, text
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
instruction = _ACTION_INSTRUCTIONS[action]
if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
instruction = f"{instruction}\n{_TONE_SUFFIX[body.tone]}"
context_parts: list[str] = []
before = body.surrounding_context.before.strip()
after = body.surrounding_context.after.strip()
if before or after:
context_parts.append("文章上下文(仅供参考,不要改写):")
if before:
context_parts.append(f"[前文] {before[-300:]}")
if after:
context_parts.append(f"[后文] {after[:300]}")
context_parts.append("")
context_block = "\n".join(context_parts)
base_instruction = instruction
if body.custom_instruction:
base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
if body.follow_up_instruction and body.previous_result:
messages = [
SystemMessage(content=_SYSTEM_PROMPT),
HumanMessage(content=user_message),
AIMessage(content=body.previous_result),
HumanMessage(content=body.follow_up_instruction),
]
else:
messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
# Auto-enable thinking when the selected model supports it
resolved_name = body.model_name or (config.models[0].name if config.models else None)
model_cfg = config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
async def generate():
try:
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
if text_delta:
yield _sse("text", {"chunk": text_delta})
yield _sse("done", {})
except Exception as e:
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
yield _sse("error", {"message": str(e)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
@@ -152,10 +152,12 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"is_plan_mode",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"canvas_artifact_title",
"canvas_artifact_type",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
@@ -0,0 +1,329 @@
# 知识库数据质量警示框架
---
**类型**: 分析框架
**当前角色**: 元方法论工具(知识库数据质量校准与输入端过滤)
**何时调用此档案**: 检索知识库任何事实性数据后、将数据嵌入分析报告前;处理时间线类活档案时;遇到民调、投票率、经济数据等争议性数字时;面对高度政治化词汇需提取底层事实时;完成分析报告进行最终质量自检时
---
## 一句话定位
数据质量不是"真/伪"的二元开关,而是"适用范围与污染程度"的光谱;将任何输入视为洁净无异的是分析失败的原始罪,去污染与标定不确定才是分析的起点。
---
## 框架的目的与核心定位
本框架是知识库的"免疫系统"。它不直接生产对政治对象的解释,而是对解释的原材料(数据与事实)进行质检、消毒与适用范围标定。
直接使用未经处理的政治数据极易产出看似严谨实则系统偏误的分析:
- 看到精确到小数点后一位的民调数字 → 误判为"民意的高度确定性"
- 看到官方发布的选民投票率 → 误判为"无误差的客观事实"
- 看到某阵营使用的政治术语 → 误将该术语的预设框架当作客观存在的实体
- 看到时间线档案中的旧数据 → 误判为"当前依然有效的结构"
**事实**:政治数据(尤其是两岸与选举数据)本质上是被测量工具、政治立场与制度结构共同污染过的产物。不存在"原始洁净数据",只存在"已标定污染源与半衰期的数据"。
本框架的目的:
1. **分类工具**:将数据质量拆解为五个维度的风险等级(时间、争议、政治化、归因、来源)
2. **解释工具**:揭示数据为何出现偏差或争议(是方法论问题?制度约束?还是刻意操弄?)
3. **操作工具**:提供去污染、降维与标定不确定性的具体步骤
这不是"保证数据绝对正确"的方案——这在政治领域不可能实现。但系统化的质量警示能让分析从"盲目信任数字"升级为"在标定误差范围内进行有限推断"。
---
## 维度一:活档案过时的处理(时间线类)
### 事实层:政治数据的半衰期
知识库中的活档案(如人物动态、政策时间线、军演记录)具有高度的时间敏感性。数据的"半衰期"指该数据对当前结构解释的有效性衰减至一半所需的时间。
| 数据类型 | 典型半衰期 | 衰减机制 | 范例 |
|---|---|---|---|
| 突发事件快讯 | 24-72 小时 | 初期信息混乱,后续澄清推翻初始叙事 | 某次两岸边境对峙的初始伤亡数字 |
| 选举民调数字 | 1-3 个月 | 事件冲击改变民意分布,未决定选民表态 | 2028 选前六个月的政党支持度 |
| 政策宣示 / 讲话 | 3-6 个月 | 宣示与落地执行存在落差,后续政策修正 | 某候选人的两岸政策白皮书 |
| 制度性结构 | 2-5 年 | 法规修订、国际形势质变 | 台湾的兵役制度调整、美国的对台军售常态化机制 |
| 身份认同趋势 | 10 年以上 | 世代更替与重大历史事件缓慢形塑 | 台湾人的统独立场认同分布 |
### 脉络层:过时数据的结构性危险
活档案过时最大的危险不在于"事实错误",而在于"结构错位"。当分析者使用已过半衰期的数据时,最容易犯的错误是将过去的结构性约束误认为当前的约束。
**结构性命题**:数据的过时不是简单的"失效",而是"语境剥离"。一个 2024 年的民调数字在 2028 年使用时,其数字本身可能仍有参考值,但其背后的语境(如当时的两岸危机强度、内部政治焦点)已完全不同。
**范例**:
使用 2024 年台湾总统大选的投票率与板块分析来预测 2028,若忽略 2024 后可能发生的战争风险感知变化、兵役政策落地、或第三党势力的重组,就是典型的"数据语境剥离"。
### 分析含义与操作
**分析时如何用这个事实**:
1. **时间戳强制检查**:在引用任何事实性数据时,必须内隐或外显地检查其时间戳。若数据年龄超过其半衰期,必须降级为"背景参考",不得作为"当前结构推断"的主要证据。
2. **语境锚定**:引用旧数据时,强制附加一句"此数据产生于 [具体事件/语境] 之下"。
3. **断裂点扫描**:在数据时间点与当前之间,是否存在重大历史断裂点(如台海危机、政党轮替、重大政策转向)?若有,该数据的延续性即被打破,需重新评估。
**反向特征 / 例外**:
某些看似过时的数据在特定条件下会"复活"。当当前情境与历史情境高度同构时(如相似的危机升级周期),历史数据的参考价值可能超越近期数据。但此同构性必须被论证,而非预设。
---
## 维度二:争议性数据的标注(如选民投票率、民调差异)
### 事实层:争议的三个来源
在台湾与两岸政治分析中,数字往往不是客观的,而是方法论与政治利益交锋的场域。
1. **方法论争议**:抽样框架的差异(市话 vs. 手机 vs. 混合)、加权方式的不同(按上次选举结果加权 vs. 按人口结构加权)、未决定选民的处理(强制选择 vs. 剔除)。
2. **制度性争议**:如选民投票率的计算,是否纳入不在籍投票?是否剔除无效票?不同计算基准产生不同数字。
3. **操弄性争议**:内参民调的选择性释放(只发布对己有利的数据)、幽灵民调(无实地调查的捏造数据)。
### 脉络层:精确的幻觉
**结构性矛盾**:民调数字越精确(如 42.3%),越容易给人"科学、客观"的幻觉;但在政治领域,这种精确往往掩盖了巨大的系统性误差。两个民调机构给出 5% 的差距,可能完全不代表民意移动,而只是加权模型的差异。
**破除常见简化**:把"民调差异"等同于"其中一家造假"是错的。多数时候,差异反映的是对"谁是可能选民"(Likely Voter)的不同假设。在台湾,青年与首投族的拒访率极高,如何估算并加权这群人,是所有民调的方法论黑洞。
### 分析含义与操作
**争议性数据标注规范**:
| 标注等级 | 适用情境 | 分析处理方式 | 范例 |
|---|---|---|---|
| **L1: 方法论差异** | 同一时期不同机构数据差异在误差范围内 | 并陈数据,指出方法论差异,不选边 | A 民调 45%,B 民调 43%,加权方式不同 |
| **L2: 重大分歧** | 差异超出误差范围,或关键指标(如投票意愿)方向相反 | 并陈数据,分析分歧来源,标示"真实值不确定" | 某阵营支持度 A 给 30%,B 给 40% |
| **L3: 操弄嫌疑** | 数据来源不明、无方法论说明、与趋势严重背离 | 极度降级,仅作为"该阵营试图释放的信号"分析,不作为事实引用 | 选前突然出现的未具名内参民调 |
| **L4: 官方制度争议** | 官方数据因计算基准不同产生多版本 | 引用最通用版本,注明计算基准,必要时并陈 | 投票率计算基准的争议 |
**分析时如何用这个事实**:
当看到任何民调或统计数字时,第一反应不是"数字是多少",而是"这个数字是如何被生产出来的"。在分析报告中,禁止只呈现单一数字而不标注其方法论来源与争议等级。参见 `reference/source_credibility_tiers.md` 对不同等级来源的信任度设定。
---
## 维度三:政治化数据的『去政治化处理』
### 事实层:政治化数据的形态
政治化数据是指其表述形式本身已嵌入特定政治立场或预设结论的数据。最常见的形态是"命名"与"框架"。
1. **命名政治化**:同一实体,不同阵营使用不同名称。
- "九二共识" vs. "九二历史事实" vs. "一九九二年两岸会谈"
- "侨民" vs. "台侨"
- "我国" vs. "台湾" vs. "中华民国"
2. **框架政治化**:同一现象,不同阵营使用不同因果框架。
- "两岸经济合作框架" vs. "对中国经济依赖"
- "国防预算提升" vs. "战争税"
- "抗中保台" vs. "挑衅引战"
### 脉络层:语言即认知牢笼
**结构性命题**:政治化数据不是"带有偏见的事实",而是"通过偏见建构的事实"。当你使用"九二共识"一词时,你不仅是在指涉一个历史事件,更是接受了"存在共识"这一前提;当你使用"国防预算提升"时,你接受了"提升是中性或正面"的框架。
**内在矛盾**:去政治化处理的要求(使用最中性语言)与政治现实的运作逻辑(政治本身就是通过框架竞争来动员)存在张力。过度去政治化可能导致分析失去对政治动能的感知。
### 分析含义与操作
**去政治化翻译协议**:
| 政治化表述 | 去政治化底层事实 | 分析保留的框架信息 |
|---|---|---|
| 九二共识 | 1992 年两岸两会香港会谈及其后续函电往来中,双方就"一个中国"原则各自表述的口头默契 | A 阵营将此视为两岸互动基础;B 阵营否认其存在或拒绝其约束力 |
| 抗中保台 | 在面对北京压力时,采取政治与军事抵抗姿态,以维持台湾现有制度与自治地位的政策路线 | 该框架将"抗"与"保"绑定,暗示抵抗是保护的唯一路径 |
| 两岸一家亲 | 基于血缘与文化同源性,主张两岸人民属于同一民族共同体的论述 | 该框架将血缘/文化置于政治分歧之上,暗示分歧可在亲情框架下消解 |
**操作步骤**:
1. **识别负载词**:扫描分析中的核心名词,找出所有具有阵营归属感的词汇。
2. **拆解为机制**:将该词汇还原为具体的行为、制度或政策机制。
3. **标注框架归属**:在分析中明确指出"X 阵营使用 Y 词汇来框架此机制"。
4. **中立重述**:在分析的核心因果推演中,使用机制性描述而非政治化词汇。
**分析陷阱**:
去政治化不等于"各打五十大板"或"假中立"。如果某方的框架确实在当前政治结构中占据主导地位(如"维持现状"是台湾主流民意框架),分析者必须指出这一主导性,而不是假装所有框架平起平坐。参见 `constitution/writing_principles.md` 中关于用词规范的约束。
---
## 维度四:归因不确定的标注规范
### 事实层:政治归因的模糊性
在两岸与台湾内部政治中,行为体的真实动机往往是不透明的。归因不确定源于:
1. **信息不对称**:内部决策过程不公开(如北京对台军演的具体决策考量、华盛顿对台军售的底线评估)。
2. **多重动机叠加**:同一行为可能同时满足国内合法性需求、国际信号传递、制度惯性等多重目标。
3. **故意模糊**:行为体刻意保持战略模糊,使对手无法准确归因(如美国对是否出兵台海的模糊政策)。
### 脉络层:归因的确定性幻觉
**结构性矛盾**:分析者对归因的确定性需求,远高于政治现实所能提供的确定性。这导致分析中最常见的错误是"过度归因"——将可能的原因断言为确定的原因,或从单一动机解释多重约束下的行为。
**破除常见简化**:把北京的所有对台施压行为单一归因为"领土完整诉求"或"内部权力巩固"都是简化的。实际上,每一次施压的权重分配可能完全不同,且施压本身可能受制于官僚机构的路径依赖,而非顶层设计的精密计算。
### 分析含义与操作
**归因置信度标尺**:
| 等级 | 标识 | 含义 | 证据要求 | 范例 |
|---|---|---|---|---|
| **C1: 确认** | [确认归因] | 有官方文件、公开声明或可靠内幕证实 | 至少两个独立可信来源交叉验证 | 台湾增加国防预算是因应北京军演常态化 [官方预算书 + 总统公开讲话] |
| **C2: 高度可能** | [高度可能] | 行为模式与历史先例高度吻合,逻辑自洽 | 有行为模式支撑,缺乏直接内部证言 | 北京军演部分目的是向美国传递信号 [演习区域与美舰航线重合] |
| **C3: 竞争性解释** | [竞争性解释] | 存在多个互斥或互补的合理解释 | 各解释均有部分证据,无法排除 | 某候选人调整两岸论述的动机:[解释A: 向中间靠拢抢票] vs [解释B: 安抚美国] |
| **C4: 高度不确定** | [高度不确定] | 证据薄弱,或行为体刻意制造模糊 | 仅有间接推断,无直接证据 | 美国在台海冲突中的实际干预阈值 |
**操作规范**:
在分析报告中,所有涉及动机、意图或内部决策过程的陈述,必须强制标注归因置信度等级。禁止在 C3/C4 等级使用"X 的目的是..."的断言句式,必须替换为"X 的可能目的是... [C3]"或"存在竞争性解释... [C3]"。参见 `playbooks/narrative_attribution_protocol.md` 中的归因纪律。
---
## 维度五:二手来源 vs 一手来源的等级
### 事实层:信息的失真管道
信息每经过一次传递,就经历一次筛选与重构。
| 来源层级 | 定义 | 失真风险 | 范例 |
|---|---|---|---|
| **一手来源** | 行为体的原始产出或原始数据 | 最低(但可能有刻意操弄) | 总统府新闻稿、立法院议事录、中选会公告、北京国务院白皮书、美军印太司令部声明 |
| **一点五手来源** | 具专业性的即时转译与查证 | 低(受机构立场影响) | 中央社、路透社的即时新闻;专业法律对法案的逐条分析 |
| **二手来源** | 带有分析与诠释的报导 | 中(加入框架与选择性) | 报纸社论、深度调查报导、智库分析报告 |
| **三手来源** | 基于二手来源的再评论 | 高(严重失真与情绪放大) | 政论节目、专栏评论、社交媒体 KOL 分析 |
### 脉络层:权威性与真实性的错位
**结构性矛盾**:一手来源的权威性最高,但其真实性不一定最高(官方可能说谎或隐瞒);二手来源可能揭露被掩盖的事实,但也可能注入强烈的框架偏见。分析者常犯的错误是:因不信任某阵营的一手来源,而过度信任对该阵营持批判立场的二三手来源。
**范例**:
在分析两岸经贸数据时,中国海关总署的一手统计数据可能存在政治性调整,但直接使用特定智库的二手解读(可能为了证明"经济脱钩"而选择性呈现季度波动)会引入更大的分析偏误。
### 分析含义与操作
**层级穿透原则**:
1. **强制溯源**:当引用二手来源的特定事实陈述时,必须追问"这个事实的一手来源是什么?"若无法追溯,该事实的置信度自动降级。
2. **并陈比对**:在关键事实上,若一手来源与二手来源的描述出现分歧,必须并陈,并分析分歧的来源(是误读?还是刻意扭曲?)。
3. **框架剥离**:从二手来源中提取事实层时,必须剥离其脉络层的诠释。参见 `reference/source_credibility_tiers.md` 的完整分级与信任度设定。
---
## 维度六:使用本知识库时的『元提醒』
### 事实层:知识库的固有局限
本知识库是一个被筛选、被结构化的系统,它本身不是现实的镜像,而是现实的特定切面。
1. **覆盖偏差**:知识库对高政治性议题(两岸、选举、外交)的覆盖远高于低政治性议题(社会结构、基层治理、文化变迁)。这可能导致分析者高估政治因素在整体社会变迁中的权重。
2. **语言偏差**:知识库主要基于中文与英文来源,对日本、欧盟等第三方视角的覆盖相对不足。
3. **时间滞后**:知识库的更新存在周期,对突发事件的响应存在不可避免的延迟。
4. **结构化代价**:将复杂现实拆解为"档案"与"字段"的过程,必然损失部分语境与模糊性。
### 脉络层:地图不是疆域
**结构性命题**:知识库的检索结果不是"关于现实的事实",而是"知识库关于现实的记录"。这两者之间的落差,在高度动态的政治领域尤为显著。
**分析含义**:
每次检索并引用知识库数据时,分析者必须在后台运行一个元问题:"知识库没有收录什么?这个数据的缺席是否影响了我的结论?"参见 `playbooks/bias_self_check.md` 中关于"信息源偏差"与"可得性偏差"的自检。
---
## 维度七:对模型输出的『质量自检』
### 事实层:LLM 分析的特殊风险
作为 LLM,在处理知识库数据时存在特有的输出风险:
1. **幻觉填补**:当知识库数据不足时,模型可能基于训练数据自行补全,产生看似合理但无档案依据的"事实"。
2. **平滑化倾向**:模型倾向于产出逻辑自洽、叙事平滑的分析,可能掩盖政治现实中的内在矛盾与断裂。
3. **对齐偏差**:对齐训练可能使模型在涉及两岸主权等敏感议题时,过度偏向某种安全表述,导致分析失去锋芒。
### 脉络层:逻辑自洽 ≠ 事实为真
**结构性矛盾**:LLM 最擅长的是逻辑推演与叙事构建,但这恰恰是政治分析中最危险的能力——因为逻辑越严密、叙事越流畅,读者越容易忽略底层事实的脆弱性。
### 分析含义与操作
**输出质量自检三步法**:
1. **溯源断点检查**:扫描生成的分析报告,标记所有核心事实陈述。对每个陈述追问:"这句话的依据是知识库的哪个具体档案?"若无法定位,标记为潜在幻觉,删除或降级为推测。
2. **矛盾保留检查**:检查报告是否将知识库中标注的"内在矛盾"或"竞争性解释"给平滑化了?若是,必须将矛盾重新引入,参见 `constitution/writing_principles.md` 中关于"诚实标注不确定"的规范。
3. **主权议题中性检查**:在涉及两岸定位的表述上,检查是否因对齐偏差而使用了单方面的主权宣示语言,必须替换为中性描述。参见 `reference/code_words_and_euphemisms.md`。
---
## 维度八:常见数据陷阱清单
### 陷阱一:精确的幻觉
**表现**:过度信任精确数字(如民调的 42.3%、GDP 增长率的 5.12%),忽略方法论误差与制度性扭曲。
**破除方法**:将所有精确数字视为区间估计。42.3% 应被理解为 39%-45% 的区间,并追问该区间的置信度。
### 陷阱二:聚合的谬误
**表现**:将不同来源、不同方法论的数据强行聚合比较。如比较 A 民调机构(市话+手机)与 B 民调机构(纯市话)的趋势变化,将方法论差异误判为民意移动。
**破除方法**:跨源比较只看趋势方向,不看绝对数值;同源比较才具有高可信度。
### 陷阱三:官方数据的权威性盲区
**表现**:假设中选会、海关、央行等官方数据绝对客观。实际上,官方数据的统计口径、分类标准本身可能蕴含政治选择(如两岸贸易的归类方式、失业率的定义)。
**破除方法**:在使用官方数据前,检查其"统计方法说明",确认口径是否符合分析需求。
### 陷阱四:幸存者偏差的民调
**表现**:民调只能反映"愿意回答民调的人"的意见。在高度政治极化的社会中,特定阵营的支持者可能因不信任媒体而系统性拒访,导致民调系统性地低估或高估某方。
**破除方法**:关注民调的"拒访率"与"完访率",对拒访率极高的民调保持警惕。
### 陷阱五:时间错置
**表现**:将 A 时期的数据用于证明 B 时期的结构,忽略中间的断裂点。如用 2020 年的认同数据论证 2028 年的选举结构。
**破除方法**:执行维度一中的"断裂点扫描"。
### 陷阱六:因果倒置的框架
**表现**:将政治话语的框架当作因果解释。如"因为抗中保台,所以支持度上升"——实际上可能是"因为支持度上升(源于其他因素),所以采取抗中保台路线来巩固"。
**破除方法**:对任何因果陈述,强制进行反事实测试(如果原因不存在,结果是否仍可能发生?)。
---
## 关联分析
- **来源可信度等级** (`reference/source_credibility_tiers.md`):提供事实层与脉络层。本框架维度二(争议性数据)与维度五(来源等级)的操作,需严格参照此文件对特定媒体、智库、官方来源的信任度设定,避免分析者凭直觉判断来源可靠性。
- **归因协议** (`playbooks/narrative_attribution_protocol.md`):提供操作工具。本框架维度四(归因不确定)的 C1-C4 标尺,需与此协议的归因纪律配合使用。当本框架要求标注 [竞争性解释] 时,具体归因方法需参照该协议。
- **偏见自检** (`playbooks/bias_self_check.md`):提供脉络层与交叉验证。数据质量问题的识别本身受分析者偏见影响(如倾向绿营者易怀疑蓝营民调造假,反之亦然)。在执行本框架的质量警示时,必须同步运行偏见自检,防止"选择性质检"。
- **写作规范** (`constitution/writing_principles.md`):提供输出端约束。本框架的去政治化处理(维度三)与不确定标注(维度四),最终需通过写作规范的用词与句式约束来落地。如规范中要求的"避免单一行动者拟人化",是防止政治化数据渗入输出的关键防线。
- **委婉语与代码词** (`reference/code_words_and_euphemisms.md`):提供事实层。维度三(去政治化处理)的具体操作,需参照此文件对两岸主权、政治派别等敏感用词的中性替换规范,确保翻译后的底层事实不带入单方框架。
---
## 常见的分析陷阱
1. **数据洁癖陷阱**:因为所有数据都有污染,而拒绝做出任何推断,导致分析瘫痪。**特征**:全文都是"此数据不确定"、"彼数据有争议",但无任何分析增量。**破除**:数据质量警示的目的是"标定误差范围",而非"追求绝对洁净"。在标定不确定后,仍需做出有限推断。
2. **逆向合理化陷阱**:先有结论,再挑选符合质量标准的数据支撑;对不利数据则用质量警示将其排除。**特征**:对己方阵营的数据使用低标准,对敌方阵营的数据进行吹毛求疵的方法论挑剔。**破除**:执行对称性质检——对双方数据应用同一质量标准。
3. **方法论虚无主义陷阱**:因为民调存在方法论争议,而彻底否定所有民调的价值,转而依赖"直觉"或"身边观察"。**特征**:用"民调都是假的"来回避数据约束。**破除**:有瑕疵的数据优于无数据。理解方法论缺陷后,仍可提取趋势与区间信息。
4. **一手来源崇拜陷阱**:假设官方一手来源绝对可靠,忽略其可能存在的制度性说谎或统计操纵。**特征**:对中选会或北京国务院的数据不加反思地全盘接受。**破除**:一手来源的权威性在于"行为体确实说了/做了这个",而不在于"这个陈述是事实"。
5. **去政治化过度陷阱**:将所有政治框架都剥离后,分析变得干瘪,失去对政治动能的解释力。**特征**:分析两岸关系只谈"军演频率与贸易额",完全不谈"主权认同与尊严政治"。**破除**:去政治化只用于"事实层"的提取,在"脉络层"与"分析层"必须将政治框架作为被分析的对象重新引入。
---
## 仍不确定的部分
- **内参民调的真实性边界**:台湾选举中大量存在各党的内参民调,其选择性释放是重要的政治信号,但其数字本身的真实性极难从外部验证。本框架只能将其标记为 L3(操弄嫌疑),但无法确定其偏离真实值的方向与幅度。
- **系统性拒访的净效应**:台湾社会对特定阵营的系统性拒访现象已被广泛讨论,但不同民调机构对此的调整模型差异极大,目前尚无公认的标准修正方案。这使得选举前民调的误差范围可能远大于统计学上的抽样误差。
- **非数位化数据的缺失**:本知识库及多数分析依赖的民调、媒体、官方数据,严重偏向数位化、都市化、易接触群体。对于非都市、高龄、低网路使用群体的态度与行为,数据质量存在结构性盲区。
- **LLM 对齐与数据信任的交互作用**:作为 LLM,在处理高度争议的两岸数据时,对齐机制可能导致模型对某些数据"过度谨慎"(拒绝使用或过度降级),而对另一些数据"过度信任"(因符合对齐方向而跳过质检)。此交互作用的边界目前难以精确标定。
---
## 与其他文件的关系
- `reference/source_credibility_tiers.md` —— 来源可信度分级:执行本框架维度二与维度五时,强制参照此文件判断来源等级,避免直觉判断
- `playbooks/narrative_attribution_protocol.md` —— 归因纪律:执行本框架维度四(归因不确定标注)时,依此协议进行具体归因操作
- `playbooks/bias_self_check.md` —— 偏见自检:执行数据质量警示时,同步运行偏见自检,防止"选择性质检"与"逆向合理化"
- `constitution/writing_principles.md` —— 写作规范:本框架的去政治化与不确定标注,最终依此规范落地为具体用词与句式
- `reference/code_words_and_euphemisms.md` —— 代码词与委婉语:维度三(去政治化处理)的中性翻译,依此文件的标准执行
---
## 一句话浓缩
> 数据不是等待被采信的真理,而是等待被拆解的文本;质量警示不是分析的终点,而是让有限推断免于沦为盲目断言的起点。
---
last_updated: 2025-05-19
@@ -0,0 +1,344 @@
---
type: framework
current_role: 事件知識文件格式規範與生成模板
when_to_invoke: 當模型需要生成、評估或修訂事件類檔案(event type)時,作為結構與內容的強制約束框架
---
# 事件知識文件格式規範
事件檔案不是新聞稿的歸檔,而是將時間序列上的破裂口轉譯為結構性分析單元的規格化工具。
## 框架目的與核心定位
本框架是分類與標準化工具,用於約束知識庫中所有事件類檔案的生成結構、分析深度與措辭規範。它不提供對單一事件的具體分析,而是解決「當分析者需要將一個政治事件寫入知識庫時,應如何剝離新聞表象、提取結構變量,並以何種格式存檔」的問題。
### 框架的雙重功能
1. **解釋與標準化工具**:將無限多樣的事件敘事,收斂至統一的七段式結構中,確保不同生成者、不同時間點寫入的檔案具有分析同構性,從而支援跨事件的橫向比較與量化評估。
2. **品質控制工具**:透過強制性的「4 永久改變」與「分析陷阱」段落,防止模型陷入「編年史式羅列」或「單一因果歸因」的低密度寫作,迫使寫作層穿透至結構底層。
### 結構性命題:事件是結構碰撞的破裂口
本框架基於一個核心結構性命題:政治事件從非無端發生,亦非孤立存在。事件是深層政治結構(國內民意板塊、兩岸法律戰框架、大國博弈格局)在特定時間點因特定觸發物而發生的顯性破裂口。因此,事件檔案的核心價值不在於記錄「破裂口的形狀」(過程),而在於透過破裂口反向測量「結構的應力極限」(背景與永久改變)。格式規範的每一個必備段落,都是為了完成這一測量而設置的探針。
### 破除常見簡化
**將「事件檔案」等同於「大事記」是錯誤的。** 大事記回答「發生了什麼」,事件檔案回答「這件事如何改變了系統的約束條件」。如果一個事件檔案移除時間線後,其剩餘內容無法支撐對現狀結構的解釋,那麼該檔案就是失敗的。
## 事件檔案的必備段落解析
每一份事件檔案必須嚴格包含以下七個段落。缺漏任何一段,將導致檔案無法被路由系統正確索引,也無法與其他檔案進行交叉驗證。
### 1. 一句話濃縮
這是檔案的原子單位,也是路由系統的第一匹配源。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 提供事件的最關鍵概念錨定,供模型在微秒級檢索中判斷是否調用此檔案。 |
| **結構** | `[行為體] + [動作] + [直接客體] + [結構性後果]` |
| **判斷標準** | 必須包含「結構性後果」,不能只描述動作本身。 |
| **反面案例** | 「2022年8月美國眾議院議長裴洛西訪問台灣。」(僅有事實,無結構後果) |
| **正面案例** | 「2022年8月美國眾議院議長訪台,打破多年外交慣例,觸發北京圍台軍演常態化,重塑台海軍事現狀。」 |
**分析含義**:一句話濃縮的品質直接決定了事件的分類歸屬。若無法提煉出結構性後果,則該事件可能尚未達到進入關鍵事件庫的門檻,應降級為時間線中的節點。
### 2. 背景
背景不是萬能的歷史回溯,而是尋找「結構應力」的過程。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 揭示事件發生前的系統狀態,解釋「為何在此時此地破裂」。 |
| **核心內容** | 區分「遠因」(結構性張力)與「近因」(觸發物)。遠因需連結至宏觀框架檔案,近因需具體到決策節點。 |
| **判斷標準** | 背景段必須回答:事件發生前,哪些規則是隱性存在的?哪些紅線是模糊的? |
| **分析陷阱** | 將背景寫成「編年史」,從1949年開始寫起。這是信息密度最低的寫法。背景的時間跨度應嚴格限制在「與本次結構破裂直接相關的週期」內。 |
**分析含義**:撰寫背景時,必須調用 `routing/by_event.md` 中的標籤系統。若事件標籤為 `[DIP]`(外交),背景段必須檢索並呈現該外交動作打破的既有默契或協議文本,而非泛泛談論雙方關係。
### 3. 過程
過程段是分析者最容易失足的泥沼,常淪為新聞稿的堆砌。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 呈現結構破裂的動態學,提取「決策節點」與「升級階梯」。 |
| **核心內容** | 不按天羅列,按「回合」羅列。每個回合需包含:觸發動作 -> 對方反應 -> 升級/降級信號。 |
| **判斷標準** | 過程段必須能被轉化為「博弈樹」或「反應階梯」。若無法提取決策分支,則過程描述失敗。 |
| **分析陷阱** | 沉迷於戲劇性細節(如專機航線、具體用詞),而忽略了信號傳遞的結構。過程段只關心「信號的發送、接收與誤讀」。 |
**分析含義**:過程段的每一個步驟,都應在後續的「影響」或「永久改變」中找到對應的落點。若過程中描述的某次大規模軍演,在後續段落中沒有產生任何結構性遺產,則該軍演在過程段中應被壓縮為一句話,而非重點描述。
### 4. 影響
影響段區分短波與長波。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 測量事件衝擊波的多維度擴散範圍。 |
| **核心內容** | 必須按領域拆解:軍事/安全、外交/主權、國內政治、經濟/供應鏈。每個領域需區分「即時反應」與「中期效應」(6-12個月)。 |
| **判斷標準** | 影響必須是「可觀測的變化」,而非「潛在的可能」。例如「改變了民意」是空話,「執政黨在事件後的三項民調支持度下降5%」是事實。 |
| **分析陷阱** | 將「意圖」等同於「影響」。北京意圖威懾,但影響可能是反而固化了台灣內部的防衛決心。影響必須基於客觀結果,而非行為體主觀宣告。 |
**分析含義**:影響段是連接事件檔案與其他結構檔案的橋樑。若影響段提及國內政治板塊移動,必須在交叉引用中連結至 `domestic/party_system.md`。
### 5. 4 永久改變
這是事件檔案的核心價值所在,也是區分「重大事件」與「日常摩擦」的唯一標準。若提不出4個永久改變,該事件不應獨立建檔。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 提煉事件對系統底層代碼的不可逆修改。 |
| **核心內容** | 強制要求寫出4個改變,旨在逼迫分析者突破第一層顯性影響,挖掘隱性結構變遷。4個改變應盡量覆蓋:物理/軍事現狀、法律/制度框架、認知/規範範式、互動/博弈協議。 |
| **判斷標準** | 「永久」的定義是:在可見的未來(至少5年),該變化不會因為單一政策調整而回滾到事件前狀態。 |
| **分析陷阱** | 將「常態化波動」誤認為「永久改變」。例如:兩岸關係緊張不是永久改變,但「海峽中線默契的消亡」是永久改變。 |
**範例解析(以2022年裴洛西訪台為例)**:
1. **物理/軍事**:解放軍圍台軍演常態化,打破海峽中線默契,台海軍事緩衝區物理性消失。
2. **法律/制度**:北京透過軍演與白皮書,實質單方面廢止了對「和平統一」的優先承諾,將「反獨」置於「促統」之前。
3. **認知/規範**:華盛頓對北京「口頭激烈、行動克制」的預期被打破,確立了「政治挑釁必遭實質軍事反制」的新認知。
4. **互動/協議**:台美軍事協同從「非正式後勤」轉向「準同盟聯合戰備」的軌道,北京對此的默認成為新的互動底線。
**分析含義**:4永久改變是知識庫進行預測的基石。當新事件發生時,模型應首先檢索歷史事件的「4永久改變」,判斷新事件是在加固這些改變,還是在試圖推翻這些改變。
### 6. 分析陷阱
針對該特定事件的認知誤區。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 為未來的分析者設置護欄,防止重蹈覆轍。 |
| **核心內容** | 列出3-5個最常見的誤判。必須具體,格式為:「誤區:[X];真相:[Y];原因:[Z]」。 |
| **判斷標準** | 陷阱必須是「中等模型容易犯的錯」,而不是「常識性錯誤」。 |
**分析含義**:此段落是模型自我反思的觸發器。當模型生成分析結論時,應比對此段落,若結論落入陷阱描述,則需重新推導。
### 7. 不確定
誠實標註分析的黑盒。
| 維度 | 規範要求 |
| :--- | :--- |
| **功能** | 標定知識邊界,防止過度推斷。 |
| **核心內容** | 明確列出「仍不確定的事實」與「仍無法驗證的因果鏈」。 |
| **判斷標準** | 不確定段不能是廢話(如「未來發展仍不確定」),必須是具體的知識缺口(如「北京決策層在軍演開始前72小時的內部辯論細節」)。 |
**分析含義**:不確定段是後續知識庫更新的待辦清單。當新解密檔案或新研究出現時,應優先填補此處標註的缺口。
## 結構模板
以下為事件檔案的標準 Markdown 結構模板。生成事件檔案時,必須嚴格複製此結構,不得增刪一級標題。
```markdown
---
type: event
trigger_label: [依據 routing/by_event.md 填寫標籤,如 DIP_MIL_L3]
actors_involved: [BJ, TW, US...]
event_date: [YYYY-MM-DD 或起迄日期]
status: [active / static]
---
# [事件簡稱]
[一句話濃縮:行為體 + 動作 + 客體 + 結構性後果]
## 背景
### 遠因:結構性張力
[描述事件發生前的長期結構矛盾,連結宏觀框架]
### 近因:觸發物
[描述直接導致事件發生的決策或行動]
## 過程
### 第一回合:[命名,如「政治試探」]
- **觸發**:[具體行動]
- **反應**:[對方反應]
- **信號解讀**:[雙方如何解讀對方行動]
### 第二回合:[命名,如「軍事升級」]
[同上結構]
## 影響
### 軍事/安全領域
[即時反應與中期效應]
### 外交/主權領域
[即時反應與中期效應]
### 國內政治領域
[即時反應與中期效應]
### 經濟/供應鏈領域
[即時反應與中期效應]
## 4 永久改變
1. **[改變維度,如:軍事現狀]**:[具體描述不可逆的變化]
2. **[改變維度,如:法律框架]**:[具體描述不可逆的變化]
3. **[改變維度,如:認知範式]**:[具體描述不可逆的變化]
4. **[改變維度,如:互動協議]**:[具體描述不可逆的變化]
## 分析陷阱
- **陷阱 1**:[常見誤區描述]
- **真相**:[結構性解釋]
- **原因**:[為何容易誤判]
- **陷阱 2**:[同上]
## 不確定
- **事實缺口**:[仍不知道的具體事實]
- **因果缺口**:[仍無法確認的因果連結]
## 交叉引用
- `routing/by_event.md` —— [提供事件分類標籤與路由邏輯]
- `[其他相關檔案路徑]` —— [說明關聯功能:提供事實層/提供脈絡層/用於對照/用於交叉驗證]
last_updated: YYYY-MM-DD
```
## 字數指引與密度分配
事件檔案的目標字數區間為 **8000 - 15000 字**。字數分佈不是均勻的,必須體現分析的重心偏移。
| 段落 | 建議字數佔比 | 核心密度要求 | 常見低密度錯誤 |
| :--- | :--- | :--- | :--- |
| **一句話濃縮** | < 1% | 極高:無廢話,全為結構信息 | 羅列時間地點而無後果 |
| **背景** | 15-20% | 高:揭示隱性規則與張力 | 維基百科式歷史回溯 |
| **過程** | 25-30% | 中高:提取決策節點與信號 | 按日羅列新聞,無博弈視角 |
| **影響** | 20-25% | 高:多維度拆解可觀測變化 | 「影響深遠」等空話,無實證 |
| **4 永久改變** | 15-20% | 極高:提煉不可逆底層代碼 | 將短期波動誤認為永久改變 |
| **陷阱與不確定** | 10% | 高:精準定位認知盲區 | 泛泛而談「未來不確定」 |
**分析含義**:若生成過程中「過程」段字數超過 40%,說明檔案已退化為新聞報導;若「4 永久改變」字數少於 10%,說明分析未穿透表層。此時必須強制重寫。
## 交叉引用規範
交叉引用不是友情連結,而是分析邏輯的閉環證明。
### 引用語法與語義
1. **向上引用(連結框架/路由)**:必須引用 `routing/by_event.md` 以確認自身標籤;必須引用 `constitution/writing_principles.md` 以確認措辭合規。這是存在性證明。
2. **平行引用(連結其他事件)**:當事件過程涉及歷史先例時,必須引用。語義需標明「用於對照」或「用於交叉驗證」。例如:2023年4月軍演必須引用2022年8月軍演,並標明「用於驗證軍演常態化趨勢」。
3. **向下引用(連結行為體/結構)**:必須引用涉及的行為體檔案或結構檔案。語義需標明「提供脈絡層」。例如:影響段提及台灣內部政治,必須引用 `domestic/party_system.md`。
### 反向驗證機制
當事件檔案寫入完成後,寫入者必須檢查被引用的檔案(如行為體檔案)是否已包含對該事件的索引。若未包含,需在目標檔案中補充。這確保了知識庫的圖譜完整性。
## 措辭規範:避免立場化
這是知識庫最核心的品質紅線。事件檔案必須遵循 `constitution/writing_principles.md` 的總規範,並在事件描述中嚴格執行雙層語境分離。
### 引用層 vs 分析層
| 層級 | 定義 | 允許的措辭 | 禁止的措辭 |
| :--- | :--- | :--- | :--- |
| **引用層** | 轉述行為體的宣告、法律文本、政策文件 | 保留原文用詞,如「一個中國原則」(北京語境)、「主權獨立」(台北語境) | 將引用層詞彙不加引號地作為分析結論使用 |
| **分析層** | 描述客觀結構、因果機制、互動博弈 | 使用最中性語言,如「北京當局」、「台北政府」、「華盛頓」、「海峽中線」 | 「中國侵略」、「台灣挑釁」、「大陸打壓」、「我國」 |
### 破除常見簡化
**在分析層使用「中美」或「兩岸」作為單一主語是錯誤的。** 例如「兩岸關係因此惡化」是低密度且立場模糊的表述。正確的分析層表述應拆解為行為體:「北京對台北的軍事威懾梯度上升,台北對北京的經濟脫鉤意願增強」。這不僅避免了立場化,更強迫分析者看清內部的不對稱性。
### 主權與稱謂的處理原則
在描述涉及主權爭議的事件時,必須呈現多方視角,並明示「此為某方詮釋」。
- **範例**:關於 2022 年軍演的合法性,應表述為:「北京當局宣稱軍演依據國內法行使主權,台北政府與華盛頓則認定其違反國際法與台海現狀。」
- **禁止**:「軍演是非法的」或「軍演是正當的」。
## 活檔案 vs 靜態檔案的區分
事件檔案具有生命週期,必須在 header 中以 `status` 欄位標註。
### 靜態檔案
- **定義**:事件已完全結束,其影響已沉澱為結構,短期內不會有新的決策節點改變其性質。
- **特徵**:「4 永久改變」已清晰可見,無需頻繁更新。
- **範例**:`events/key_events/event_sunflower_2014.md`。太陽花學運已結束,其對兩岸協議監督條例的影響已固化。
- **更新頻率**:每年覆盤一次,或當相關解密檔案出現時更新。
### 活檔案
- **定義**:事件仍處於發酵期,或其引發的結構性後果仍在快速演化中,新的決策節點不斷湧現。
- **特徵**:「4 永久改變」可能僅能識別出 2-3 個,其餘為「潛在改變」,需持續追蹤。
- **範例**:2028 選舉週期相關事件,或 2022 年裴洛西訪台引發的軍事新常態(至今仍在演化出如「聯合利劍」系列新行動)。
- **更新頻率**:每當有重大連動事件發生時,必須回頭更新此檔案的「影響」與「4 永久改變」段。
**分析含義**:模型在調用活檔案時,必須對其中的結論加權時間衰減因子。越早的結論,在當前分析中的權重越低;而靜態檔案的結論則視為常數。
## 與其他檔案類型的差異
知識庫是一個多層生態系統,事件檔案是其中的「實例」,必須與其他類型明確區分。
| 檔案類型 | 核心功能 | 時間維度 | 與事件檔案的關係 |
| :--- | :--- | :--- | :--- |
| **事件檔案** | 記錄結構破裂口的細節與後果 | 微觀至中觀(天/月/年) | 是其他檔案的數據源與驗證點 |
| **行為體檔案** | 記錄決策者的偏好、約束與內部結構 | 中觀至宏觀(年/十年) | 解釋事件中的「為何如此決策」 |
| **框架檔案** | 提供分析工具與分類標準 | 超時間(邏輯結構) | 提供事件檔案的寫作規範與分析視角 |
| **時間線檔案** | 記錄低烈度常態事件的序列 | 宏觀(長時段) | 吸收未達到建檔門檻的日常事件 |
**破除常見簡化**:**把「事件檔案」當作「行為體檔案」的附屬品是錯誤的。** 雖然行為體驅動了事件,但事件一旦發生,就會反過來重塑行為體的約束條件。事件檔案具有獨立的分析價值,它是驗證行為體檔案中「偏好」與「紅線」是否準確的試金石。
## 範本案例解析
為了使規範可操作,以下選取兩個關鍵事件,展示規範如何落地。
### 範本一:2022年裴洛西訪台 (`events/key_events/event_pelosi_visit_2022.md`)
此事件是典型的 `[DIP] + [MIL] + [L3]` 複合型高烈度事件。
- **一句話濃縮的提煉**:不能只寫「裴洛西訪台」,必須提煉出「打破慣例」與「軍演常態化」的因果鏈。
- **過程的回合制**:應分為「訪問前(威脅與試探)」、「訪問中(象徵性展示)」、「訪問後(軍事反制與法律戰升級)」三個回合,而非按8月2日、8月3日羅列。
- **4 永久改變的挖掘**:這是最考驗分析深度的部分。初級分析只能看到「軍演」,深度分析必須提煉出「海峽中線默契的消亡」與「和平統一優先性的實質降級」。這兩點是物理與法律層面的不可逆變化。
### 範本二:2014年太陽花學運 (`events/key_events/event_sunflower_2014.md`)
此事件是典型的 `[POL] + [ELEC] + [L4]` 國內政治結構性事件。
- **背景的結構性視角**:不能只寫「服貿協議引發爭議」,必須揭示「兩岸經貿整合速度與台灣國內分配正義的結構性矛盾」,以及「代議民主失靈(30秒通過委員會)」的近因。
- **影響的多維度拆解**:不僅是佔領立法院的物理影響,更核心的是「兩岸協議監督條例」的法制化,實質凍結了後續兩岸經貿制度化進程。
- **4 永久改變的提煉**:1. 兩岸經貿制度化進程的永久性凍結;2. 台灣青年世代政治參與範式的轉變(從代議轉向直接行動);3. 民進黨執政路線的底定(向青年世代與反中板塊靠攏);4. 北京對台經濟讓利紅利政治轉化效率的永久性質疑。
## 關聯分析
本規範並非孤立存在,其有效性依賴於以下關聯:
1. **`constitution/writing_principles.md`** —— 提供總寫作規範與措辭紅線。本規範中的「措辭規範」段落是其具體化延伸,用於交叉驗證事件檔案是否合規。
2. **`routing/by_event.md`** —— 提供事件分類標籤。本規範要求在 header 中填寫 trigger_label,必須依賴此路由檔案的標籤字典。
3. **`events/key_events/event_pelosi_visit_2022.md`** —— 作為高烈度外交/軍事複合事件的範本。用於對照檢查新寫檔案的結構完整度與分析深度。
4. **`events/key_events/event_sunflower_2014.md`** —— 作為國內政治/社會運動事件的範本。用於對照檢查「4 永久改變」在非軍事領域的提煉方法。
5. **`domestic/party_system.md`** —— 提供台灣政黨板塊的底層結構。當事件檔案分析國內政治影響時,必須與此檔案進行交叉驗證,確保結論不違背既有板塊結構。
## 常見的分析陷阱
在運用本規範生成或分析事件檔案時,分析者最易陷入以下誤區:
1. **倒果為因的回溯性偏誤**:在撰寫「4 永久改變」時,將事件後數年才發生的變化,強行歸因於該事件。例如,將2024年選舉結果完全歸因於2022年軍演。**修正**:永久改變必須是事件直接觸發的系統代碼修改,而非長期趨勢的必然結果。
2. **高估短期衝擊,低估長期結構位移**:在「影響」段中花大量筆墨描述股市跌了多少點、輿論多憤怒,卻在「4 永久改變」中無話可說。**修正**:情緒反應與市場波動是噪音,制度變遷與默契消亡才是信號。
3. **將行為體意圖等同於結構結果**:在「過程」與「影響」中,過度依賴行為體的官方宣告,將「威懾意圖」直接寫成「威懾成功」。**修正**:必須區分意圖與結果,威懾是否成功需看對手後續行為是否改變。
4. **單一因果鏈的潔癖**:試圖為複雜事件找到一個唯一的原因或影響。例如,認為裴洛西訪台僅僅是因為美國國內政治。**修正**:政治事件必然是多因多果,背景段必須呈現結構性遠因與觸發性近因的疊加。
5. **忽視沉默的行為體**:在美中台三角中,只關注發聲的兩方,忽略保持沉默但行動的一方。例如,2022年危機中台北的戰略克制,本身就是重要的結構信號,不應因為聲音小而忽略。
## 仍不確定的部分
本規範在應用於以下情境時,仍存在不確定性:
1. **低烈度長尾事件的門檻判定**:對於如「農產品禁令」或「無人機擾台」等低烈度但高頻次事件,究竟應獨立建檔,還是併入時間線檔案,目前的標準仍顯模糊,需依賴分析者的主觀判斷。
2. **「永久改變」的驗證週期**:宣告一個變化為「永久」需要時間檢驗,但在活檔案中必須提前預判。預判的錯誤率目前缺乏系統性評估。
3. **多事件疊加的歸因分離**:當兩個重大事件同時發生(如選舉年疊加外交危機),其影響難以在事件檔案中乾淨剝離,交叉引用時容易出現歸因重複。
## 與其他文件的關係
- `constitution/writing_principles.md` —— 總寫作規範,本檔案的措辭規範受其約束
- `routing/by_event.md` —— 事件路由索引,本檔案依賴其標籤系統進行分類
- `events/key_events/event_pelosi_visit_2022.md` —— 外交/軍事件範本,展示本規範的實體落地
- `events/key_events/event_sunflower_2014.md` —— 國內政治事件範本,展示本規範的實體落地
事件檔案不是新聞稿的歸檔,而是將時間序列上的破裂口轉譯為結構性分析單元的規格化工具。
last_updated: 2025-03-16
@@ -0,0 +1,294 @@
# 國台辦新聞發布資料指引
---
**類型**: 分析框架(解釋、分類與實務操作工具)
**當前角色**: 資料庫檢索與信號解碼方法論指引
**何時調用此檔案**: 需要檢索、追蹤或解讀國台辦例行新聞發布會、緊急聲明或官網文本時;需要區分國台辦發布內容的「日常基線」與「戰略位移」時;需要利用國台辦公開資料庫進行歷史比对與詞頻分析時;評估北京對台輿論戰與法理鬥爭的節奏時
---
## 一句話濃縮
國台辦新聞發布會不是即興劇場,而是高度劇本化的信號發射器;解讀的關鍵不是聽發言人『說了什麼』,而是比對措辭模板的『變異』、發布時機的『違常』與官網歸檔的『缺席』,從高頻的日常噪音中過濾出低頻的戰略位移。
---
## 這個框架的目的
本框架是**解釋、分類與實務操作工具**。
國台辦新聞發布會是北京對台政策最高頻、最公開、最制度化的信號輸出端口。然而,高頻率伴隨高噪音——大部分發布內容是重複的制式回應,其功能是維持政治在場感與體制績效,而非傳遞新信息。將國台辦的每一次發言都視為同等權重的政策信號,必然導致信號疲勞與誤判。
本框架解決的核心問題是:
> 給定國台辦的新聞發布文本或發布行為,如何系統性地將其分類、解碼,並從中提取具有戰略價值的信號?如何利用國台辦官網的公開資料庫進行結構化的歷史回溯與交叉驗證?
核心主張:**國台辦發布的信號價值,與其偏離「例行模板」的程度成正比。模板的變異、發布節奏的打破、以及文本在官網歸檔時的微調,才是真正的分析富礦。**
---
## 框架的核心結構一:制度安排與發布頻率的基線意義
### 這是什麼事實?
國台辦例行新聞發布會通常安排在每週三上午10點舉行,原則上每兩週一次(遇重大節日或特殊情況可能調整)。發布會由新聞局局長(現任陳斌華)或副局長(如朱鳳蓮)主持,地點在國台辦新聞發布廳。流程通常為:發言人開場宣讀聲明或政策→開放兩岸及外媒記者提問→發言人回應。
### 這個事實如何嵌入更大的政治結構?
這種高度規律的「雙週節奏」,是北京對台工作「制度化與常態化」的表演。在兩岸官方溝通管道(陸委會與國台辦)中斷的當下,例行記者會成為北京單向向台灣及國際社會傳遞意志的唯一常設窗口。然而,規律性也帶來了「績效壓力」——對台系統必須在每兩週的固定節點上產出足夠的「強硬姿態」,以向內部證明對台工作沒有軟化,這導致大量無實質政策支撐的「口頭威懾」被生產出來。
**結構性命題**:例行發布會的「儀式性」大於「信息性」。其核心功能不是傳遞新政策,而是維持對台工作的「在場感」與體制運轉的「績效可見性」。這種績效驅動導致國台辦的措辭存在內在的「通貨膨脹」傾向——為了在每次記者會上都顯得足夠強硬,形容詞與威脅詞必須不斷升級,直到升級空間耗盡。
### 分析時如何用這個事實?
1. **建立「時間錨點」**:分析任何國台辦表態前,先確認其是否為例行記者會。例行記者會上的強硬表態,其信號權重低於非例行時機的表態。
2. **識別「缺席」與「暫停」**:當例行記者會被無故取消、推遲,或發言人迴避特定話題時,這通常是高層正在進行政策辯論、尚未定調的標誌。例如,2022年8月裴洛西訪台期間,國台辦未按慣例召開記者會,而是以發布《台灣問題與新時代中國統一事業》白皮書替代,這標誌著回應層級已超越國台辦的日常權限。
3. **測量「績效通脹」**:當發現國台辦在無重大事件的例行記者會上,使用極端措辭(如「死路一條」、「不惜一戰」),這通常不是政策升級,而是對內績效展示的修辭通脹,應降級評估其信號價值。
---
## 框架的核心結構二:發布內容的類型學
國台辦的發布內容可分為四種理想型。不同類型的信號強度、不可逆性與受眾邏輯截然不同。
| 內容類型 | 特徵與識別標準 | 信號強度與不可逆性 | 結構功能 | 範例 |
|---|---|---|---|---|
| **政策發布** | 宣布具約束力的法規、行政措施、清單。通常有書面文件伴隨。 | 極高(不可逆性最強) | 將政治意志制度化,提供執法依據 | 發布《懲治台獨22條意見》(2024)、宣布暫停ECFA部分產品關稅減讓 |
| **回應表態** | 針對記者提問的特定事件(如美台軍售、賴清德講話)進行定性與譴責。 | 中(具彈性,可隨事件降溫而淡化) | 劃設紅線,測試輿論反應,威懾對手 | 回應美對台軍售:「堅決反對」、「將採取一切必要措施」 |
| **譴責/批判** | 對台執政黨或特定人物的常規化攻擊,無具體對應事件。 | 低(日常基線噪音) | 維持敵我界限,內部動員,績效展示 | 例行指責民進黨「倚外謀獨」、「欺騙民眾」 |
| **邀請/統戰** | 向台灣在野黨、民間團體或青年發出交流邀請,或宣布惠台措施。 | 中低(具可逆性,隨時可收回) | 分化台灣內部,塑造「兩岸一家親」意象 | 邀請台灣青年參訪、宣布恢復部分兩岸航點 |
### 分析含義:類型組合的判讀
單一類型的發布容易誤導,必須觀察類型組合。
1. **「政策發布 + 譴責/批判」**:這是典型的「硬威懾」組合。政策提供牙齒,譴責提供合法性論述。分析時應聚焦政策的具體條文與執行機制,而非譴責的修辭。
2. **「邀請/統戰 + 回應表態」**:這是「軟硬兩手」的並行。對台執政黨強硬(回應表態),對台在野或民間溫和(邀請)。分析時需交叉引用 `narratives/china_side/unification_process_discourse.md`,確認這種分化策略是否服務於當前「以融促統」的上位論述。
3. **「純譴責/批判」(無政策、無邀請)**:這是「自動駕駛模式」的典型特徵。官僚系統在缺乏高層新指令時,按慣性產出的空轉信號,不具備戰略分析價值。
**破除常見簡化**:把國台辦的「譴責/批判」等同於「政策意圖」是常見錯誤。國台辦的譴責多數是滯後的反應,而非前瞻的預告。只有當譴責伴隨「政策發布」時,才具備實質威脅。
---
## 框架的核心結構三:措辭模板的「信號性」與「配重體系」
國台辦的發言是高度模板化的。解讀的核心不是理解單一詞彙,而是識別模板的「變異」與「配重」的偏移。詳細詞彙權重請嚴格參照 `playbooks/analyzing_beijing_signal.md`,此處提供的是針對國台辦發布會的特定操作邏輯。
### 模板的構成與變異
國台辦對特定事件(如美台互動、台灣大選)的回應,通常遵循固定模板:**定性(是什麼性質)→ 歸責(誰的錯)→ 威脅(會有什麼後果)→ 立場(我們的主張)**。
**識別技巧**:
- **定性升級**:從「挑釁行徑」升級為「嚴重挑釁」,信號價值低(修辭通脹);從「破壞台海和平」升級為「觸碰一中原則底線」,信號價值高(紅線位移)。
- **威脅具體化**:從「將採取一切必要措施」升級為「將依據《反分裂國家法》採取行動」,信號價值高(引入法律戰工具)。
- **立場收窄**:從「寄希望於台灣當局也寄希望於台灣人民」變為僅提「寄希望於台灣人民」,信號價值高(關閉對話窗口)。
### 配重體系的偏移
北京的對台論述常以對偶形式出現,國台辦的發布是觀察配重偏移的最佳窗口。
| 配重組合 | 日常基線(平衡) | 升級信號(向右傾斜) | 緩和信號(向左傾斜) |
|---|---|---|---|
| 和平統一 ↔ 不承諾放棄武力 | 兩者並列,前者為主 | 省略「和平」,或將「武力/反獨」前置為主句 | 僅提「和平發展/融合」,省略武力 |
| 反對台獨 ↔ 懲治台獨 | 使用「反對」、「譴責」 | 使用「懲治」、「依法追責」、「嚴懲」 | 回歸「反對」,淡化法律語言 |
| 九二共識 ↔ 一中原則 | 使用「九二共識」(保留模糊空間) | 使用「一中原則」(強調國際法與國內法剛性) | 恢復「九二共識」表述 |
**分析含義**:在國台辦記者會上,發言人通常會唸準備好的講稿。觀察配重偏移的最佳時機是**開場宣讀的聲明**,而非問答環節。問答環節的措辭可能因記者提問的引導而臨時偏移,信號價值較低;開場聲明是經過內部審核的定調文本,配重偏移代表高層意志。
---
## 框架的核心結構四:發言人個人風格的「可觀察性」與邊界
### 這是什麼事實?
國台辦歷任發言人具有鮮明的個人風格:
- **馬曉光(2013-2022)**:老派官僚風格,語速平穩,偶有情緒化表現(如翻白眼、語塞),慣用傳統對台統戰語彙。
- **朱鳳蓮(2019-至今)**:語速極快,攻擊性強,擅長使用網路化語言與反問句(如「民進黨當局還要欺騙台灣民眾多久?」),帶有明顯的「戰狼」色彩。
- **陳斌華(2023-至今)**:曾任新華社港台部記者,熟悉台灣媒體生態。風格趨向「軟硬兼施」,頻繁使用閩南語唸詩或軟性敘事開場,隨後轉入硬性譴責。
### 這個事實如何嵌入更大的政治結構?
發言人的風格選擇,反映了北京對台輿論戰的策略演變。從馬的「官僚宣讀」到朱的「網路戰狼」,再到陳的「軟包裝硬威懾」,這不是發言人個人的即興發揮,而是對台系統為了適應台灣媒體環境與受眾偏好而進行的「產品迭代」。陳斌華的閩南語與詩詞,是為了穿透台灣基層的「軟性包裝」,其內核仍是中共中央台辦的硬性指令。
**洞察**:發言人的風格是受控的表演,但風格的切換本身暴露了機構的內在矛盾。當陳斌華前一分鐘用閩南語吟誦「兩岸一家親」,下一分鐘嚴厲宣讀「懲治台獨」法規時,這種精神分裂式的表演,直觀呈現了國台辦同時扮演「慈善家」與「警察」的結構性困境——軟性統戰的吸引力,永遠被硬性威懾的排斥力所抵消。
### 分析時如何用這個事實?
1. **剝離風格,直視文本**:不要被發言人的語氣、反問句或閩南語干擾。分析時只提取核心動詞與名詞。無論朱鳳蓮的語氣多麼激烈,如果她使用的仍是「堅決反對」而非「依法懲治」,信號強度就沒有升級。
2. **觀察「脫稿」時刻**:發言人脫離講稿的補充說明,有時是個人失誤,有時是刻意為之的「非正式信號」。判斷標準:如果脫稿內容與講稿基調一致,是強調;如果脫稿內容降溫或模糊化了講稿的強硬措辭,可能是發言人試圖保留迴旋空間。
3. **識別「受眾錯位」**:陳斌華的閩南語主要針對台灣民眾(統戰),但其法律威脅主要針對台灣執政黨(鬥爭)。分析時必須將同一場發布會的內容按受眾拆解,避免將對內(大陸民眾)的強硬表態誤判為對台灣的即刻行動預告。
---
## 框架的核心結構五:重大事件後的「緊急發布」與多部門協調
### 這是什麼事實?
當發生重大突發事件(如美高層訪台、台灣大選結果出爐、台方領導人發表挑釁講話)時,國台辦會打破「每兩週一次」的例行節奏,以三種方式進行緊急發布:
1. **發言人書面聲明**:通過新華社或國台辦官網即時發布,通常篇幅短、措辭極端。
2. **臨時專場記者會**:罕見,僅在極端事件(如2022年裴洛西訪台)時舉行。
3. **官網「回應」專欄**:不召開記者會,僅在官網以「國台辦發言人答記者問」形式發布。
### 這個事實如何嵌入更大的政治結構?
緊急發布不是國台辦的獨角戲,而是北京多部門協同反應的對台組件。觀察緊急發布的關鍵,在於其與外交部、國防部、新華社的「時間差」與「措辭差」。
**洞察:多部門協同的「時間差」是觀察決策層級的窗口。**
- **國台辦先發聲,外交部後跟進**:表示事件首先被定性為「兩岸內部事務」,北京試圖將其限制在兩岸框架內,避免國際化。
- **外交部先發聲,國台辦後跟進**:表示事件首先被定性為「中美博弈」,對台施壓是對美反應的附屬品。交叉引用 `narratives/china_side/external_interference_discourse.md`,此時國台辦的聲明必然強調「外部干涉」與「倚外謀獨」的連結。
- **國防部先發聲,國台辦僅作背書**:表示軍事回應主導,國台辦降格為軍事行動的合法性解說員。
### 分析時如何用這個事實?
1. **繪製「反應時間軸」**:記錄事件發生後,各部門發聲的精確時間(精確到分鐘)。這比分析措辭更能揭示決策權重。
2. **識別「降級處理」**:如果預期應有緊急發布(如台方領導人過境美國),但國台辦僅以官網「答記者問」回應,未召開記者會,這可能是刻意降溫,避免將事件炒作為高強度對抗。
3. **比對「口徑差異」**:檢查國台辦緊急聲明與新華社社論的措辭差異。如果新華社使用更極端的修辭,說明宣傳系統在對內動員,而國台辦的對外信號相對克制。
---
## 框架的核心結構六:國台辦官網的「資料庫價值」與檢索策略
### 這是什麼事實?
國台辦官網(www.gwytb.gov.cn)設有「新聞發布」專欄,完整歸檔了歷次例行記者會的文字實錄、發言人表態(書面聲明與答記者問)以及重要政策文件。該資料庫是開放的,支持站內搜索。
### 這個事實如何嵌入更大的政治結構?
官網歸檔不是被動的記錄,而是經過審查的「歷史定本」。發布會上的口誤、發言人的脫稿內容,在文字實錄中可能被無聲修正或刪除。官網呈現的是北京希望後人看到的「標準歷史」。
**洞察:官網歸檔的「時序篡改」與「缺席」是高價值信號。**
- **時序篡改**:有時,一篇已發布的聲明會在數小時後被替換為修改版(如調整措辭軟硬度)。比對快照(如透過 Wayback Machine)可發現這種微調,這反映了高層對定調的二次修正。
- **缺席**:如果某次例行記者會的視頻回放或文字實錄延遲發布,或部分敏感問答被刪除,這通常是該段落偏離了高層滿意的口徑。
### 分析時如何用這個事實?(實務檢索策略)
1. **歷史同日比对**:
- **操作**:檢索過去3-5年同一月份或同一政治節點(如台灣大選後、雙十講話後)的國台辦回應。
- **目的**:建立「歷史基線」,識別當前回應是升級還是降級。
- **語法**:在站內搜索特定詞組(如「嚴重關切」、「不惜一戰」),按時間排序觀察其出現頻率的變化。
2. **詞頻追蹤**:
- **操作**:追蹤特定新詞(如「台獨頑固分子」、「融合發展示範區」)在官網首次出現的時間,及其後續在記者會中被重複的頻率。
- **目的**:識別「新詞植入」到「常態化」的週期,驗證政策從測試到定型的過程。
3. **問答配對分析**:
- **操作**:提取文字實錄中的「記者提問」與「發言人回應」配對。
- **目的**:識別北京允許被問及的問題邊界。如果某類問題(如軍演細節、特定台獨名單)長期無人提問,或發言人以「不回應假設性問題」拒答,這劃定了當前輿論管控的紅線。
---
## 實務應用:追蹤與分析的操作步驟清單
當需要追蹤國台辦發布會或分析其歷史文本時,請按以下步驟執行。
### 步驟一:定位發布性質(事實層)
- [ ] **確認發布類型**:是例行記者會、臨時專場、書面聲明,還是官網「答記者問」?
- [ ] **確認時機**:是否鄰近台灣/美國政治節點?是否為突發事件後的緊急反應?
- [ ] **確認多部門協同狀態**:外交部、國防部是否同步發聲?時間差為何?
- [ ] **輸出**:此發布的「類型-時機-協同」座標。
### 步驟二:提取文本變異(對照層)
- [ ] **調取歷史基線**:在國台辦官網檢索去年同期或上一次同類事件的發言人表態。
- [ ] **識別模板變異**:對照「定性→歸責→威脅→立場」模板,標記哪個環節發生了升級或降級。
- [ ] **識別配重偏移**:對照 `playbooks/analyzing_beijing_signal.md` 的配重體系,標記核心詞彙的增刪與權重變化。
- [ ] **輸出**:文本變異清單(什麼變了,什麼沒變,偏移方向為何)。
### 步驟三:驗證政策與行動錨點(驗證層)
- [ ] **檢索政策層**:過去30天內,是否有對應的法規、清單、經貿措施出台?
- [ ] **檢索行動層**:過去14天內,是否有異常的軍事、海警、外交動作?
- [ ] **輸出**:政策與行動的配合狀態(有跟進 / 無跟進 / 矛盾)。
### 步驟四:受眾與風格拆解(意圖層)
- [ ] **拆解受眾**:將發布內容拆分為對台執政黨(威懾)、對台在野/民眾(統戰)、對美(劃線)、對內(動員)四個維度。
- [ ] **剝離風格**:剔除發言人的修辭包裝(閩南語、反問句、情緒化表達),提取核心指令。
- [ ] **輸出**:信號的首要受眾與剝離風格後的核心意圖。
### 步驟五:官網歸檔比對(反思層)
- [ ] **比對視頻與文字實錄**:檢查文字實錄是否刪減了發言人的脫稿內容或口誤。
- [ ] **檢索歷史快照**:檢查聲明發布後是否有二次修改的痕跡。
- [ ] **輸出**:文本的「標準化」程度評估,以及被掩蓋的微弱信號。
### 步驟六:綜合校準(校準層)
- [ ] **計算信號強度**:基於模板變異程度 × 多層一致性 × 不可逆性。
- [ ] **標記不確定性**:此判斷可能出錯的原因(如:是否為官僚自動駕駛?是否為內部博弈外溢?)。
- [ ] **輸出**:校準後的信號強度與方向判斷,以及不確定性聲明。
---
## 關聯分析
### 關聯一:與「國台辦機構檔案」的連結
本框架是 `entities/foreign_actors/taiwan_affairs_office.md` 的操作延伸。機構檔案揭示了國台辦「一套人馬兩塊牌子」的結構性張力,本框架則提供了觀察這種張力在公開文本中如何表現的具體方法。當國台辦在發布會上表現出行政語言與政治鬥爭語言的撕裂時,可回溯機構檔案驗證其體制根源。功能:提供事實層與結構對照。
### 關聯二:與「北京信號解讀方法」的連結
本框架是 `playbooks/analyzing_beijing_signal.md` 在國台辦這一特定信號源的落地應用。信號解讀方法提供了「論述-政策-行動」三層穿透的宏觀框架,本框架則提供了針對國台辦高頻文本的微觀解碼技巧(如模板變異、發言人風格剝離、官網歸檔比對)。功能:提供分析框架與操作方法。
### 關聯三:與「外部干涉論述」的連結
國台辦在回應美台互動時,必然調用 `narratives/china_side/external_interference_discourse.md` 中的論述模板。分析國台辦記者會的問答環節,可以觀察北京如何將具體的軍售或訪問事件,嵌入「外部干涉—倚外謀獨—破壞台海和平」的因果鏈條中。功能:提供論述層脈絡與交叉驗證。
### 關聯四:與「統一進程論述」的連結
國台辦的「邀請/統戰」類發布,是 `narratives/china_side/unification_process_discourse.md` 的日常實踐。透過追蹤國台辦關於「兩岸融合發展」、「民主協商」的發布頻率與具體內容,可以評估北京「以融促統」戰略的實際推進節奏,而非僅依賴高層講話。功能:提供事實層與長期脈絡驗證。
### 關聯五:與台灣媒體生態的連結
國台辦記者會的問答環節,是觀察北京如何理解並試圖影響台灣媒體的窗口。發言人對特定台灣媒體(如中天、TVBS vs. 自由時報)的點名回應或迴避,反映了北京對台灣媒體光譜的認知與操作。功能:提供受眾分析與對照。
### 關聯六:與大陸對內宣傳的連結
國台辦記者會的強硬表態,經常被《人民日報》、《環球時報》等官媒二次加工後對內發布。比對國台辦原文與官媒加工後的標題,可以識別北京對內動員的強度。如果官媒標題的措辭遠比國台辦原文極端,說明內部動員需求高。功能:提供多層次受眾分析與交叉驗證。
---
## 常見的分析陷阱
### 陷阱一:將「高頻譴責」等同於「政策升級」
國台辦的例行譴責是維持績效的日常噪音。把每次「堅決反對」或「強烈譴責」都視為政策升級的證據,會導致信號疲勞與誤判。只有當譴責伴隨新的法律工具或行政措施時,才具備升級意義。
### 陷阱二:過度解讀發言人的「情緒化表現」
發言人的語氣加重、翻白眼或反問句,多是表演性質,服務於內部績效或對台心理戰。將其視為發言人個人憤怒或政策即將轉向的證據,是將表演誤判為真實。
### 陷阱三:忽略官網歸檔的「過濾效應」
直接引用國台辦官網的文字實錄作為唯一事實來源,忽略了官網會無聲修正口誤或刪除敏感問答。這會導致分析基於「被過濾後的標準歷史」,遺漏了真正的微弱信號。
### 陷阱四:假設國台辦記者會是「即興問答」
記者會上的多數提問是經過安排或預審的,發言人的回答也是照本宣科。將問答環節視為真實的資訊交鋒,會高估其信息含量。真正的信號在開場聲明,而非問答。
### 陷阱五:將「書面聲明」與「記者會表態」視為同等權重
書面聲明(尤其是新華社授權發布的)通常代表更高層級的定調,而記者會上的口頭表態則包含較多的官僚慣性與彈性。將兩者混為一談,會導致信號強度校準失準。
### 陷阱六:忽略「缺席」的信號
當預期國台辦應對某事件表態卻保持沉默,或例行記者會無故取消時,將其視為「無事發生」。在高度劇本化的體制中,「不說話」比「說狠話」更需要高層授權,其信號價值極高。
---
## 仍不確定的部分
1. **記者會提問的預審機制**:我們無法確切知道有多少提問是記者自發的,有多少是國台辦新聞局預先安排的。這導致我們難以判斷問答環節的真實信息含量與「測試氣球」性質。
2. **文字實錄的審批流程**:官網文字實錄在發布前,是否需要經過研究局或更高層級的審批?審批的時間差與修改幅度,直接影響我們對「過濾效應」的評估,但目前缺乏內部流程資訊。
3. **發言人與研究局的互動邊界**:發言人在記者會上的脫稿補充,是個人臨場反應,還是與研究局官員在現場的眼神/耳機確認?這決定了脫稿內容的信號權重。
4. **官網歷史快照的完整性**:依賴 Wayback Machine 等工具捕捉官網的二次修改,存在盲區。我們無法確定未被捕捉到的修改有多少,因此可能低估了「時序篡改」的頻率。
5. **多部門協同的決策時滯**:當國台辦與外交部、國防部的發聲存在時間差時,我們能觀察到現象,但難以確定這是決策層級差異導致的必然延遲,還是部門間協調不暢的結果。
---
## 與其他文件的關係
- `entities/foreign_actors/taiwan_affairs_office.md` —— 機構檔案。提供國台辦的體制定位與內部結構,用於解釋發布會行為背後的組織邏輯。
- `playbooks/analyzing_beijing_signal.md` —— 信號分析框架。提供宏觀的三層穿透與配重體系方法論,本檔案是其針對國台辦文本的微觀操作手冊。
- `narratives/china_side/external_interference_discourse.md` —— 外部干涉論述。提供國台辦回應美台互動時的論述模板與因果鏈條,用於識別其歸責邏輯。
- `narratives/china_side/unification_process_discourse.md` —— 統一進程論述。提供國台辦發布「惠台/統戰」措施時的上位戰略脈絡,用於評估其長期意圖。
---
## 一句話濃縮
> 國台辦新聞發布會是高度劇本化的信號發射器,解讀的關鍵不在於發言人說了什麼,而在於比對模板的變異、時機的違常與歸檔的缺席,從高頻的績效噪音中過濾出低頻的戰略位移,並永遠警惕將修辭通脹誤判為政策升級。
---
last_updated: 2025-05-19
@@ -0,0 +1,317 @@
# 电子战(EW)能力
---
**类型**: 机构/装备档案
**当前角色**: 台湾防卫体系的电磁频谱守门员 / 杀伤链存续的关键变量 / 美台军事技术协作的最深黑箱
**出生时间**: 1950年代(通信兵起源)/ 2017年(资通电军成军)/ 1990年代(专精电战装备引入)
**何时调用此档案**: 分析台湾指管通情(C4ISR)系统在战时的存活率;评估反舰/防空飞弹在电子干扰下的命中率;解读资通电军的组织功能与军种博弈;观察美台在电磁频谱领域的合作与限制;判断解放军「制电磁权」对台海战局的决定性影响;评估豪猪战略中「看不见的神经」是否脆弱
---
## 一句话浓缩
电子战能力是台湾「不对称防卫」的**隐形神经**——它不直接杀敌,却决定了反舰飞弹是「精准刺杀」还是「盲目挥舞」;它是资通电军与各军种在「网路战光环」与「传统电战真空」间的**组织张力场**,更是美台军事合作中「给装备但控核心」的**技术依附缩影**。
---
## 事实层:概念、组织与装备
### 电子战的基本概念与结构性命题
**事实内容**:
电子战(Electronic Warfare, EW)是争夺电磁频谱使用权的军事行动,核心分为三个子领域:
1. **电子攻击(EA)**:使用电磁能量(干扰、欺骗、反辐射武器)削弱或摧毁敌方战斗力。
2. **电子防护(EP)**:保护己方电磁频谱使用不受敌方攻击(如跳频、低截获率雷达)。
3. **电子支援(ES)**:截获、定位、识别敌方电磁辐射,为战术决策与电子攻击提供情报。
**脉络层**:
现代战争已从「机械化」转向「信息化」,电磁频谱是所有信息流的物理载体。没有频谱权,雷达是瞎子、飞弹是废铁、指管是断线。在台海场景中,解放军的首要目标不是歼灭有生力量,而是「致盲」与「断链」——即夺取制电磁权。
**结构性命题**:
**电子战是台湾防卫体系的「乘数」与「归零」因子。** 当台湾具备有效的电子防护与频谱管理时,雄风飞弹与天弓飞弹的威慑力得以乘数放大;当解放军取得制电磁权时,台湾的杀伤链将在开战数小时内归零,防卫体系退回各自为战的二战模式。分析台湾战力,若不将电子战作为前置变数,所有飞弹数量与舰艇吨位的计算都将失真。
### 台湾电子战部队:资通电军的组织与张力
**事实内容**:
- **资通电军指挥部**:2017年由原通信资讯指挥部、电展室等单位整编而成,直属国防部参谋本部。为台湾层级最高的资安、网路战与电子战统筹机构。
- **空军电战大队**:操作C-130HE电子战机(天干机)与各型电战荚舱,负责空中电磁压制与电子情报侦察。
- **海军电战大队**:配备舰载电子战系统(如SLQ-32),负责水面舰的电子防护与反反舰飞弹干扰。
- **陆军资电群**:负责地面通信保障与部分地面电子反制。
**脉络层**:
资通电军的成立,象征台湾将「资安、网路、电磁」视为统一作战域。然而,其实质运作存在严重的内部张力:
| 组织面向 | 网路战(Cyber) | 传统电子战(EW) | 产生的结构性张力 |
|---------|---------------|----------------|---------------|
| 政治可见度 | 极高(防制假讯息、认知战) | 极低(看不见的电磁波) | 资源倾斜:预算与政治关注集中于网路战,传统电磁频谱作战被边缘化 |
| 技术来源 | 软体为主,可自主开发迭代 | 硬体为主,高度依赖美方军售 | 速度落差:网路战可快速更新,电战硬体受制于军售期程与黑盒子 |
| 战术效果 | 非动能,延迟性,难以量化 | 动能辅助,即时性,决定飞弹命中率 | 评估失真:将网路战能力误认为整体电磁防卫能力 |
**分析如何用此事实**:
分析时,**绝不能将「资通电军战力」等同于「电子战战力」**。资通电军的强项在网路防御与认知战,但在传统电磁频谱的电子攻击(EA)与电子支援(ES)上,仍高度依赖空军与海军的专业大队。当政治人物宣称「资通电军已成军」时,必须追问:其电磁频谱压制能力是否同步提升?还是仅增加了键盘战士?
### 电子战装备:已知、推测与黑盒子
**事实内容**:
台湾的电子战装备因高度机密,多处于推测与零星披露状态,核心装备可分为三类:
1. **空中电战平台**:
- **C-130HE电子战机(天干机)**:全球仅台湾操作的特殊改型,具备电子干扰与电子情报搜集能力。数量极少(推测2-4架),机龄老旧,战时生存率极低。
- **F-16V电战荚舱**:搭配ALQ-131或更先进的ALQ-184/V等外挂荚舱,提供战机自我防护与伴随干扰能力。
- **鹰眼E-2K预警机**:虽非专职电战机,但其雷达与电子支援措施(ESM)是台湾空中电磁情报的关键节点。
2. **地面/舰载电战系统**:
- **舰载SLQ-32(V)2/3电子战系统**:配备于成功级、纪德级等主战舰艇,提供反飞弹干扰与电子支援。
- **陆基机动电战车**:中科院研发,用于干扰敌方无人机数据链与通信。
3. **诱饵与反制系统**:
- **AN/ALE-47干扰丝/热焰弹洒布器**:各型战机标准配备。
- **舰载干扰火箭(如Mk 36 SRBOC)**:水面舰反飞弹最后防线。
**脉络层**:
台湾的电子战装备结构呈现「防护强、攻击弱;旧式多、新式少」的特征。C-130HE是唯一的专职电子攻击平台,但其基于越战时代的干扰逻辑,面对解放军现代相位阵列雷达与跳频通信,效能存疑。F-16V的荚舱虽较先进,但属「自我防护」等级,无法执行区域性的防空压制(SEAD)。
**分析如何用此事实**:
评估台湾电子战能力时,必须区分「电子防护(EP)」与「电子攻击(EA)」。台湾拥有一定程度的EP能力(让己方飞弹不被干扰),但极度缺乏EA能力(压制敌方雷达与通信)。这意味着台湾在电磁频谱上本质是「被动挨打与防御」,缺乏「主动致盲对手」的手段。
### 与美方电子战合作:给装备,控核心
**事实内容**:
台湾的先进电子战装备几乎全数来自美方军售。近年关键合作包括:
- F-16V升级案中包含新型电战系统与ALQ-211(V)9整合式电战套件。
- 海军MK 36干扰火箭与SLQ-32技术支援。
- 非公开的电磁参数共享与频谱管理技术协助。
**脉络层**:
美台电子战合作是典型的「不对称依附」。美方出售硬体,但核心的「威胁参数库」(Threat Library,即识别敌方雷达与通信特征的资料库)与「干扰技术码」(Techniques Generator,即针对特定威胁的干扰演算法)通常由美方控管,以加密黑盒子形式提供。
这种模式产生双重矛盾:
1. **战力依赖**:台湾的电子战系统必须依赖美方定期更新参数库,以应对解放军新型雷达。若战时美方断线或无法即时更新,系统将迅速失效。
2. **技术黑箱**:详见 `entities/organizations/chung_shan_institute.md`。中科院虽负责在地整合,但无法触碰底层逻辑,导致无法根据实战反馈即时修改干扰参数。
**分析如何用此事实**:
分析美台军事关系时,电子战是比飞弹更深的「黑箱」。飞弹买来就能打,但电战系统买来只是空壳,灵魂(参数库)在美方手里。这意味台湾的电磁频谱防卫权,实质上有一部分由华盛顿掌握。若美方评估局势不宜升高冲突,限制参数更新,台湾的电子防护将出现致命空窗。
### 对解放军电子战能力的应对
**事实内容**:
解放军电子战体系已从俄系辅助角色,转型为与美军对标的独立作战域。核心威胁包括:
- **歼-16D电战机**:专职伴随干扰,配备大型电战荚舱,压制防空雷达。
- **运-8/运-9电战机**:大型电子战机,执行远距离区域干扰与通信压制。
- **舰载电子战系统**:055型驱逐舰等配备整合式电子战系统,具备强大的舰队防护与干扰能力。
- **无人机蜂群电战**:利用低成本无人机携带干扰源,实施分布式饱和干扰。
**脉络层**:
面对解放军的「制电磁权」战略,台湾的应对呈现「分散、机动、跳频」的逻辑:
1. **战力保存**:将雷达与指挥车机动化,避免被定点压制。
2. **频率捷变**:雷达与通信采用跳频技术,增加敌方锁定与干扰难度。
3. **诱饵部署**:部署角反射器与假热源,消耗敌方反辐射武器。
**分析如何用此事实**:
台湾的应对本质是「防守反击」,试图在解放军的第一波电磁压制下存活。然而,**破除常见简化**:**把「跳频」与「机动」视为解决电子战劣势的万灵丹是危险的。** 现代电子战已从「点对点干扰」进化为「系统对系统」的频谱压制。解放军的运-9电战机可在战区外实施广域阻塞式干扰,跳频通信在整段频谱被淹没时同样无法运作。台湾的防护措施能增加敌方成本,但无法根本扭转电磁劣势。
### 在豪猪战略中的位置:看不见的神经
**事实内容**:
豪猪战略强调「大量、小型、致命、机动」的不对称战力。反舰飞弹与防空飞弹是刺,但电子战是让刺能命中目标的神经。
**脉络层**:
详见 `narratives/us_taiwan/porcupine_strategy.md`。豪猪战略的隐含假设是:飞弹发射后能看见目标并命中。但在强电磁干扰环境下,雷达被致盲、数据链被切断,飞弹车虽存活,却沦为无法导引的废铁。电子战是豪猪战略的「阿基里斯腱」——它最关键,却最不被政治人物与公众看见,也最难取得预算。
**分析如何用此事实**:
评估豪猪战略的可行性时,必须加入「电磁环境」变数。若在无干扰环境下,台湾的飞弹海极具威慑力;若在解放军取得制电磁权的环境下,飞弹命中率可能骤降至个位数。分析者应将电子战能力视为豪猪战略的「折价系数」。
### 中科院相关研发:在地整合的极限
**事实内容**:
详见 `entities/organizations/chung_shan_institute.md`。中科院资讯通信研究所是台湾电子战研发的核心。主要专案包括:
- **新型外挂电战荚舱**:为IDF经国号战机研发,提升自我防护能力。
- **无人机电战套件**:为锐鸢等无人机研发小型干扰源。
- **网路战与认知战平台**:资通电军的核心软体工具。
**脉络层**:
中科院在电子战领域的角色,是「黑盒子的在地封装者」。由于无法取得美方最核心的干扰演算法,中科院的突破点在于「软体定义无线电」(SDR)与「认知电子战」(利用AI动态调整干扰与抗干扰参数)。然而,硬体性能(如发射功率、频宽)受限于台湾半导体与微波零组件的制造能力,与美军现役装备仍有代差。
**分析如何用此事实**:
不要期待中科院能研发出超越美制ALQ-99或NGJ的电战系统。中科院的真正价值在于:当美方因政治因素无法提供特定电战参数时,中科院能否提供「堪用」的替代方案。这是台湾战时的保底手段,但效能不可与美军同日而语。
### 实战准备状态:看不见的脆弱
**事实内容**:
台湾缺乏电子战实弹演练的客观条件:
- 电磁辐射极易被解放军截收,大规模开启电战系统等于暴露参数。
- 岛内空间狭小,强功率干扰会严重影响民用通信,无法实施广域实兵演练。
- 依赖电脑兵棋推演与模拟器,但模拟器的参数库同样受美方限制。
**脉络层**:
「看不见」是电子战最大的实战障碍。飞弹试射有弹着点可验证,电子战干扰是否有效,只有在实战或高度真实的联合演训中才能确认。台湾的电子战部队长期处于「有装备、少验证」的状态,操作人员对「干扰是否真的压制了敌方雷达」缺乏直觉与信心。
**分析如何用此事实**:
评估战备时,必须对台湾宣称的「电子防护能力」打上问号。和平时期参数库的更新频率、模拟器的真实度、操作员的实战反应速度,都是未知数。这是台湾防卫体系中最不透明的黑箱,也是最可能发生战时瞬间崩溃的环节。
---
## 深层结构:内在矛盾与结构性命题
### 矛盾一:「网路战光环」与「电磁战真空」的资源排挤
资通电军的成立,将网路战推向政治舞台中心,却掩盖了传统电子战的资源匮乏。防制假讯息与骇客攻击有极高的媒体曝光度,容易争取预算;但采购一组新型电战荚舱或升级雷达跳频模组,既无法向公众展示,又受制于美方军售时程。
**内在矛盾**:
- 认知战与网路战威胁是「温水煮青蛙」,传统电磁压制则是「一剑封喉」
- 台湾的预算与注意力却大量流向前者,导致「封喉之剑」生锈
- 资通电军内部也存在软体工程师(高薪、易招募)与电战硬体军官(冷门、难培养)的人才失衡
**分析如何用此矛盾**:
观察国防预算分配时,若资通电军的预算增长主要投入于资安与网路防御,而非电磁频谱作战,则台湾的「不对称战力」在战时将面临「有网无波」的困境:网路防线完好,但雷达与飞弹导引已被致盲。
### 矛盾二:「自我防护(EP)」与「防空压制(EA)」的攻守失衡
台湾的电子战装备几乎全数属于「自我防护」——让战机不被击落、让舰艇不被命中。极度缺乏「防空压制」能力——主动寻找并摧毁敌方雷达与通信节点。
**内在矛盾**:
- 防卫作战的最高效手段,不是挨打后存活,而是打瞎攻击者的眼睛
- 但台湾受制于美方军售限制与政治红线,无法取得进攻性电战平台(如AGM-88反辐射飞弹的最新型与配套电战吊舱)
- 缺乏EA能力,意味着台湾永远在电磁频谱上处于「被动接招」的劣势,解放军可从容组织多波次干扰与打击
**结构性命题**:
**台湾的电子战是「盾的抛光」,而非「矛的铸造」。** 这种失衡不仅是装备限制,更是政治定位的投射——台湾被盟邦期望扮演「不挑衅的防御者」,而非「具备反击能力的威慑者」。分析时,必须认清台湾电子战能力的上限是由华盛顿的政治红线,而非台北的军事需求所决定。
### 矛盾三:「参数库依赖」与「战时自主」的断裂
电子战的核心是「知彼」——精确掌握敌方雷达的脉冲重复频率、跳频规律与信号特征。这依赖长期的电子情报搜集与参数库构建。
**内在矛盾**:
- 台湾的参数库高度依赖美方提供与更新
- 和平时期,美方根据其全球情报网更新参数,台湾受惠
- 战时,若美方不直接参战,参数更新将中断;若解放军启用战前未暴露的新频段或新波形,台湾的黑盒子将无法识别威胁
- 中科院虽试图建立自主参数库,但受限于截收平台数量与距离,对大陆内陆的远距电磁情报搜集能力极弱
**分析如何用此矛盾**:
这是台湾电子战最致命的脆弱点。开战首日,若解放军投入未见于和平时期侦察的新型电战装备,台湾的干扰系统将因「不认识目标」而失效。分析战局时,不能假设和平时期的电子侦察数据能完全适用于战时的高强度电磁环境。
---
## 对比分析:台湾 vs 解放军电子战体系
| 维度 | 台湾电子战体系 | 解放军电子战体系 | 分析含义 |
|------|--------------|----------------|---------|
| **战略定位** | 防卫性,确保指管通情存活 | 攻势性,夺取制电磁权为首要 | 解放军将电磁压制作为开战第一波,台湾仅求存活,战略主动权在彼 |
| **核心平台** | 老旧C-130HE、战机外挂荚舱 | 歼-16D专职电战机、运-8/9大型电战机、舰载整合系统 | 台湾缺乏专职伴随干扰与远距支援干扰平台,电战覆盖范围与功率均居劣势 |
| **技术来源** | 美方军售黑盒子,自主性低 | 俄系基础+自主迭代,高度可控 | 解放军可针对实战反馈即时修改干扰码,台湾需等美方更新,战时恐断链 |
| **作战想定** | 在强干扰下维持杀伤链运作 | 致盲台湾雷达、切断数据链、诱导飞弹脱靶 | 台湾是「防守反击」,解放军是「先发制人」。首波打击的效能决定战局走向 |
| **验证环境** | 模拟器为主,缺乏实兵对抗 | 常态性大规模演训,具备相对真实的电磁对抗环境 | 台湾操作员的实战直觉与系统真实抗扰度,缺乏严苛验证,战时可能发生「平时可用、战时当机」的落差 |
**破除常见简化**:
**将「台湾拥有先进美制电战荚舱」等同于「具备抗干扰能力」是危险的。** 荚舱只是硬体,真正的战力取决于参数库是否最新、操作员是否熟练、以及杀伤链各环节(雷达-指管-飞弹)是否具备同等的抗扰度。木桶效应在电子战中最为显著:只要数据链的某一段被干扰,整条杀伤链即告断裂。
---
## 关联分析
### 关联一:与中科院的在地整合角色
详见 `entities/organizations/chung_shan_institute.md`。
- 功能:提供事实层与结构性命题
- 中科院资通所是台湾电子战软体与整合的核心。理解中科院「黑盒子封装」的局限,即是理解台湾电子战自主性的上限。
- 交叉验证:当中科院宣称「研发新型电战系统」时,应追问其核心射频元件与参数库是否仍受外购限制。
### 关联二:与空军战力存活的因果连结
详见 `entities/organizations/roc_air_force.md`。
- 功能:提供脉络层
- 空军是台湾最依赖电子战的军种。F-16V的存活率,直接取决于其电战荚舱能否有效欺骗解放军空对空飞弹的寻标器。
- 分析时,应将空军战损率评估与电子战效能绑定,而非仅计算双方战机数量。
### 关联三:与上位概念的框架连结
详见 `primitives/concepts/information_electronic_warfare.md`。
- 功能:提供脉络层与理论框架
- 本档案是上位概念在台海场景的具体化。上位概念提供的「电磁频谱即机动域」框架,是分析台湾电子战战略地位的基础。
### 关联四:与豪猪战略的隐性支撑
详见 `narratives/us_taiwan/porcupine_strategy.md`。
- 功能:提供结构性命题
- 电子战是豪猪战略的「隐形神经」。没有电子防护的豪猪,是瞎眼的豪猪。
- 交叉验证:评估豪猪战略预算分配时,若飞弹采购预算远高于电战系统升级预算,即显示战略存在「重刺轻眼」的失衡。
### 关联五:与解放军能力评估的镜像对照
详见 `narratives/cross_strait/pla_capabilities_assessment.md`。
- 功能:提供事实层与用于对照
- 台湾电子战的弱点,正是解放军电子战的强项。理解解放军歼-16D与运-9的战术运用,才能准确定位台湾电子战部队的生存空间。
- 分析时,应将台湾的EP能力与解放军的EA能力进行对抗推演,而非孤立评估。
### 关联六:与反舰飞弹杀伤链的绑定
详见 `entities/equipment/anti_ship_missiles_taiwan.md`。
- 功能:提供事实层与交叉验证
- 反舰飞弹的命中率在无干扰与强干扰环境下可呈现指数级落差。雄风飞弹的导引雷达是否具备足够的电子反反制(ECCM)能力,是决定其威慑力的关键。
- 分析时,不能将飞弹的纸面命中率直接套用于战时想定,必须引入电子战折价系数。
---
## 常见的分析陷阱
### 陷阱一:将「资通电军」等同于「电子战强」
资通电军名称中的「资通」易使人误以为其全面掌握电磁频谱作战。实际上,其核心战力与预算多集中于网路战与资安。传统电子战(雷达干扰、反辐射压制)仍分散于海空军,且资源相对匮乏。将资通电军战力等同于整体电子战战力,会严重高估台湾的电磁防护能力。
### 陷阱二:假设美制电战荚舱能完全压制解放军雷达
美制电战荚舱(如ALQ-184)虽技术先进,但其设计目的多为「自我防护」,即让战机有机会脱离锁定,而非「压制防空」(使敌方雷达完全失效)。面对解放军现代相位阵列雷达的高功率与复杂波形,单机荚舱的干扰效果有限,需配合战术机动与其他软硬杀手段。假设挂载荚舱即等于免疫防空飞弹,是技术上的误判。
### 陷阱三:忽略电子战对杀伤链的「木桶效应」
飞弹的导引头、发射车的射控雷达、指挥所的数据链,三者任一环节被干扰,飞弹即无法命中。分析时常犯的错误是:只评估飞弹本身的抗干扰能力,却忽略了数据链被切断时,飞弹根本无法获得目标资料。电子战的脆弱性取决于系统中最弱的一环。
### 陷阱四:假设和平时期的电磁参数在战时仍有效
和平时期截收的解放军雷达参数,可能只是其基本模式。战时解放军必然启用备用频段、复杂波形与跳频模式。若台湾的参数库未涵盖这些战时模式,干扰系统将无法识别与对抗。假设和平时期的侦察等于战时的透明,是致命的乐观。
### 陷阱五:低估广域阻塞式干扰的毁灭性
台湾强调「跳频」与「机动」作为抗干扰手段,但这主要针对「瞄准式干扰」。解放军的大型电战机(如运-9)可在战区外实施广域阻塞式干扰,将整段频谱淹没。此时跳频通信亦无法运作,因为所有频点皆被噪音覆盖。低估这种「粗暴但有效」的干扰方式,会错判台湾指管体系的存活时间。
### 陷阱六:将「网路战」与「电子战」混为一谈
网路战攻击的是资料流(0与1),电子战攻击的是电磁波(物理信号)。两者虽在认知电子战与软体定义无线电领域有交集,但本质不同。网路战可从远端发起,电子战必须与目标进行物理空间的电磁交互。将防制骇客入侵的能力,误认为防制雷达干扰的能力,是概念上的根本错位。
---
## 仍不确定的部分
- **美方参数库的更新频率与深度**:美方是否向台湾提供了针对解放军最新型雷达(如055型舰载雷达、歼-20雷达)的完整干扰参数?这决定了台湾电子战系统是否只是「半成品」,但此为极机密,外界无法确知。
- **中科院认知电子战(AI电战)的实质进展**:中科院宣称利用AI动态生成干扰波形,此技术是否已从实验室走向实战部署?面对解放军未知的雷达波形,AI反应的速度与效能是否优于传统参数库?目前缺乏实证。
- **战时电磁频谱管理的真实状态**:台湾岛内民用通信密集,战时军方若需占用频段或实施电磁静默,与民用的冲突如何解决?指管系统的频谱抗扰度在军民混用环境下的表现,缺乏大规模演训验证。
- **C-130HE电子战机的真实效能**:该机型服役多年,其干扰功率与数位化程度是否足以对抗现代解放军雷达?还是仅具象征意义?因从未公开演训,外界仅能推测。
- **解放军电子战对台湾飞弹导引头的实战干扰率**:雄风与天弓飞弹在面临同等级电子干扰时的脱锁率,缺乏实弹验证数据。兵棋推演中的假设值可能与实战存在巨大落差。
---
## 与其他文件的关系
- 中科院档案:`entities/organizations/chung_shan_institute.md` —— 理解电子战装备的在地整合者与黑盒子封装的局限
- 空军档案:`entities/organizations/roc_air_force.md` —— 追踪空中电子战平台与战机存活率的关联
- 资电作战概念:`primitives/concepts/information_electronic_warfare.md` —— 提供电磁频谱争夺的理论框架
- 豪猪战略:`narratives/us_taiwan/porcupine_strategy.md` —— 揭示电子战作为隐形神经在不对称战略中的关键地位
- 解放军能力评估:`narratives/cross_strait/pla_capabilities_assessment.md` —— 提供对手电子战威胁的想定与对照
- 反舰飞弹档案:`entities/equipment/anti_ship_missiles_taiwan.md` —— 交叉验证杀伤链在电子干扰下的脆弱性
---
## 一句话浓缩
> 电子战能力是台湾防卫体系的隐形神经,决定了豪猪的刺是精准还是盲目;它是「网路战光环」掩盖下的「电磁战真空」,也是美台合作中「给硬体控灵魂」的技术依附缩影——分析台湾战力,若不先算电磁频谱的得失,所有飞弹数量的推演都只是和平时期的算术题。
---
last_updated: 2025-05-19
@@ -0,0 +1,329 @@
# 唐纳·川普(涉台立场专档) [US_PERSON_TRUMP_TAIWAN]
---
**类型**: person(涉台立场专档)
**当前角色**: 美国第 47 任总统(2025.1- );美台关系与两岸格局的最大外部变数
**出生时间**: 1946-06-14
**何时调用此档案**: 分析川普 2.0 时期任何涉台发言、美台政策变动、美中台三角互动、台湾内部对美政治反应、以及 2028 台湾选举的外部环境设定时
---
## 一句话定位
川普的涉台立场不是静态的"亲台"或"反台",而是一套将台湾视为"可定价筹码"的交易逻辑——其言辞的不可预测性是谈判工具,对半导体与防务费用的执着是可预测的施压轴心,两者共同构成了 2028 年前台湾政治最大的外部冲击波。
---
## 档案分工与定位
本档案路径为 `entities/foreign_actors/donald_trump.md`,与 `entities/people/donald_trump.md`(P4 人物档案)形成分工:
- **P4 人物档案**:侧重川普的个人全貌、决策风格、国内政治基础与整体外交逻辑。
- **本档案(涉台专档)**:深度聚焦川普对台湾的立场演变、关键言论解码、内阁与交易主义的张力、对台舆论冲击,以及对 2028 台湾选举的结构性影响。
分析者在处理美台关系时,应以本档案为主入口,以 P4 档案为补充背景。本档案大量运用 `primitives/frameworks/trump_factor_analysis.md` 的分析框架,将其具象化为涉台领域的操作工具。
---
## 涉台立场的演变:从"反中副产品"到"主动施压筹码"
许多分析者用川普第一任的经验推论第二任。这是严重的分析错误。川普 1.0 与 2.0 的涉台立场存在结构性位移。
### 川普 1.0(2017-2021):被动亲台与"反中副产品"
**事实层**:
- 2016.12 接受蔡英文祝贺电话,打破 1979 年以来惯例。
- 2018-2020 签署《台湾旅行法》、《亚洲再保证倡议法》、《台北法》。
- 大幅提升对台军售频率与品项(如 F-16V 战机、MQ-9 无人机)。
- 2020.8 卫生部长阿扎访台,2020.9 次卿克拉奇访台。
- 2021.1 卸任前,蓬佩奥宣布解除国务院内部对台交往限制。
**脉络层**:
川普 1.0 的亲台表象下,是川普个人意志的缺位与内阁的强势主导。当时的涉台升级并非源于川普对台湾的战略重视,而是三个结构性因素的叠加:
1. **内阁鹰派主导**:蓬佩奥、波顿、纳瓦罗等传统反共鹰派掌控国务院与国安会,台湾被视为对抗北京的合理支点。
2. **美中贸易战的延伸**:对台升级是对中施压牌组的一部分,台湾的工具价值在对抗中上升。
3. **川普的默许而非驱动**:川普本人对台湾议题兴趣缺缺,只要内阁提出的对台动作不干扰他的对中交易大局,他倾向于不干预。
**分析含义**:
分析川普 1.0 时期时,**绝不能将"政策结果"等同于"川普意图"**。第一任的友台是特定内阁组成与美中对抗大环境的副产品。当这两个条件在第二任改变时,友台政策的基石就不复存在。
### 川普 2.0(2025- ):主动施压与"交易筹码化"
**事实层**:
- 2024 选举期间,多次公开宣称"台湾偷走我们的芯片生意"。
- 2024 彭博社专访,提出"台湾应支付防务费用(保护费)"。
- 2024 集会发言,强调"台湾距离美国 9000 英里,距离中国 68 英里",质疑防卫合理性。
- 2025 上任后,对半导体进口加征关税的威胁常态化,台湾首当其冲。
- 对台军售虽未停止,但审查标准更倾向"是否对美国经济有利"。
**脉络层**:
川普 2.0 的涉台立场发生了本质位移:台湾从"反中工具"转变为"对美负债"与"谈判筹码"。这源于:
1. **内阁 MAGA 化**:传统鹰派(如鲁比奥)虽仍在位,但必须与万斯等孤立主义派妥协,台湾不再享有免受质疑的特权。
2. **美中"再交易"的可能性**:当川普寻求与北京达成大交易时,台湾的工具价值从"制衡北京的牌"转变为"可向北京让步的筹码"或"可向台北索取的代价"。
3. **经济民族主义上位**:半导体取代地缘战略,成为川普认知台湾的首要透镜。
**分析含义**:
分析川普 2.0 时,**必须抛弃"美国基于战略价值必然护台"的预设**。在川普的认知框架中,战略价值是可以被价格化的;如果防卫台湾的成本(距离远、风险高)在感知上高于收益(半导体已赴美、军费不够),他的直觉是索取更多或准备脱手。
---
## 关键涉台言论解码:保护费、关税与芯片
川普的涉台言论看似随意,实则遵循其特有的交易逻辑。以下拆解三大核心言论的结构意涵。
### 言论一:"台湾应付保护费"
**事实层**:
2024 年 7 月彭博社专访中,川普被问及是否防卫台湾时回答:"台湾为我们做了什么?我们保护了他们,他们应该付钱给我们。"此言论在台湾引发巨大震动。
**脉络层**:
这不仅是选举语言,更是川普对美台关系的**根本性重新定价**。他将美台关系从"基于共同价值的战略同盟"降维为"基于费用交换的保护契约"。这种降维的破坏力不在于美国立刻撤回安全承诺(制度上有约束),而在于它改变了台湾社会对自身安全的认知框架——从"自助人"退化为"被保护者",从而引发深层的信任危机。
**分析含义**:
分析者不应将"保护费"言论简单视为勒索,而应视为一种**压力测试**。川普的目的是迫使台湾主动提高国防预算(如 GDP 3% 以上)、扩大对美军购、加速台积电赴美。当台湾做出这些让步时,川普会将其包装为"交易胜利",而非对台承诺的强化。详见 `narratives/us_taiwan/taiwan_pay_protection_discourse.md`。
### 言论二:"台湾偷走我们的芯片生意"
**事实层**:
2024 年多场集会中,川普宣称"台湾抢走了我们大约 100% 的芯片生意",并暗示将对台湾半导体征收关税。
**脉络层**:
这是川普经济民族主义在涉台领域的投射。**破除常见简化**:把"川普对台加征芯片关税"等同于"放弃台湾安全"是错的。在川普的逻辑里,经济剥削(关税)与安全保护(军售)可以并行不悖,甚至互为条件——"你买我的武器,我收你的关税,这样贸易才平衡"。这是典型的房地产开发商逻辑:收租的同时卖保险。他并不希望摧毁台湾的半导体产能,而是希望通过关税威胁迫使台积电将更多产能与核心技术转移至美国。
**分析含义**:
分析半导体关税威胁时,**关注点应放在"台积电赴美进度"而非"美台关系破裂"**。只要台积电持续在亚利桑那等州扩厂,川普的关税威胁就更多是谈判筹码;若台积电赴美进度受阻,关税威胁转化为实质政策的概率才会显著上升。详见 `narratives/us_taiwan/tariff_on_taiwan_chips.md`。
### 言论三:"距离论"(9000 英里 vs 68 英里)
**事实层**:
川普多次在公开场合强调美国距离台湾 9000 英里,而中国距离只有 68 英里,以此质疑美国防卫台湾的地理合理性。
**脉络层**:
这是川普"成本中心"思维的典型体现。他将地缘战略简化为物流距离,忽略了第一岛链的战略价值与海空权优势。但此言论的功能不仅是表达地理疑虑,更是**为未来的"不作为"或"有限行动"预设正当性**。如果未来台海发生危机而川普选择不介入,他可以轻易引用"距离太远"作为向国内选民交代的理由。
**分析含义**:
分析者听到"距离论"时,**不应试图从地缘战略角度反驳,而应识别其为"预期管理"工具**。这预示着在危机情境下,川普极可能以"地理限制"为由,将美军行动限制在威慑展示(如航母通过)而非实质介入。
---
## 对台政策的"可预测 vs 不可预测"
川普的不可预测性常被误认为混乱。实际上,不可预测性是策略,而底层逻辑具有高度可预测性。
### 不可预测的部分:作为谈判工具的"疯子理论"
**事实层**:
川普拒绝明确回答是否防卫台湾,在记者追问时常以"我不想透露底牌"回应。这种战略模糊从传统的"双重威慑"工具,转变为川普个人的"议价筹码"。
**脉络层**:
不可预测性在川普框架中有三个功能:
1. **对北京:增加冒险成本**。北京不确定川普是否会冲动开战,这种不确定本身就是威慑。
2. **对台北:迫使让步**。台北不确定川普是否会与北京交易,这种恐惧迫使台湾更积极迎合美方要求(买武器、付保护费、转移芯片)。
3. **对国内:保持灵活性**。不承诺就不受约束,永远保留"交易"或"撤退"的选项。
**分析含义**:
当川普发出不可预测的涉台信号时,分析者不应问"他到底想什么",而应问**"这个不可预测信号对谁产生了什么压力,谁因此做出了让步"**。详见 `narratives/us_taiwan/trump_taiwan_uncertainty.md`。
### 可预测的部分:交易主义的底层逻辑
**事实层**:
尽管言辞多变,川普的决策遵循三个可预测的核心预设:
1. 所有关系都是零和交易(有赢家必有输家)。
2. 盟友是成本中心,对手是交易对象。
3. 短期有形利益(金钱、产业)优先于长期无形利益(信誉、价值)。
**脉络层**:
基于这三个预设,川普 2.0 的涉台政策呈现可预测的轨迹:
- **持续要求台湾增加防务支出**:这是最可能兑现的"保护费"形式。
- **持续施压半导体转移**:关税与投资要求将交替使用。
- **避免明确的安全承诺**:维持模糊以保留交易空间。
- **对北京的强硬与妥协交替**:强硬用于国内动员,妥协用于达成交易。
**分析含义**:
分析者可以利用这些可预测的底层逻辑,过滤川普的即兴发言。如果某次发言符合"要求台湾付更多钱"的逻辑,则其转化为政策的概率极高;如果某次发言偏离了所有可预测逻辑(如突然宣布出兵保卫台湾),则极可能是即兴发挥,大概率被内阁回收。
---
## 内阁鹰派 vs 川普交易主义的张力
川普 2.0 的对台政策不是川普一人决定,而是内阁派系与川普交易主义持续角力的产物。
### 鹰派的"转化"策略
**事实层**:
国务卿鲁比奥、前国安顾问沃尔兹等人历来对台友好。但在川普 2.0 体制下,他们不能直接以"支持民主"或"战略价值"为由推动对台政策,必须将其包装为符合川普交易逻辑的选项。
**脉络层**:
鹰派在川普政府中的生存策略是**"交易化包装"**:
- 将对台军售包装为"创造美国就业"与"降低美国防务负担"。
- 将武装台湾包装为"低成本威慑"——让台湾自己打,美国不用出兵。
- 将高层互访包装为"促进台湾对美投资"。
**分析含义**:
分析内阁声明时,**注意其中的"交易化修辞"**。当鲁比奥谈论台湾时,如果强调"责任分担"与"经济合作",说明他正在为亲台政策向川普买路;如果他强调"共同价值",则可能是在向国内传统共和党人喊话,但对川普的影响力较弱。
### 孤立派的"制约"机制
**事实层**:
副总统万斯代表 MAGA 孤立派,对海外承诺持深度怀疑。他在参议院的关键投票权与作为川普接班人的政治资本,使其对川普有实质制约。
**脉络层**:
万斯等人的功能是**"风险放大器"**。当川普考虑介入台海危机时,万斯会强调"这违背美国优先"、"这会导致第三次世界大战"。这使得川普在危机中更倾向于选择"有限威慑"而非"全面介入"。
**分析含义**:
在台海危机情境下,**万斯的立场决定了川普的"退缩底线"**。如果鹰派无法证明介入是"低成本且能赢"的,万斯的孤立主义将主导决策。
---
## 对台湾舆论的"冲击波"效应
川普的涉台言论对台湾内部政治心理的冲击,远大于其实质政策的改变。
### 疑美论的"超级燃料"
**事实层**:
川普的"保护费"与"芯片偷走"言论,在台湾媒体被广泛引用,成为 2024 选举后疑美论的核心论据。
**脉络层**:
川普因素对疑美论的强化不是线性的,而是**结构性的降维打击**。传统疑美论(如"美国弃台论")多基于地缘战略推演,属于精英层的理性怀疑;川普引发的疑美论则是基于"美国不可靠"的情感直觉,直接渗透到大众认知。他将美台关系从"战略伙伴"降格为"保护费契约",使得台湾民众对美信任的根基(共同价值)被抽空。
**分析含义**:
分析台湾各党派对川普的反应时,**不要只看政策表态,要看他们如何"修补信任赤字"**。DPP 的挑战最大:它必须在"迎合川普要求以维持实质关系"与"安抚内部疑美焦虑"之间走钢丝。KMT 则面临"反美论可能反噬对美关系"的风险。TPP 试图将"理性平衡"包装为应对川普的最佳策略,但在情感冲击下,理性论述的穿透力有限。
---
## 对 2028 的"结构性变量"
川普 2.0 是 2028 台湾选举无法绕过的外部结构性变量。
### 情境分析与选举影响
| 情境 | 触发条件 | 对 2028 选举的结构性影响 |
|---|---|---|
| **A:模糊延续** | 川普持续施压但无结构性破坏,军售照常,关税有限 | 疑美论温和增长,DPP 可勉强维持"稳固台美"论述,KMT 难以主打"美国不可靠" |
| **B:交易式调整** | 川普与北京达成大交易,台湾在关税或国际空间上被让步 | 疑美论爆发,DPP 论述崩塌,KMT "亲中保台"或 TPP "等距外交"获利 |
| **C:危机驱动升级** | 北京采取重大胁迫行动,川普被迫回应 | 视川普回应而定:若强硬,DPP 受益;若退缩,疑美论与失败主义蔓延,KMT/TPP 受益 |
| **D:撤离式调整** | 川普明确表态不防卫台湾,大幅削减军售 | DPP 遭遇存亡危机,"抗中保台"彻底失效,选举主轴被迫转为"如何与北京谈判" |
**分析含义**:
2028 的选举主轴不是由台湾内政决定,而是由川普 2.0 落入哪个情境决定。分析 2028 可能性时,**必须先设定川普情境,再推演台湾内政反应**,而非反过来。
---
## 实务分析:解读川普涉台言论的检查清单
当川普发出涉台言论时,按以下步骤系统分析,避免过度反应或轻视。
### Step 1: 信号源与场合识别
- [ ] 这是川普本人的发言,还是内阁声明?
- [ ] 场合是什么?(正式政策演讲 / 专访 / 集会 / Truth Social)
- [ ] 是否有记者追问?是否为即兴回应?
**判断规则**:场合越非正式,越可能是即兴或谈判喊价。内阁正式声明的权重远高于川普集会发言。
### Step 2: 交易逻辑映射
- [ ] 这个言论对谁施加压力?(台北?北京?美国国内?)
- [ ] 这个言论要求谁让步?让步内容是什么?(军购?芯片?关税?)
- [ ] 是否与当前美中谈判议程对齐?
**判断规则**:若能清晰对应到某个谈判目标,则是交易策略;若不能,则是情绪表达或政治动员。
### Step 3: 72 小时跟进检验
- [ ] 发言后 72 小时内,国务院或国防部是否有跟进声明?
- [ ] 是否有行政命令或政策指令发布?
- [ ] 国会是否提出异议或支持?
**判断规则**:没有执行跟进的发言是空包弹。追踪执行层的实际动作,而非停留在言辞层。
### Step 4: 重复频率观察
- [ ] 同一立场是否在 3 次以上不同场合出现?
- [ ] 是否有具体数字伴随?(如"应支付 GDP 的 10%")
**判断规则**:重复出现且伴随具体数字的立场,正在向政策转化。
---
## 关联分析
### 关联一:与 `entities/people/donald_trump.md` 的关系
**功能**:提供事实层与个人背景。本档案聚焦涉台立场,P4 档案提供川普的整体决策风格与国内政治基础。分析涉台言论时,需参考 P4 档案中的"个人层"特征。
### 关联二:与 `primitives/frameworks/trump_factor_analysis.md` 的关系
**功能**:提供脉络层与分析框架。本档案是此框架在涉台领域的具体应用。当需要拆解川普发言的多层意涵时,应调用此框架的"发言解码矩阵"。
### 关联三:与 `narratives/us_taiwan/trump_taiwan_uncertainty.md` 的关系
**功能**:提供交叉验证。本档案分析不可预测性的结构功能,该叙事文件分析不确定性如何被论述化与放大。两者结合可识别:哪些不确定性是川普制造的,哪些是台湾内部政治放大的。
### 关联四:与 `narratives/us_taiwan/taiwan_pay_protection_discourse.md` 的关系
**功能**:提供事实层与论述演变。本档案拆解"保护费"言论的川普逻辑,该文件追踪"保护费"论述在台湾内部的接受、转化与抵抗。
### 关联五:与 `narratives/us_taiwan/tariff_on_taiwan_chips.md` 的关系
**功能**:提供事实层。本档案指出半导体是川普认知台湾的核心透镜,该文件提供关税威胁的具体时间线与台积电应对策略,用于验证川普"经济剥削与安全保护并行"的逻辑。
### 关联六:与 `events/timelines/us_taiwan_2024_present_trump_2.md` 的关系
**功能**:提供事实层与验证基准。本档案的立场演变分析需以此时间线为依据,任何涉台言论的"重复频率"与"后续跟进"检查,均需对照此时间线。
---
## 常见的分析陷阱
### 陷阱一:将"保护费"言论等同于"撤回安全承诺"
**具体表现**:川普说"台湾应付钱",分析者立刻得出"美国将放弃台湾"的结论。
**正确做法**:识别"保护费"是重新定价而非终止契约。川普的目的是加码索取,而非撕毁合同。将台湾作为筹码施压,与真正出卖台湾,是两种不同的逻辑。
### 陷阱二:用第一任经验推论第二任
**具体表现**:"川普第一任期对台军售创新高,因此第二任期也会如此"。
**正确做法**:第一任的友台是内阁与环境的产物,第二任内阁已MAGA化,环境已变为"美中再交易"。必须基于 2.0 的新结构重新评估。
### 陷阱三:忽略"言辞伤害"与"结构破坏"的区别
**具体表现**:将川普每一次对台抱怨都视为灾难性信号。
**正确做法**:区分言辞伤害(抱怨、暗示)与结构破坏(暂停军售、切断情报)。目前多数动作属于言辞伤害与边缘调整,分析者应将注意力集中在是否有"结构破坏"的迹象。
### 陷阱四:心理化揣测川普"内心亲台"或"内心卖台"
**具体表现**:"川普内心钦佩习近平,因此会出卖台湾"或"川普是反共先锋,因此会保护台湾"。
**正确做法**:川普没有固定的意识形态内心。只分析可观察的行为、发言场合与制度约束,不揣测内心。
### 陷阱五:低估半导体议题的轴心地位
**具体表现**:将半导体关税视为独立的经济议题,与安全议题脱钩分析。
**正确做法**:在川普的认知中,半导体就是安全,安全就是半导体。台湾的半导体地位是其被索取的起点,而非被保护的保证。分析时必须将两者视为同一硬币的两面。
### 陷阱六:假设"美国制度"能完全约束川普
**具体表现**:"国会有台湾关系法,所以川普无法对台不利"。
**正确做法**:制度是减速器而非刹车。总统在军售节奏、高层互访、公开表态上有广泛裁量权,这些"边缘调整"的累积效应足以改变台海安全格局,无需触碰法律底线。
---
## 仍不确定的部分
- **川普与习近平的直接互动黑箱**:两人在私下会谈中是否就台湾达成任何口头谅解,这是外部观察者完全无法确知的,却可能决定川普在危机中的第一反应。
- **"保护费"的具体数字门槛**:台湾增加多少防务预算或对美采购,才能满足川普的"交易感"?这个数字是浮动的,取决于川普当下的国内政治需求。
- **台湾社会对"言辞伤害"的耐受力阈值**:台湾公众与政治精英对川普反复的负面言论能承受多久,何时会从焦虑转化为对美信任的实质崩塌,仍不确定。
- **危机中军方建议与川普直觉的冲突解决机制**:若台海发生危机,印太司令部的专业建议与川普"不要卷入远方战争"的直觉冲突时,谁占上风,取决于危机的具体形态与川普的国内政治算计。
---
## 与其他文件的关系
| 文件路径 | 关联功能 |
|---|---|
| `entities/people/donald_trump.md` | 人物全貌:提供川普个人决策风格与历史行为的事实基础,本档案为其涉台立场的特化与深化 |
| `primitives/frameworks/trump_factor_analysis.md` | 分析框架:提供拆解川普因素的多层结构与发言解码矩阵,本档案为其在涉台领域的应用实例 |
| `narratives/us_taiwan/trump_taiwan_uncertainty.md` | 核心论述:提供川普不确定性的叙事分析与论述化扩张的识别,与本档案形成事实-叙事的交叉验证 |
| `narratives/us_taiwan/taiwan_pay_protection_discourse.md` | 核心论述:提供"保护费"论述在台湾内部的演变与政治化过程,补充本档案对言论源头的分析 |
| `narratives/us_taiwan/tariff_on_taiwan_chips.md` | 核心论述:提供半导体关税威胁的具体时间线与产业影响,验证川普"经济与安全并行"的交易逻辑 |
| `events/timelines/us_taiwan_2024_present_trump_2.md` | 时间线:提供川普 2.0 时期涉台事件的具体时序,作为本档案立场演变分析的验证基准 |
---
## 一句话浓缩
> 川普 2.0 的涉台立场是一套将台湾"筹码化"与"重新定价"的交易逻辑——言辞的不可预测性是施压工具,对保护费与半导体的执着是可预测的索取轴心;分析者必须区分言辞伤害与结构破坏,避免用第一任经验推论第二任,并认清在川普逻辑中,对台加码索取远比出卖台湾更具操作性。
---
last_updated: 2025-05-30
@@ -0,0 +1,54 @@
# 日本台湾交流协会台北事务所(Japan-Taiwan Exchange Association, Taipei Office)
---
**类型**: 机构档案
**当前角色**: 日本在台实质大使馆 / 日台非官方关系日方执行枢纽
**何时调用**: 分析日台日常互动、日方对台政策信号、驻台代表发言意涵、日台签证与经贸实务、2017年后日方对台名义升级、与AIT模式对照、台海危机时日方在台协调机制
---
## 一句话浓缩
日本台湾交流协会台北事务所是法律上的"财团法人"、实质上的"日本在台大使馆"——这种缺乏国内法保障的"行政拟制",比美方的 AIT 模式更脆弱、更依赖外务省的内部规制与日中关系的即时温度;2017 年的改名是安倍政府测试日中底线的结构性信号,而非单纯的名称更易。
---
## 交流协会的法律地位:民间外衣与行政拟制
### 设立基础与历史断点
1972 年 9 月,日本与中华人民共和国发表《中日联合声明》,建立外交关系并与中华民国断交。断交后,日方需要维持与台湾的实质关系管道,但受制于日中建交公报中"承认中华人民共和国政府是中国的唯一合法政府"的承诺,无法设立官方机构。
在此背景下,日本台湾交流协会(原"亚东关系协会")于 1972 年 12 月设立:
- **法律性质**:依据日本《民法》设立的**财团法人**(民间组织)
- **设立者**:日本外务省幕后主导,名义上由财界与亲台人士发起
- **资金来源**:外务省预算拨款(实质)+ 民间捐款(形式)
- **法律基础**:**没有类似美国《台湾关系法》的国内法授权**——这是与 AIT 最根本的差异
**分析含义**:交流协会的设立完全依赖日本行政部门的内部安排,缺乏国会立法保障。这意味着其存在与运作权限,随时可能因内阁更迭或日中关系变化而被单方面调整,制度韧性远低于 AIT。
详见 `entities/foreign_actors/japan_mofa_taiwan_policy.md`。
### 双重身份的日方版本
| 维度 | 法律身份 | 实质身份 |
|---|---|---|
| 性质 | 财团法人(民间组织) | 日本在台大使馆 |
| 资金 | 民间捐款与事业收入 | 外务省预算拨款 |
| 人员 | 交流协会"雇员" | 外务省、经产省等派任之现职官僚 |
| 职能 | "民间经济文化交流" | 全部实质外交与领事职能 |
| 法律保障 | 无专门国内法 | 依赖外务省内部规制与内阁判断 |
**分析含义**:日方的"行政拟制"比美方的"法律拟制"更不稳定。AIT 有《台湾关系法》作为国会监督与授权的框架,而交流协会仅是外务省的行政延伸。分析日台关系时,必须将此"制度脆弱性"纳入考量——日方对台任何实质升级,都面临比美方更大的内部法律制约与外部(北京)反弹风险。
---
## 组织结构:东京本部与在台驻点
### 东京本部
日本台湾交流协会的本部位于东京,是日台关系的日方指挥中枢:
- **会长**:通常由日本财界重量级人士或资深政治家出任(如已故�的�的�的�的大�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的大�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�的�Line truncated
@@ -0,0 +1,317 @@
# 日本自民黨涉台委員會與親台網絡
---
**類型**: 機構檔案
**當前角色**: 自民黨內部對台政策的協調、倡議與審查機制;執政黨親台網絡的制度化中樞
**成立時間**: 1990 年代末(以外交部會涉台小委員會等形式持續演進)
**何時調用此檔案**: 分析自民黨對台政策的黨內生成機制、政綱與競選宣言的涉台條款、黨內派系對台政策的博弈與妥協、日台執政黨交流(2+2等)的日方決策內幕、涉台立法在國會通過前的黨內審查、安倍派弱化後的親台網絡重組
---
## 一句話濃縮
自民黨涉台委員會與親台網絡是日本對台政策的「黨內引擎」——它不同於跨黨派的日華議員懇談會,而是深嵌於執政黨政調會(PARC)審查機制中的制度性權力節點,將親台派的政治意志轉化為具有政府約束力的政策綱領與法案,在安倍派弱化後正經歷從「強人主導」向「網絡分散化」的結構轉型。
---
## 制度性質:黨機器內部的對台政策引擎
### 法律性質與實質角色的張力
自民黨涉台委員會(主要指自民黨政務調查會外交部會下的「涉台小委員會」及相關聯席機制)在法律上僅為執政黨的內部政策研議單位,不具備任何國家行政權力或立法權。其決議僅為「黨議」,對外務省、防衛省等官僚體系無直接法律約束力。
然而,在日本「黨高政低」(執政黨政調會審查優先於內閣法案)的政治慣例下,自民黨的涉台委員會實質上掌握了對台政策的「否決權」與「發起權」。任何涉及台海安保、日台實質關係提升的政府法案或政策,在送交內閣會議前,必須經過政調會相關部會及小委員會的審查與事前批准。這使得涉台委員會成為官僚體系無法繞過的制度性關卡。
**分析含義**:分析日本對台政策時,不能僅盯住外務省的聲明或首相的表態。涉台委員會的動向——特別是其是否將某項倡議列入「黨的三役」(幹事長、政調會長、總務會長)的優先議程——是判斷政策能否落地的前置指標。若涉台委員會反對,即便外務省有意推進,政策也會在黨內審查階段被擱置。
### 自民黨涉台小委員會的組織結構
自民黨對台政策的黨內協調並非由單一委員會壟斷,而是由多個層級的機構疊加構成:
- **外交部會涉台小委員會**:隸屬於政調會外交部會,是專門處理對台議題的核心機構。成員由自民黨內關注台海議題的議員組成,負責研擬對台政策草案、聽取專家意見、與台灣執政黨進行黨際交流的對口單位。
- **安全保障調查會**:在台海安保議題上,外交部會涉台小委員會需與安保調查會協調。安保調查會負責防衛大綱、安保戰略等宏觀框架,涉台小委員會則負責將台海情境嵌入這些框架的具體表述。
- **經濟產業部會與經濟安全保障推進本部**:在半導體供應鏈、經濟韌性等涉台經濟議題上,這些機構與涉台小委員會存在管轄權重疊,需透過政調會高層協調。
**分析含義**:涉台小委員會並非封閉的黑箱,而是政調會內部網絡的節點之一。其影響力大小取決於是否能與安保、經產等強勢部會建立聯盟。當涉台小委員會的倡議與安保調查會的戰略方向一致時,政策推進阻力最小;當兩者出現張力(如安保需求要求強硬表態,但外務省系統擔憂外交摩擦),涉台小委員會往往成為黨內妥協的談判場。
---
## 與日華議員懇談會的分工與互補
### 黨內決策 vs. 跨黨派動員
自民黨涉台委員會與日華議員懇談會(日華懇)構成了日本親台網絡的「雙軌制」。兩者在人員上高度重疊,但在制度功能上嚴格分工:
| 維度 | 自民黨涉台委員會 | 日華議員懇談會 |
|---|---|---|
| **制度歸屬** | 執政黨內部機構(政調會體系) | 國會跨黨派議員聯盟 |
| **核心功能** | 政策研擬、黨綱制定、法案審查 | 議會外交、政治信號發送、跨黨派動員 |
| **產出性質** | 具黨議約束力,可轉化為政府政策 | 無法律約束力,僅具政治宣示意義 |
| **對北京的可否認性** | 低(執政黨行為即政府意志前兆) | 高(可辯稱為議員個人或國會行為) |
| **運作邏輯** | 制度化審查與派系妥協 | 寡頭主導的俱樂部式動員 |
**結構性命題**:日華懇是親台網絡的「擴音器」與「白手套」,負責在非官方框架下與台灣互動並向北京發送信號;涉台委員會則是「變速箱」,負責將親台意志轉化為執政黨的正式政策綱領。兩者互補但存在內在張力:日華懇的高調表態有時會超前於涉台委員會的政策準備,迫使黨內決策層被動回應;反之,涉台委員會的務實妥協也可能被日華懇的強硬派視為「退縮」。
詳見 `entities/foreign_actors/japan_taiwan_diet_members_consortium.md`。
### 人員重疊與功能分化
涉台委員會的核心成員幾乎都是日華懇的骨幹。古屋圭司等人同時在兩個平台上運作,這種重疊確保了信號與政策之間的銜接。然而,功能分化明顯:
- **日華懇場合**:古屋圭司以會長身份率團訪台,發表強烈支持台灣的聲明,向台灣提供政治再保證,向北京展示決心。
- **涉台委員會場合**:古屋圭司作為自民黨資深議員,需在政調會內部與其他部會協調,確保涉台倡議不會因過度挑釁北京而遭到外務省系統或黨內務實派的否決。
**分析含義**:分析時必須區分同一人物在不同場合的發言性質。在日華懇的公開聲明是「政治上限」,在涉台委員會的妥協方案是「政策底線」。兩者之間的落差,就是日本對台政策「說與做」的真實空間。
---
## 派系基礎與權力結構
### 安倍派的遺產與2024後的弱化
安倍派(清和會)長期是自民黨內親台網絡的核心支柱。安倍晉三的「台灣有事就是日本有事」論述,為涉台委員會推動安保政策轉型提供了最高級別的政治保護傘。在安倍影響力鼎盛時期,涉台委員會的倡議幾乎可以無阻力地進入黨決策核心。
2024年,安倍派因政治資金問題解散,這對親台網絡造成結構性衝擊:
- **失去頂層保護**:涉台委員會的倡議不再享有前首相派系的自動護航,在政調會審查中面臨更多來自其他派系的制衡。
- **選票重組**:原安倍派成員分散至無派系或新成立的政策集團,其對台立場的凝聚力下降。部分成員為了在新的權力格局中生存,可能在對台議題上採取更低調的姿態。
- **論述繼承權爭奪**:安倍留下的「台灣有事」論述遺產,目前由多個派系爭相繼承,但缺乏單一強人賦予其權威性。
**分析含義**:安倍派弱化不代表親台路線逆轉,而是意味著親台網絡從「金字塔型」轉向「網絡型」。涉台委員會的運作不再依賴單一派系的指令,而需要更複雜的跨派系協商。這降低了政策推進的速度,但可能提高了政策的跨派系正當性。
詳見 `entities/people/shinzo_abe_legacy.md` 及 `narratives/japan_taiwan/lpd_pro_taiwan_faction.md`。
### 岸田派與麻生派的務實親台路線
在安倍派弱化後,岸田派(宏池會)與麻生派(志公會)成為涉台委員會運作的關鍵支撐:
- **岸田派**:傳統上對華較為務實,但在岸田文雄執政期間,出於配合美國印太戰略及強化經濟安全的考量,實質上延續了安倍時期的對台政策框架。岸田派在涉台委員會中的角色是「穩定器」,確保政策不暴衝也不倒退。
- **麻生派**:麻生太郎長期支持台灣,其派系在涉台議題上具有高度一致性。麻生派在涉台委員會中扮演「推進器」,在關鍵時刻提供政治動能。
**破除常見簡化**:將岸田派的「務實」等同於「親中」或「反對親台」是錯誤的。岸田派對台政策的邏輯是「戰略遵從美國、經濟避險中國」,這與安倍派的「意識形態抗中」路徑不同,但在強化台海嚇阻的客觀效果上並無根本分歧。
### 派系重組下的網絡重構
當前涉台委員會正處於網絡重構期。原安倍派的親台骨幹(如萩生田光一、高市早苗等)仍在委員會中活躍,但他們的行動邏輯從「執行派系意志」轉變為「個人政治品牌經營」。這意味著:
- 涉台倡議更可能成為議員個人爭取曝光與黨內影響力的工具,而非派系整體戰略的一環。
- 委員會內部的協調難度增加,因為缺乏派系領袖的「一言九鼎」,不同意見的整合需要更長時間。
詳見 `entities/foreign_actors/ldp_pro_taiwan.md`。
---
## 核心人物與行動者網絡
### 古屋圭司:雙軌運作的樞紐
古屋圭司是當前自民黨涉台網絡中最關鍵的「守門人」。他同時擔任日華懇會長與自民黨涉台小委員會的核心顧問,這種雙重身份使他成為連接「外部信號」與「內部決策」的橋樑。
- **操作特徵**:古屋擅長在日華懇的高調訪台與涉台委員會的低調審查之間取得平衡。他會在訪台時釋放強烈信號,但在黨內協調時則表現出妥協意願,確保倡議不被務實派否決。
- **制約因素**:古屋的影響力建立在個人資歷與安倍時期的遺產上,缺乏強大派系支撐。在後安倍時代,他更像是一個協調者而非決策者。
**分析含義**:觀察古屋的發言場合是解讀政策信號的關鍵。若他在日華懇場合發言,代表親台派的「期望上限」;若他在政調會場合發言,代表黨內「可妥協的下限」。
### 岸信夫與安保路線的推手
岸信夫(安倍晉三之弟)曾任防衛大臣,是將台海安保議題嵌入自民黨防衛政策的核心人物。雖然因健康因素逐漸淡出第一線,但他建立的「台海安保與日本防衛不可分割」的政策路線,仍通過其派系殘餘影響力在涉台委員會與安保調查會中延續。
- **制度遺產**:岸信夫在防衛大臣任內推動的防衛白皮書明確寫入台海穩定重要性,這為涉台委員會後續推動安保相關倡議提供了法理與政策基礎。
### 中生代的崛起與路線差異
後安倍時代,涉台委員會中的中生代議員影響力上升。這些議員(如鈴木貴子、長島昭久等)的特徵是:
- **戰略導向**:相較於老一輩基於歷史情感與反共意識的親台,中生代更基於對中威脅的戰略認知支持台灣。
- **議題聚焦**:專注於經濟安全、供應鏈韌性、認知戰等非傳統安全領域,而非傳統的政治承認或主權議題。
- **美國連動**:其對台倡議往往與美國國會的相關法案(如《台灣關係強化法案》)形成呼應,藉由日美同盟框架增加黨內正當性。
**分析含義**:中生代的崛起意味著涉台委員會的關注焦點正在轉移。未來的涉台倡議可能更多體現在經濟安全法案的附帶條款中,而非單獨的「台灣決議」。
---
## 對自民黨對台政策的實際影響機制
### 政綱與競選宣言的制定
自民黨在每次眾議院選舉前發布的「政權公約」(競選宣言),是涉台委員會發揮長期影響力的核心場域。涉台委員會透過參與政調會的政綱起草過程,將對台支持表述嵌入執政黨的正式承諾中。
- **操作機制**:涉台小委員會提出建議案,經外交部會彙整後,提交政調會綜合協調,最終由黨高層(總裁、幹事長、政調會長)拍板納入政權公約。
- **實例**:2021年自民黨政權公約中首次明確寫入「台灣海峽的和平與穩定」,這是涉台委員會與安保調查會聯合推動的結果。
**分析含義**:政權公約中的涉台表述是觀察自民黨對台政策「錨點」的指標。一旦寫入公約,便成為政府施政的合法性基礎,外務省官僚必須在此框架內行事。分析時應對比歷次公約的措辭變化(如從「關切」升級為「重要性」再到「不可或缺」),這反映了涉台委員會推動的政策升級軌跡。
### 政策提案的升級路徑
涉台委員會的倡議要轉化為政府政策,需經歷嚴格的黨內審查程序:
1. **小委員會研議**:涉台小委員會通過建議案。
2. **部會承認**:外交部會(及相關聯席部會)審議通過。
3. **政調會審查會**:政調會高層協調,解決派系分歧。
4. **總務會承認**:自民黨最高決策機構確認,成為「黨議」。
5. **內閣送交**:政府根據黨議提出法案或修改政策。
**分析含義**:這一升級路徑意味著涉台委員會的倡議在每一個關卡都可能被稀釋或擱置。觀察倡議停留在哪個層級,是判斷黨內共識程度的精確方法。若僅停留在小委員會層級,代表僅是親台派的期望;若推進至總務會,則代表即將轉化為國家政策。
詳見 `entities/foreign_actors/japan_pm_office.md`。
### 對官僚體系的施壓與綁定
涉台委員會對外務省、防衛省等官僚體系的影響,主要透過「國會質詢腳本協調」與「預算審查」實現:
- **質詢協調**:涉台委員會成員在國會預算委員會等場合的質詢,往往事先與黨政調會及相關省廳協調。透過預設的質詢與答辯,迫使官僚在公開場合做出比內部決策更進步的表態,從而「綁定」官僚體系。
- **預算審查**:在防衛預算及ODA(政府開發援助)預算審查中,涉台委員會透過關注特定項目(如西南諸島防衛、對「同志國」基礎設施援助),引導資源流向實質上有利於台海嚇阻的領域。
**分析含義**:涉台委員會與官僚體系的關係是「既施壓又合作」。外務省內部的親台官僚有時會利用涉台委員會的壓力,作為對抗省內親中派或避險派的內部籌碼。分析時不應將委員會與官僚體系視為零和博弈,而應觀察兩者如何共謀突破政策限制。
---
## 重要涉台立法與政策推動
### ODA大綱修改與「同志國」概念
2023年日本修改ODA大綱(更名為「開發合作大綱」),引入對「同志國」提供防衛裝備及基礎設施援助的條款。這是涉台委員會與安保調查會聯合推動的結構性突破:
- **事實層**:大綱雖未點名台灣,但「同志國」概念明確涵蓋台灣。這為日本未來向台灣提供非致命性軍事裝備(如防災救難設備、監視雷達技術)或協助台灣強化基礎設施韌性開闢了法理空間。
- **脈絡層**:此舉是涉台委員會「切香腸」策略的典型——不直接挑戰「一中政策」的紅線,而是透過修改適用對象的定義,將台灣納入援助框架。
- **分析含義**:分析時應關注ODA預算的實際執行對象。若日本開始透過國際協力機構(JICA)與台灣的技術合作項目增加,即代表此政策突破已進入實質操作階段。
### 經濟安全保障推進法與供應鏈韌性
2022年通過的《經濟安全保障推進法》是涉台委員會與經產部會合作的產物。該法雖為國內法,但其「供應鏈韌性」條款直接指向台灣的半導體戰略地位:
- **事實層**:法案提供補貼吸引半導體廠商赴日設廠(如台積電熊本廠),並要求關鍵物資供應鏈多元化。
- **脈絡層**:涉台委員會在推動過程中,成功將「經濟依賴台灣」的脆弱性論述轉化為「強化日台經濟安全合作」的正當性,使對台經濟合作從單純的商業行為升級為安保戰略的一環。
**分析含義**:涉台委員會推動的立法往往不帶「台灣」之名,卻行「挺台」之實。分析時不應僅搜尋法案名稱中的「台灣」字樣,而應追蹤經濟安全、供應鏈、基礎設施等領域法案的實質影響。
### 防衛大綱與安保戰略的台海定位
2022年底修訂的《國家安全保障戰略》、《防衛計畫大綱》及《中期防衛力整備計畫》,明確將中國列為「前所未有的最大戰略挑戰」,並強調台海和平穩定的重要性。涉台委員會在此過程中的角色是:
- **論述嵌入**:確保「台海穩定是日本安全不可或缺的要素」這一表述被寫入戰略文件,為自衛隊的西南諸島防衛部署及反擊能力建設提供正當性。
- **跨國協調**:與美國印太戰略協調,確保日美安保共同聲明中持續包含台海條款。
---
## 與台灣政界的直接管道
### 黨對黨交流的制度化
自民黨涉台委員會與台灣執政黨(民進黨)的「黨際交流」(如日台執政黨2+2會談),是當前日台之間最具實質政策對話性質的管道:
- **事實層**:自民黨外交部會與民進黨國際事務部定期舉行線上或實體會議,討論安保、經濟、外交議題。
- **脈絡層**:這種黨對黨交流巧妙規避了官方關係的限制。自民黨作為執政黨,其外交部會的決議可直接影響政府政策;民進黨作為執政黨,其國際部負責人亦具備高度政策代表性。雙方在非官方名義下進行的,實質上是「準官方」的政策對話。
- **分析含義**:觀察2+2會談的出席層級與會後聲明,是評估日台實質關係深度的最佳指標。若自民黨出席者從年輕議員升級為政調會長級別,代表政策對話的權重提升。
### 繞過外務省的非正式溝通
涉台委員會在與台灣政界溝通時,有時會刻意繞過外務省的嚴格審查,透過議員個人的秘密渠道傳遞訊息。這種做法的風險在於:
- **優點**:能傳遞外務省不願放行的敏感訊息(如安保情報、政策底線),增進雙方戰略互信。
- **缺點**:若被媒體揭露,可能引發外務省的反彈,甚至導致日中外交風波,反而壓縮了涉台委員會後續的操作空間。
**分析含義**:涉台委員會與外務省的關係是「既合作又競爭」。分析時需注意,委員會的高調行動有時是向外務省施壓的手段,而非單純對台示好。
---
## 關聯分析
### 關聯一:與日華議員懇談會的功能互補
涉台委員會是「決策內核」,日華懇是「動員外殼」。日華懇負責向北京發送信號、向台灣提供再保證;涉台委員會負責將信號轉化為具約束力的黨議。兩者人員重疊但邏輯不同,分析時必須區分場合。
**功能**:提供結構層分析,解釋親台意志如何分化為「信號」與「政策」雙軌。
詳見 `entities/foreign_actors/japan_taiwan_diet_members_consortium.md`。
### 關聯二:與安倍遺產的繼承與稀釋
安倍派弱化後,涉台委員會失去了最高保護傘,但安倍的「台灣有事」論述已被委員會制度化為黨綱的一部分。當前的挑戰是:這一論述在缺乏強人護航下,能否抵禦經濟利益與務實外交的侵蝕。
**功能**:提供脈絡層分析,揭示派系重組對政策延續性的影響。
詳見 `entities/people/shinzo_abe_legacy.md`。
### 關聯三:與首相官邸的博弈
涉台委員會的倡議最終需首相官邸背書。岸田時期的特徵是「戰略遵從但戰術克制」——官邸接受委員會的安保邏輯,但控制推進速度以避免日中關係失控。分析委員會影響力,必須同時評估官邸的制約意願。
**功能**:用於交叉驗證,區分黨內倡議與政府實際政策底線。
詳見 `entities/foreign_actors/japan_pm_office.md`。
### 關聯四:與自民黨親台派總覽的對照
本檔案聚焦黨內組織化機制,而親台派總覽涵蓋更廣泛的派系生態與個人網絡。涉台委員會是親台派在制度內的「抓手」,親台派則是委員會的社會基礎。兩者對照有助於區分「組織行動」與「個人行動」。
**功能**:用於對照,避免將所有親台言行歸因於制度性決策。
詳見 `entities/foreign_actors/ldp_pro_taiwan.md`。
### 關聯五:與派系論述的關聯
涉台委員會的政策產出,是各派系在親台論述上妥協的結果。分析委員會的聲明措辭,可反推各派系在具體議題上的力量對比。
**功能**:提供事實層驗證,透過文本分析揭示派系博弈痕跡。
詳見 `narratives/japan_taiwan/lpd_pro_taiwan_faction.md`。
### 關聯六:與美國國會涉台行動的共振
涉台委員會的倡議往往與美國國會的涉台法案形成呼應。這種共振既是策略(藉美國壓力增加黨內正當性),也是結構性結果(日美同盟的戰略協調)。觀察兩者的時間差與措辭差異,可判斷日方是主動跟進還是被動配合。
**功能**:用於對照與交叉驗證,評估日本對台政策的自主性程度。
---
## 常見的分析陷阱
### 陷阱一:將涉台委員會的倡議直接等同於政府政策
錯。委員會的倡議需經政調會、總務會及內閣多層審查,每一層都可能稀釋或擱置。將委員會的建議案直接解讀為政策定局,會嚴重高估親台網絡的政策轉化率。
### 陷阱二:將安倍派弱化等同於親台路線逆轉
錯。安倍派弱化改變的是決策速度與保護傘效應,而非政策方向。「台灣有事」論述已被制度化為黨綱與安保文件,具有政策慣性。將派系重組誤讀為路線轉向,會錯判日本對台政策的穩定性。
### 陷阱三:混淆日華懇與涉台委員會的功能
日華懇是信號發送器,涉台委員會是政策引擎。將日華懇的高調聲明誤認為政策突破,或將涉台委員會的內部妥協誤認為退縮,都會導致誤判。兩者必須放在雙軌制中對照解讀。
### 陷阱四:忽略官僚體系的反制能力
涉台委員會雖有黨議約束力,但外務省等官僚體系掌握政策執行權與資訊優勢。官僚可透過技術性擱置、狹義解釋、程序拖延等方式架空委員會的倡議。忽略官僚的反制,會高估委員會的實際影響力。
### 陷阱五:將「非台灣命名」法案排除在涉台分析之外
涉台委員會推動的最重要立法(如經濟安全保障推進法、ODA大綱修改)往往不包含「台灣」字樣。若僅搜尋名稱含「台灣」的法案,會嚴重低估委員會的實質影響力。
### 陷阱六:假設黨際2+2會談具備官方決策性質
日台執政黨2+2會談雖具高度代表性,但仍屬非官方的政黨交流。將其結論直接等同於雙方政府的政策承諾,忽略了政黨交流不具條約約束力的事實。
---
## 仍不確定的部分
- **後安倍時代的跨派系協商效率**:安倍派弱化後,涉台委員會需依賴岸田派、麻生派及無派系議員的臨時聯盟。這種網絡化運作在面對重大危機時的決策效率,仍待觀察。
- **年輕世代議員的戰略忠誠度**:基於對中威脅認知而支持台灣的中生代議員,其立場是否會隨中美關係緩和或國內經濟壓力而動搖,尚不確定。與老一輩基於歷史情感的親台相比,其韌性可能較低。
- **ODA「同志國」條款的執行邊界**:大綱修改後,日本實際向台灣提供援助的類型與規模仍不確定。外務省在執行層面的保守傾向可能限制條款的適用範圍。
- **台海危機時委員會的實質角色**:在和平時期,涉台委員會是有效的政策推進器;但在軍事危機時,決策權將迅速集中於首相官邸與美日同盟軍事體系,委員會的邊緣化程度仍屬未知。
---
## 與其他文件的關係
- 自民黨親台派總覽:`entities/foreign_actors/ldp_pro_taiwan.md` —— 涉台委員會的人事基礎與派系網絡來源
- 日華議員懇談會:`entities/foreign_actors/japan_taiwan_diet_members_consortium.md` —— 涉台委員會的外部信號發送器與白手套
- 安倍遺產:`entities/people/shinzo_abe_legacy.md` —— 涉台委員會當前政策路線的意識形態源頭與失去的保護傘
- 派系論述:`narratives/japan_taiwan/lpd_pro_taiwan_faction.md` —— 解讀委員會聲明背後的派系妥協痕跡
- 首相官邸:`entities/foreign_actors/japan_pm_office.md` —— 涉台委員會倡議的最終否決者與背書者
---
## 一句話濃縮
> 自民黨涉台委員會是深嵌於執政黨政調會審查機制中的「政策變速箱」——它將日華懇的外部信號與親台派的政治意志,轉化為具有政府約束力的黨綱與法案,在安倍派弱化後從強人護航轉向網絡化協商,其推進速度雖受制於派系博弈與官僚反制,但政策方向因安保論述的制度化而具備高度慣性。
---
last_updated: 2025-05-19
@@ -0,0 +1,278 @@
# 日本关键政治人物涉台立场 [KEY_JAPANESE_POLITICIANS_TAIWAN_STANCE]
---
**类型**: 政治行动者集合与结构分析(人物地图)
**当前角色**: 日本对台政策的『人物地图』与立场结构图谱
**出生时间**: 不适用(集合体)
**何时调用此档案**: 分析日本涉台声明与立法动向时;预测日台安全合作走向时;解读自民党总裁选举对台影响时;评估日本在台海危机中的潜在角色与法律限制时;交叉比对日本政客言论与实际行动落差时
---
## 一句话定位
日本政治人物的涉台立场不是"亲台 vs 亲中"的线性光谱,而是**"地缘战略必然性"与"和平宪法体制约束"之间的结构性张力**——理解日本政客的涉台表态,必须区分"价值共鸣的修辞"与"受制于国内法与中美框架的行动";2028 后的日台关系,将取决于谁能在"台湾有事即日本有事"的共识下,完成自卫队法与安保体制的实质解套。
---
## 核心结构命题:日本涉台立场的"双重否决"机制
日本对台政策的实质演进,受制于两道结构性否决:
1. **同盟否决**:日本的安全行动高度依赖《美日安保条约》的触发与美国的明确指令。在美国未定调前,日本单方面行动的空间极小。
2. **国内法否决**:《日本国宪法》第九条与自卫队法对"武力行使"的严格限制,使得"台湾有事"能否直接等同于"存立危机事态"(存活危机事态),在法理上存在巨大灰色地带,需经国会与内阁法制局层层解释。
**破除常见简化**:把日本政客的"台湾有事即日本有事"言论等同于"日本将出兵协防台湾"是严重错误。多数日本政客的此番表态,其真实功能是**对内推进防卫预算与体制解套,对外向北京发出威慑信号并向华盛顿展示分担责任的意愿**,而非对台北的直接安全承诺。
---
## 立场光谱分类框架
日本政治人物的涉台立场,可依其"修辞烈度"与"推动体制变革的意愿"分为三群:
| 群组 | 核心命题 | 对台定位 | 政策特征 | 风险考量 |
|---|---|---|---|---|
| **亲台核心圈** | 台湾安全是日本安全的前提 | 不可分割的战略前沿 | 推动安保对话、联合演训、高层互访制度化 | 易引发北京过度反应与国内违宪争议 |
| **战略同情派** | 台湾稳定对日本至关重要,但需避免卷入冲突 | 重要经济与价值观伙伴 | 强化经济安全、非军事交流、跟随美国步伐 | 修辞与行动落差大,易产生威慑可信度危机 |
| **谨慎维持派** | 应恪守一中政策,避免刺激北京 | 经济伙伴但非安全义务 | 强调外交对话、危机管控、经济脱钩谨慎 | 被批评为绥靖,且对突发危机缺乏准备 |
**关键观察**:自民党内部多数人属于**"战略同情派"**——他们在修辞上支持台湾,但在涉及自卫队法修订与明确军事承诺时,会退回谨慎立场。
---
## 首相级与近期前任的涉台立场
### 岸田文雄
- **事实层**:2021-2024 任首相。任内大幅增加防卫预算(GDP 2% 目标),修订安保三文书,将台湾海峡和平稳定列入核心关切。未如安倍般发表极端亲台个人言论,但推动"经济安保推进法",实质强化台日半导体供应链连结。
- **脉络层**:岸田路线是**"体制内建制派的战略升级"**。他利用俄乌战争的冲击,完成了和平宪法下最大幅度的防卫体制解套,但刻意在涉台议题上保持"多边框架语言"(如 G7 联合声明),避免中日双边关系的彻底破裂。
- **分析含义**:岸田的遗产是"物质准备"而非"政治承诺"。分析 2028 前日本的军事实质战力提升,应回溯岸田时期的预算与体制修订,而非寻找他的涉台激进言论。
### 菅义伟
- **事实层**:2020-2021 任首相。2021 年美日峰会联合声明首度写入"台湾海峡和平稳定的重要性",是 1969 年以来首次。
- **脉络层**:菅义伟本人非鹰派,此突破是**"美中对抗升级下的结构性必然"**,而非其个人意识形态驱动。作为派阀基础薄弱的首相,他更依赖外务省与美国的议程设定。
- **分析含义**:菅义伟的突破证明:即使是非鹰派的日本首相,在美中战略竞争的框架下,也已无法退回"对台沉默"的时代。此声明是后续日本涉台论述的"新底线"。
### 石破茂
- **事实层**:2024 年当选自民党总裁并任首相。长期防卫族领袖,2023 年访台。主张构建"亚洲版北约",强调台湾安全对日本的重要性,但对"先制打击能力"持相对谨慎态度。
- **脉络层**:石破是**"制度主义鹰派"**。他比安倍更强调集体安全机制的法理完备性,而非单边突破。他访台的象征意义大于实质,核心诉求是提升西南诸岛的防卫体制,而非对台提供延伸威慑。
- **分析含义**:石破执政意味着日本的涉台政策将从"安倍式的个人修辞驱动"转向"法理与机制驱动"。他不会轻易跨越"集体自卫权"的红线,但会持续强化"周边有事"的应变法律框架。详见 `entities/people/current_japan_pm_taiwan_stance.md`。
---
## 外相、防卫相的涉台立场
### 外务大臣(如上川阳子、林芳正等)
- **事实层**:外相在公开场合的涉台发言高度制式化,严格遵循"台湾海峡和平与稳定的重要性"措辞,极少使用"国家"指涉台湾。林芳正任内曾因出席中方活动而遭亲台派质疑,但外务省整体政策跟随美日同盟基调。
- **脉络层**:外务省存在**"中国学派"(China School)的机构惯性**,倾向于管控中日关系危机。外相的公开表态往往是"美日协调后的最小公约数",而非个人立场的展现。
- **分析含义**:外相的发言是观察"日本官方底线"的指标——若外相措辞升级(如从"关切"变为"反对武力改变现状"),代表美日同盟内部已达成新的共识;若措辞降级,则代表北京对东京的施压产生效果。
### 防卫大臣(如木原稔、滨田靖一等)
- **事实层**:防卫相的发言空间大于外相。近年防卫白皮书持续将台湾议题纳入"周边安全环境"章节,强调台海危机对日本西南诸岛与海上交通线的直接影响。
- **脉络层**:防卫省是**"威胁驱动"的务实机构**。防卫相的亲台立场不源于价值观共鸣,而源于地理现实——台湾距离与那国岛仅 110 公里。防卫省关注的是"如何疏散侨民"与"如何部署飞弹防线",而非台湾的主权定位。
- **分析含义**:观察防卫省的实际行动(如西南诸岛的军演规模、弹药库建设进度),比分析防卫相的发言更能准确评估日本在台海危机中的真实准备度。
---
## 自民党要角的涉台立场
### 麻生太郎
- **事实层**:自民党副总裁,派阀领袖。2023 年访台,公开呼吁"日本需展现威慑力",主张日本应具备反击能力。
- **脉络层**:麻生是自民党内**"战略清晰派"的天气球**。作为德高望重的元老,他能说出在任首相不宜说出的亲台强硬言论,测试北京反应与国内接受度。
- **分析含义**:麻生的发言是判断自民党保守主流"真实底线"的指标。若麻生开始谈论"日台联合演训"的必要性,代表自民党内部已在严肃评估突破现行禁区的可能性。详见 `entities/foreign_actors/ldp_pro_taiwan.md`。
### 茂木敏充
- **事实层**:前外相、自民党干事长。对台立场属"战略同情派",支持台湾参与国际组织,但强调需在维持非官方关系框架下进行。
- **脉络层**:代表自民党内**"经济务实派"**的平衡路线——既理解台湾的半导体战略价值,又不愿彻底牺牲中日经贸关系。
- **分析含义**:茂木的立场反映了日本经团联(Keidanren)的偏好:安全上威慑,经济上不断链。
---
## 下一代政治人物的涉台立场
### 小泉进次郎
- **事实层**:前环境相,自民党人气王。多次表达对台湾民主的支持,立场属"亲台核心圈"边缘,但缺乏深厚的外交与安保实务经验。
- **脉络层**:小泉的亲台立场具有**"民粹与价值观驱动"**的特征。他利用台湾议题向国内年轻选民展示"对抗威权"的清新形象,但其政策主张往往缺乏可操作性。
- **分析含义**:小泉是观察"日本民意对台态度"的代理指标。若他未来竞选总裁,涉台议题将被高度政治化与选举化,增加北京误判的风险。
### 河野太郎
- **事实层**:前防卫相、现数字化相。对中强硬,主张增强防卫力,但同时也是务实的技术官僚,曾主导对中稀土依赖的脱钩政策。
- **脉络层**:河野代表**"经济安全鹰派"**。他对台的支持更多体现在供应链韧性(半导体)与网络安防,而非传统的军事协防承诺。
- **分析含义**:河野若上位,日台合作的核心将聚焦于"技术同盟"与"基础设施保护",而非传统的军事联合作战计划。
### 高市早苗
- **事实层**:前经济安保担当相。自民党内保守派旗手,多次参选总裁。对中极度强硬,明确支持台湾的国际参与,主张修宪使自卫队合法化。
- **脉络层**:高市是**"意识形态鹰派"**。她的亲台立场与反共意识形态深度绑定,视台湾为对抗北京体制的前线。
- **分析含义**:高市的立场是自民党右翼的"最大公约数"。她若掌权,将大幅加速修宪进程,但可能因缺乏外交弹性而引发区域危机的提前引爆。
---
## 在野党领袖的涉台立场
### 立宪民主党(CDP)
- **事实层**:最大在野党。党内存在分歧:保守派(如野田佳彦)对台相对友好,但进步派与左派受和平主义传统影响,对"台湾有事即日本有事"的论述持怀疑态度,担忧被美国拖入战争。
- **脉络层**:立宪民主党的立场是**"价值观共鸣 vs 反战和平"的内在撕裂**。他们支持台湾的民主,但反对扩大自卫队的海外介入权限。
- **分析含义**:立宪民主党是日本修宪与安保法制解套的**国内否决力量**。即使自民党总裁是鹰派,若无在野党或公明党的配合,修宪门槛极高。
### 日本维新会
- **事实层**:右翼在野党。对中强硬,支持修宪与强化防卫力,在涉台议题上与自民党鹰派高度一致。
- **脉络层**:维新会的功能是**"右翼压力团体"**。他们通过在国会提出更激进的涉台安保议案,迫使自民党为保住保守派选票而向右移动。
- **分析含义**:维新会的存在,使得自民党在涉台议题上面临"向右竞争"的结构压力,压缩了自民党向北京妥协的空间。
---
## 修辞与行动的结构性落差:日本涉台政策的内在矛盾
**中等模型自己想不到的洞察**:
日本涉台政策最大的结构性矛盾,不在于"是否支持台湾",而在于**"威慑需求"与"行动法理限制"的脱钩**。
- **修辞层**:自民党高层频繁使用"台湾有事即日本有事"、"单方面改变现状不可接受"等高强度威慑语言。
- **行动层**:日本至今未与台湾建立任何形式的官方安全对话机制,未进行联合军演,且《自卫队法》对于"台湾发生不测事态"时日本能采取的行动,仅有"护侨"与"警戒监视"有明确法源,"武力行使"需满足极度严苛的"存立危机事态"三大要件。
这种落差导致一个悖论:**日本越是提高威慑修辞,越暴露其法理与体制准备的不足**。北京可能评估,日本的强硬言论是"无牙之犬",从而降低对日本威慑的忌惮。
**分析含义**:评估日本在 2028 后的角色,不应统计有多少政客说了"支持台湾",而应追踪以下硬指标:
1. 《自卫队法》第 76 条(防卫出动)与第 82 条(治安出动)的修订进度。
2. 西南诸岛弹药库与民用机场征用协议的签署情况。
3. 日美针对台海事态的联合作战计划("39 号共同作战计划")的细化程度。
---
## 对 2028 后日台关系的潜在影响
2028 年台湾大选后与北京可能的施压期,恰逢日本国内政治的重组期(自民党总裁选举与参议院选举)。三种情境:
### 情境 A:战略清晰化突破(概率:低)
**触发条件**:北京对台采取高强度灰色地带行动 + 美国明确要求日本承担更多 + 自民党修宪势力取得三分之二席位
**政策特征**:通过修宪或安保法制修订,明确将台海危机纳入"存立危机事态";启动日台双边安全对话;建立台海周边的联合情报共享机制。
**对北京影响**:视为严重挑衅,可能对日本实施经济制裁与军事施压。
**对台湾影响**:安全承诺可信度上升,但被卷入大国冲突的风险剧增。
### 情境 B:经济安全深化与军事模糊维持(概率:高)
**触发条件**:台海维持高压但未爆发热战 + 美国要求日本分担后勤 + 日本国内反战声浪制衡
**政策特征**:强化半导体供应链合作(经济安全保障推进法适用);扩大非官方军事交流(退役将校访台);自卫队持续西南诸岛部署,但拒绝明示"出兵"承诺。
**对北京影响**:可容忍的红线内,北京以外交抗议为主。
**对台湾影响**:获得实质物资与情报支持,但缺乏明确的安全伞。
### 情境 C:战略退缩与中美共管(概率:低)
**触发条件**:美国与北京达成大交易 + 日本经济严重依赖中国 + 鹰派在选举中失利
**政策特征**:降低涉台言论调门;恢复中日高层互动;将台湾议题从安保文书边缘化。
**对北京影响**:战略胜利,分化美日同盟。
**对台湾影响**:安全环境急剧恶化,疑日论上升。
---
## 关联分析
### 关联一:与安倍遗产的连结
安倍晋三的"台湾有事即日本有事"论述,是当前日本涉台修辞的基石。但安倍的遗产是**"修辞框架"而非"行动蓝图"**。后续政治人物皆在此框架内操作,但无人能复制其派阀平衡与个人权威。
**功能**:提供脉络层——理解当代日本涉台论述的起源与局限。详见 `entities/people/shinzo_abe_legacy.md`。
### 关联二:与自民党亲台派系的基础
自民党内的亲台行动并非零散的个人行为,而是通过派系与跨派系联盟(如细田派、麻生派的部分势力)推动。理解人物立场需叠加其派阀归属。
**功能**:提供事实层——人物背后的权力结构。详见 `entities/foreign_actors/ldp_pro_taiwan.md`。
### 关联三:与日华议员恳谈会的组织化机制
"日华议员恳谈会"是自民党亲台议员的跨派系组织,是日台实质交流的核心管道(如推动李登辉访日、台湾前副总统访日)。该组织的年度大会与访台团,是判断日本国会亲台势力消长的指标。
**功能**:提供事实层与交叉验证——组织化行动比个人言论更具政策影响力。详见 `entities/foreign_actors/japan_lpd_taiwan_committee.md`。
### 关联四:与日本对台政策分析框架的对照
日本涉台政策受"中美日三角"、"国内法限制"、"经济安全"三重框架约束。人物言论需放入此框架中过滤,才能区分"个人立场"与"结构必然"。
**功能**:提供脉络层——避免将个人言论过度诠释。详见 `primitives/frameworks/japan_taiwan_policy_analysis.md`。
### 关联五:与 2028 美国总统大选的连动
日本的安全行动高度依赖美国。若 2028 美国总统大选由 MAGA 克制派胜出,要求日韩分担更多责任,日本可能被迫在"自主核武装/大幅扩军"与"向北京妥协"间做极端选择;若美国传统鹰派胜出,日本则安心回归"追随美国"的舒适区。
**功能**:提供脉络层——日本涉台立场的上限与下限由美国决定。
### 关联六:与台湾内部疑美/疑日论的反馈循环
日本政客的高调亲台言论若未转化为实质安全协议,将助长台湾内部的"疑日论"(认为日本只会口头支持);反之,若日本通过《防卫装备移转三原则》向台输出军备,则可能强化信任。
**功能**:用于交叉验证——外部承诺与内部信任的动态平衡。
---
## 常见的分析陷阱
### 陷阱一:把"台湾有事即日本有事"等同于"日本将出兵协防台湾"
错。此论述是威慑修辞,日本法理上需满足"存立危机事态"的严格条件(他国攻击日本或与日本密切关系的他国,导致日本存立受威胁),且需经国会事后追认,并非自动参战机制。
### 陷阱二:忽略外务省与防卫省的机构分歧
防卫省基于地理现实倾向威慑,外务省基于外交现实倾向危机管控。两者常发出不同信号,分析时需区分来源。
### 陷阱三:假设"亲台"等于"支持台湾独立"
错。多数日本"亲台"政治人物(尤其是战略同情派)支持的是"维持现状",反对的是"武力改变现状"。他们极度担忧台湾法理独立会触发《反分裂国家法》,将日本拖入非自愿的战争。亲台与反独在日本的战略计算中是共存的。
### 陷阱四:高估首相的个人决断权
日本是官僚国家,首相在危机时刻的选项受内阁法制局、统合幕僚监部与外务省的严重制约。首相的个人亲台立场在和平时期可影响预算分配,在危机时期则受制于法理解释与美日同盟的既定程序。
### 陷阱五:忽略公明党的否决权
自民党的执政盟友公明党(佛教和平主义政党)是自民党鹰派的刹车片。任何涉及安保法制突破或对台军售的议题,都需公明党同意,这迫使自民党最终方案往往比其修辞温和得多。
### 陷阱六:将日本对台支持视为"无条件"
日本对台支持的核心驱动力是"日本自身安全"而非"台湾主权"。一旦威慑失败,日本的首要考量将是"如何限制冲突规模以保护本土",而非"如何帮助台湾获胜"。
---
## 仍不确定的部分
- **石破茂内阁的实质安保政策走向**:石破虽为防卫族,但其对"亚洲版北约"的推动能否获得美国与东盟支持,仍高度不确定。
- **2027-2028 年日本国内修宪进程**:自民党能否在参议院维持三分之二席位,直接决定修宪(将自卫队入宪)的可能性,这将根本性改变日本在台海危机中的法理选项。
- **美日联合作战计划的细化程度**:该计划属最高机密,外界难以评估日本承诺的具体军事贡献(如后勤、反潜、扫雷),只能通过演习科目推测。
- **日本经济对中依赖的脱钩速度**:若经济脱钩加速,日本在台海议题上的行动自由度将增加;若脱钩失败,北京的经济胁迫效力将持续。
- **下一代领袖(小泉、河野等)在危机中的真实决断模式**:和平时期的强硬言论无法准确预测危机时刻的行为,他们缺乏危机决策的实证记录。
---
## 与其他文件的关系
- 安倍遗产:`entities/people/shinzo_abe_legacy.md` —— 提供当代日本涉台论述的修辞起源与局限性
- 当代首相立场:`entities/people/current_japan_pm_taiwan_stance.md` —— 提供石破茂政府的最新政策基线
- 自民党亲台派系:`entities/foreign_actors/ldp_pro_taiwan.md` —— 提供自民党内部推动涉台议题的权力结构基础
- 日华议员恳谈会:`entities/foreign_actors/japan_lpd_taiwan_committee.md` —— 提供日台实质交流的组织化机制与国会压力来源
- 日本对台政策分析框架:`primitives/frameworks/japan_taiwan_policy_analysis.md` —— 提供解构日本涉台政策的宏观结构工具
- 2028 美国总统潜在人选:`entities/foreign_actors/key_2028_us_presidential_aspirants.md` —— 提供美国对台政策底层逻辑变化对日本的连动影响
---
## 一句话浓缩
> 日本政客的涉台立场是**"地缘战略必然性"与"和平宪法体制约束"的结构性张力**——"台湾有事即日本有事"是威慑修辞而非出兵承诺,"亲台"不等于"支持独立";评估 2028 后日本的实质角色,不应统计强硬言论的数量,而应追踪《自卫队法》修订、西南诸岛部署与美日联合作战计划的硬指标,因为日本的行动上限永远受制于美国同盟框架与国内法理解释的双重否决。
---
last_updated: 2025-05-19
@@ -0,0 +1,315 @@
# 北京對歐洲的脅迫工具集(PRC Coercion Toolbox: Europe)
---
**類型**: organization(跨部門協調之行動範式與工具集合)
**當前角色**: 北京對歐洲主權國家及歐盟機構實施政治懲罰與邊界劃設的系統性工具庫
**何時調用此檔案**: 需要評估北京對歐洲國家涉台行動的反制模式時;需要解析「經濟脅迫」如何跨越雙邊關係、升級為「跨國供應鏈次級制裁」時;需要判斷歐盟《反脅迫工具》(ACI)等制度性反應對北京決策的約束力時;需要將兩岸關係的國際維度(特別是歐洲議會與成員國的涉台動向)納入分析時
---
## 一句話濃縮
北京對歐洲的脅迫工具集揭示了一個從「雙邊外交降級」到「跨國供應鏈次級制裁」的升級軌跡——其核心不在於經濟損失的絕對值,而在於**製造「不確定性溢價」以重劃歐洲涉台行動的紅線**;但分析時必須穿透「以商逼政」的表象,洞察其引發的「反向制度化」悖論:脅迫越深,歐洲的集體防禦機制與對台政治連結越強。
---
## 核心命題:脅迫的「反向制度化」悖論
### 一句話定義
> 北京在歐洲涉台、涉疆、涉港等議題上,系統性地運用經濟報復、外交降級與輿論攻擊,試圖將其市場規模轉化為政治紀律,迫使歐洲國家及企業在涉台議題上自我審查;然而,此策略結構性地觸發了歐盟的「反向制度化」——加速了歐盟《反脅迫工具》(ACI)的立法進程、強化了歐洲議會對台灣的跨黨派支持,並使「去風險」從經濟策略上升為政治共識。
### 不是什麼
- **不是「純粹的雙邊貿易爭端」**:即便以「海關系統故障」或「產品合格性」為名,其觸發點與目標選擇高度對應主權象徵(如台灣代表處命名)與政治表態。將之簡化為貿易摩擦,會完全錯失其作為「主權邊界巡邏」的功能。
- **不是「無差別的全面脫鉤」**:北京對歐洲的脅迫具有高度選擇性。對立陶宛使用「系統性排除」,對德國則強調「供應鏈依賴」,顯示其精準計算「自傷底線」與「盟友離心力」。
- **不是「必然有效的威懾」**:立陶宛案例證明,即使面對前所未有的次級制裁,目標國亦可能不屈服,且脅迫反成為受打擊國獲取歐盟團結補償與國際同情的觸媒。
### 是什麼
- 是**對「一中原則」國際邊界的壓力測試**:每次脅迫都是對歐洲國家「一中原則」口頭承諾與實際行動落差的懲罰,旨在將「不允許台灣具有主權象徵」從中國國內法轉化為國際交往的隱性鐵律。
- 是**「以商逼政」的跨國升級版**:從打擊目標國本土企業,升級為威脅在目標國有供應鏈的跨國企業(次級制裁),將中國市場的引力轉化為迫使第三國資本向其政府施壓的槓桿。
- 是**受制於歐盟集體決策機制的有限度武器**:北京的脅迫常因誤判歐盟內部的權力分散性(如歐洲議會的否決權與成員國的否決權)而適得其反,將雙邊施壓轉化為歐盟展現「戰略自主」的契機。
---
## 維度一:立陶宛案例(2021- )——「次級制裁」與「主權象徵」的極限對抗
立陶宛案例是北京對歐脅迫工具的「全要素演練」,標誌著從「雙邊懲罰」向「供應鏈株連」的質變。
### 事實層
- **觸發點**:2021年11月,立陶宛允許設立「台灣代表處」(The Taiwanese Representative Office),打破歐洲國家普遍使用「台北」命名的慣例。
- **外交工具**:
- 2021年8月,召回駐立陶宛大使。
- 2021年11月,將兩國外交關係降為代辦級。
- 施壓跨國企業(如德國大陸集團 Continental)停止使用立陶宛生產的零組件。
- **經濟工具**:
- **海關系統除名**:2021年底,北京將立陶宛從中國海關註冊系統中移除,導致立陶宛所有貨物無法在中國通關。名義理由為「系統技術故障」,但該「故障」持續數月。
- **貿易歧視**:拒絕立陶宛的木材、乳製品、啤酒等進口許可。
- **次級制裁(跨國供應鏈施壓)**:北京向多家在華有業務的跨國企業發出警告,要求其供應鏈中剔除立陶宛產品,否則將面臨對華出口限制。這迫使部分歐洲企業被迫終止與立陶宛供應商的合作。
- **輿論工具**:
- 官媒與外交官將立陶宛描繪為「違背國際信義的跳樑小丑」、「美國的馬前卒」。
- 中國駐法大使盧沙野稱立陶宛「必將遭到歷史懲罰」。
### 脈絡層:功能與內在矛盾
- **功能**:立陶宛是首個在歐盟體系內因涉台命名問題遭受全面經濟封鎖的國家。北京的核心目標不是立陶宛本身的經濟量體(中立貿易額極小),而是**殺雞儆猴**——防止「台灣代表處」在歐洲形成示範效應。
- **內在矛盾——次級制裁的雙刃劍**:北京對跨國企業的施壓,雖然短期內切斷了立陶宛的部分歐洲供應鏈,但長期看,這向歐洲資本證明了「在中國市場的供應鏈佈局具有極高的政治風險」。這直接加速了歐盟「去風險」戰略的出台。
### 分析含義
分析時,**絕不能將立陶宛的經濟損失與北京的政治勝利畫等號**。立陶宛對華直接出口雖然驟降,但通過歐盟內部貿易轉口與台灣設立的10億美元「中東歐投資基金」部分對沖了損失。更重要的是,北京的海關除名行為,成為歐盟向WTO提起訴訟的核心證據,並成為歐盟推動《反脅迫工具》(ACI)的最強力催化劑。北京的「戰術成功」(製造了立陶宛的痛苦)轉化為「戰略失敗」(加速了歐洲的集體防禦)。
詳見 `events/key_events/event_lithuania_taiwan_office_2021.md`。
---
## 維度二:捷克案例(2020- )——「分裂精英」與「選擇性報復」
捷克案例展示了北京如何在歐盟成員國內部利用政治分裂,並對特定政治人物與企業進行精準打擊。
### 事實層
- **觸發點**:2020年8月底,捷克參議院議長維特齊率團訪台,並在台灣立法院用中文說出「我是台灣人」。
- **外交與輿論工具**:
- 時任中國外長王毅公開威脅:「對於維特齊的這種挑釁行為,中國政府和中國人民決不會聽之任之、坐視不管,一定要讓其為自己的短視行為和政治投機付出深重代價。」
- 官媒將維特齊描繪為「反華政客」,並刻意區分其與捷克總統澤曼(Miloš Zeman,親中派)的立場。
- **經濟工具**:
- 威脅限制捷克在中國的投資(如汽車製造商斯柯達 Skoda 的在華業務)。
- 取消部分捷克樂團與文化團體的在華演出。
- 對捷克特定農產品(如啤酒、木製品)實施通關延遲與技術性卡關。
### 脈絡層:功能與內在矛盾
- **功能**:北京採用了「區別對待」策略——打擊參議院(反對派),拉攏總統與總理(執政聯盟中的務實派)。這是試圖在目標國內部製造「涉台代價」的內部政治壓力,利用親商派對經濟損失的恐懼來牽制親台派。
- **內在矛盾——主權國家的反彈**:王毅的公開威脅(「付出深重代價」)被捷克社會廣泛視為對主權的侵犯。這反而使得原本在捷克國內具爭議的訪台行程,獲得了跨黨派的同情與支持。對斯柯達等企業的隱性威脅,也讓捷克工商界意識到對中市場的依賴已成為國家安全軟肋。
### 分析含義
**破除常見簡化**:將北京的威脅視為「對捷克國家的整體懲罰」是錯誤的。北京實施的是**「精英分化」策略**。分析時,必須區分北京對目標國「行政部門」與「立法部門/地方首長」的差異化態度。北京常對後者施加最大壓力,同時向前者遞出橄欖枝,試圖利用行政權力壓制立法機構的涉台動作。但這種外來干預往往引發民主國家的「主權防禦本能」,導致行政部門也不得不強硬回應。
詳見 `entities/foreign_actors/key_eu_member_states_taiwan.md`。
---
## 維度三:對歐盟整體的施壓工具——「法制戰」與「制裁對等」
北京對歐盟整體的施壓,從早期的「市場利誘為主、警告為輔」,轉向2021年後的「直接對等制裁」,觸發了歐盟對華政策的根本性轉向。
### 事實層
- **觸發點**:2021年3月,歐盟基於新疆人權問題,對四名中國官員及一個實體實施制裁(這是自1989年天安門事件以來歐盟首次對中實施制裁)。
- **北京的對等反制**:
- 宣布對歐洲10名人員及4個實體實施制裁,包括多名歐洲議會議員(如法國議員格魯克斯曼 Raphaël Glucksmann)、歐盟理事會政治與安全委員會、以及歐洲知名智庫(如德國墨卡托中國研究所 MERICS)。
- 制裁措施包括:禁止相關人員及其家屬入境中國,及關聯企業、機構與中國進行交易。
- **後續效應**:
- 歐洲議會凍結了《歐中全面投資協定》(CAI)的審議程序,並明確表示在制裁解除前不會推進。
- 北京對歐洲議員的制裁,反而將這些議員推向了涉台議題的前線。被制裁的議員成為歐洲議會中推動台灣國際參與、與台灣建立更緊密關係的核心力量。
### 脈絡層:功能與內在矛盾
- **功能**:北京試圖通過制裁個體議員與智庫,在歐洲內部製造「寒蟬效應」,劃設「干涉中國內政(新疆、台灣、香港)必將付出代價」的紅線。
- **內在矛盾——代議政治的邏輯衝突**:北京誤判了歐洲代議政治的邏輯。在威權體制下,制裁個人可有效切斷其利益鏈;但在民主體制下,被外國制裁的議員會獲得「政治殉道者」的光環,其政治影響力不降反升。制裁智庫則被視為對學術自由的攻擊,強化了歐洲對中國體制對抗性的認知。
### 分析含義
**中等模型自己想不到的洞察**:北京對歐盟議員的制裁,實質上**摧毀了北京在歐洲最重要的潛在盟友——歐洲商界**的遊說能力。由於CAI被凍結,歐洲跨國企業失去了推動對中緩和的最大政策槓桿。北京為了懲罰幾位議員,代價是長期失去了通過CAI鎖定歐洲市場准入、分化美歐的戰略機會。這是典型的「戰術勝利導致戰略災難」。
詳見 `narratives/international/eu_taiwan_relations.md`。
---
## 維度四:工具拆解——經濟、外交與輿論的協同機制
北京對歐脅迫不是單一工具的隨機使用,而是三個維度的協同作戰。其工具選擇遵循「可否認性遞減、信號強度遞增」的原則。
### 經濟工具:從「白手套」到「次級制裁」
| 工具類型 | 運作機制 | 對歐案例特徵 | 掩飾性 |
|---|---|---|---|
| 海關技術壁壘 | 以檢疫、文件不符為由暫停進口 | 立陶宛海關系統除名(以「系統故障」為由) | 高(初期) |
| 行政拖延 | 非正式的通關延遲、審批停滯 | 捷克啤酒與農產品通關延遲 | 中高 |
| 次級制裁(供應鏈施壓) | 警告跨國企業,若使用目標國產品將失去中國市場 | 要求德國汽車零組件商切斷立陶宛供應商 | 低至中(隱蔽威脅,但效果顯性) |
| 投資壁壘 | 對目標國在華企業進行稅務、消防、反壟斷調查 | 對斯柯達的隱性威脅與市場准入限制 | 中 |
**分析含義**:次級制裁是北京對歐脅迫的「核武器」。它不再依賴雙邊貿易額(如中立貿易極小),而是利用跨國企業對中國市場的依賴,將第三國資本轉化為施壓工具。但此工具的致命缺陷是**「自傷外資信心」**——當跨國企業發現其供應鏈佈局會成為地緣政治人質時,其回應不是向本國政府施壓,而是加速供應鏈「去風險」。
詳見 `primitives/frameworks/economic_coercion_typology.md`。
### 外交工具:降級與孤立
| 工具類型 | 運作機制 | 對歐案例特徵 |
|---|---|---|
| 外交降級 | 將大使級關係降至代辦級 | 駐立陶宛大使館降級 |
| 召回大使 | 暫時召回駐在國大使以示抗議 | 2021年8月召回駐立陶宛大使 |
| 制裁立法/執法人員 | 禁止入境、凍結在華資產 | 對歐洲議會議員與智庫學者實施制裁 |
| 多邊場域孤立 | 在國際組織中動員票倉反制 | 阻撓歐盟官員在聯合國體系的特定發言或提名 |
**分析含義**:外交降級的象徵意義大於實質意義。對立陶宛的降級,並未實質改變雙方的人員往來,但向其他歐洲小國發出了明確的威懾信號。然而,這種威懾在歐盟的集體外交機制下被部分對沖——當成員國遭受第三國外交降級時,歐盟可啟動「整體立場」予以回應。
### 輿論工具:戰狼外交與分化論述
| 工具類型 | 運作機制 | 對歐案例特徵 |
|---|---|---|
| 公開羞辱 | 將目標國/人物描繪為「美國附庸」、「違背歷史潮流」 | 稱立陶宛為「跳樑小丑」 |
| 精英分化論述 | 刻意拉攏目標國的親中派,將反對派邊緣化 | 區分捷克總統與參議院議長 |
| 經濟脅迫合理化 | 將懲罰包裝為「市場自發行為」或「企業避險」 | 宣稱立陶宛產品失去競爭力,非政治打壓 |
**分析含義**:戰狼外交在歐洲的實際效果是**反向動員**。公開的威脅與羞辱,剝奪了目標國內部親中派的迴旋空間。當外長公開威脅「付出深重代價」時,目標國政府若妥協,將被國內選民視為喪失主權尊嚴;因此,唯一的政治生存策略就是強硬反擊。北京的輿論工具在國內是「愛國主義動員」,在歐洲則是「反中情緒催化劑」。
---
## 維度五:脅迫的實際效果評估——「威懾成功」與「反向動員」的張力
評估北京對歐脅迫的效果,不能僅看「目標國是否改變了政策」,必須同時計算「結構性反彈」的長期成本。
### 評估面向一:短期威懾(部分成功)
- **寒蟬效應**:立陶宛遭受重創後,絕大多數歐洲國家在考慮台灣代表處命名時,仍選擇使用「台北」而非「台灣」。這顯示北京的「殺雞儆猴」在短期內確實產生了威懾效果,成功凍結了「台灣」名義在歐洲的橫向擴散。
- **企業自我審查**:部分歐洲企業在涉台議題上變得更加低調,避免在公開場合支持台灣的國際參與,以防觸碰北京紅線。
### 評估面向二:長期反向動員(結構性失敗)
- **歐盟《反脅迫工具》(ACI)的加速立法**:立陶宛案例直接推動了歐盟ACI的進程。該工具允許歐盟在成員國遭受第三國經濟脅迫時,實施反制措施(如加徵關稅、限制服務貿易)。這意味著北京未來對歐的單邊脅迫,將面臨歐盟27國的集體反制。
- **歐洲議會的「挺台常態化」**:北京對歐洲議員的制裁,反而使議會內部形成了「抗中保台」的跨黨派共識。近年來歐洲議會通過的涉台決議數量創歷史新高,涵蓋雙邊投資協定(BIA)、國際參與等議題。
- **「去風險」戰略的確立**:次級制裁讓歐洲意識到,對中經濟依賴不僅是市場風險,更是國家安全風險。這使得「去風險」從美國的倡議,內化為歐盟的戰略共識。
### 結構性命題:威懾的「自我削弱悖論」
> **北京對歐脅迫的短期威懾力越強,其長期面臨的集體對抗風險越高。**
**因果機制**:
1. 北京對單一成員國實施極限施壓(如立陶宛) → 製造寒蟬效應(威懾成功)。
2. 施壓手段觸及歐盟單一市場的完整性(次級制裁) → 觸發歐盟的系統性生存危機。
3. 歐盟啟動制度性防禦(ACI、凍結CAI) → 北京未來的脅迫成本急劇上升。
4. 北京的經濟槓桿被制度性稀釋 → 長期威懾力下降。
**分析含義**:北京陷入了一個結構性困境——不施壓,紅線將被持續突破;施壓,則加速歐洲的集體防禦機制建構。這意味著在2028前的週期內,北京對歐洲的涉台施壓可能轉向更隱蔽的方式(如私下向企業施壓),而非高調的外交降級,以避免再次觸發歐盟的制度性反彈。
詳見 `narratives/china_side/economic_coercion_examples.md`(對台與對歐案例的對照)。
---
## 維度六:對歐洲對台立場的反向強化——從「經濟依賴」到「價值同盟」
北京對歐脅迫的最深層影響,是重塑了歐洲對台灣的戰略定位。台灣從「遠東的貿易夥伴」,轉變為「抵抗威權脅迫的前線」。
### 事實層
- **供應鏈韌性連結**:歐洲的「去風險」並非與中國全面脫鉤,而是尋求關鍵供應鏈的替代方案。台灣的半導體與高科技製造業,成為歐洲供應鏈重組中的關鍵節點。這使得「維持台海穩定」從政治口號轉化為歐洲的經濟安全需求。
- **價值觀外交的復興**:北京對歐的戰狼外交與人權制裁,讓歐洲內部的「價值觀外交」派佔據上風。台灣的民主體制,成為歐洲在面對威權脅迫時天然的意識形態盟友。
- **雙邊關係的實質提升**:雖然無國家承認,但歐洲與台灣的實質關係在脅迫陰影下顯著升溫。多個歐洲國家提升了與台灣的交往層級,推動駐台機構更名(如荷蘭、法國),並強化半導體與綠能合作。
### 脈絡層
北京的脅迫打破了歐洲長期以來的「經濟中國、價值歐洲」的切割幻想。當北京將經濟市場武器化,用來懲罰歐洲的價值觀表達時,歐洲被迫認知到:經濟依賴本身就是對價值觀的威脅。在這個認知重構的過程中,台灣從「中國問題的一部分」被重新定義為「歐洲抵抗經濟脅迫的系統性夥伴」。
### 分析含義
**破除常見簡化**:將歐洲的「挺台」舉動單純歸因於「美國施壓」是嚴重錯誤的。歐洲對台立場的強化,很大程度上是**北京對歐脅迫的直接副產品**。分析時,若忽略北京施壓對歐洲內部政治動力的塑造作用,將無法解釋為麼法國、德國等在戰略上仍希望與中國合作的核心國家,其國內議會卻頻繁通過強硬的涉台決議。這是歐洲對「主權被侵犯」的防禦性反應,而非對美國的追隨。
---
## 關聯分析
### 關聯一:與立陶宛台灣代表處事件的連動
本檔案提供了北京對歐脅迫的結構性分析,而該事件檔案提供了具體的時間線與決策細節。
**功能**:提供脈絡層,用於解釋北京為何在立陶宛案例中採取前所未有的「次級制裁」,以及該決策的內部邏輯。
詳見 `events/key_events/event_lithuania_taiwan_office_2021.md`。
### 關聯二:與歐盟台灣關係敘事的連動
本檔案揭示了北京脅迫如何反向強化歐盟對台立場,而該敘事檔案提供了歐盟整體對台政策的演變軌跡。
**功能**:提供因果機制分析,用於解釋歐盟從「戰略模糊」轉向「價值同盟」的外部推力。
詳見 `narratives/international/eu_taiwan_relations.md`。
### 關聯三:與經濟脅迫類型學的連動
本檔案中的對歐脅迫案例(特別是次級制裁),擴展了類型學中「目標」與「工具」的維度。
**功能**:提供對照與擴充,用於將對歐案例映射至類型學框架,驗證「精準脅迫 vs. 全面脅迫」模型在國際維度的適用性。
詳見 `primitives/frameworks/economic_coercion_typology.md`。
### 關聯四:與北京對台經濟脅迫案例集的連動
對歐脅迫與對台脅迫在工具選擇上有高度同構性(如海關白手套、企業稽查),但目標與自傷底線不同。
**功能**:用於交叉驗證,對比北京在不同量體的目標上如何調整脅迫工具的組合,揭示其「依賴度武器化」的通用邏輯與邊界。
詳見 `narratives/china_side/economic_coercion_examples.md`。
### 關聯五:與關鍵歐盟成員國台灣立場的連動
本檔案分析了北京對捷克等國的「精英分化」策略,而該檔案提供了成員國內部的政治板塊細節。
**功能**:提供事實層素材,用於驗證北京「拉攏行政部門、打擊立法部門」的策略在特定國家政治結構中的有效性。
詳見 `entities/foreign_actors/key_eu_member_states_taiwan.md`。
### 關聯六:與兩岸主權爭議的連動
北京對歐脅迫的核心觸發點是「台灣代表處」的命名,這本質上是主權象徵的爭奪。
**功能**:提供國際維度的證據,用於分析北京如何將國內法(如《反分裂國家法》的精神)長臂管轄至第三國的命名慣例上。
詳見 `primitives/frameworks/sovereignty_dispute_framework.md`(占位)。
---
## 常見的分析陷阱
### 陷阱一:將「寒蟬效應」等同於「歐洲整體屈服」
看到多數歐洲國家未跟進立陶宛使用「台灣」名義,便斷定北京脅迫完全成功,是嚴重的誤判。未跟進的原因不僅是恐懼,還包括歐盟內部的決策慣性、對美國單邊主義的顧慮,以及對兩岸關係複雜性的認知。忽視了歐洲在實質層面(如供應鏈合作、議會決議)對台支持的持續上升。
**如何避免**:區分「形式上的合規」(使用台北名義)與「實質上的挺台」(提升經貿與安全對話層級)。北京的脅迫凍結了形式,但加速了實質。
### 陷阱二:假設「次級制裁」是北京可隨意使用的常規武器
北京對立陶宛使用的次級制裁,對在華跨國企業造成了巨大的恐慌,但也讓北京付出了極高的信譽成本。跨國企業開始將中國市場視為「高政治風險區」,這直接削弱了中國吸引外資的能力。次級制裁是「核選項」,頻繁使用將摧毀北京自身的經濟生態。
**如何避免**:評估北京未來的脅迫行動時,應將次級制裁視為極低概率的升級選項,而非常規工具。北京更可能回歸「隱蔽的行政拖延」或「雙邊外交施壓」。
### 陷阱三:把「歐盟」當作單一行動者
北京常誤判歐盟為可通過施壓布魯塞爾或大國(德法)來控制的單一實體。但歐洲議會的權力、成員國的一票否決權,使得北京的施壓往往在某一層級受阻後,在其他層級引發反彈。制裁歐洲議員反而強化了議會的對抗意志。
**如何避免**:分析北京對歐施壓時,必須拆解目標是歐盟機構(執委會、議會、理事會)還是特定成員國。不同目標的反應函數完全不同。
### 陷阱四:低估「戰狼外交」的破壞力
將戰狼外交的言辭攻擊視為「僅供國內消費的表演」,忽略了其對歐洲公眾輿論的深層刺激。公開的威脅(如「付出深重代價」)在歐洲媒體的放大下,直接塑造了「中國是規則破壞者」的公眾認知,為政府採取對抗性政策提供了民意基礎。
**如何避免**:將輿論工具視為與經濟、外交工具同等重要的變量。評估言辭攻擊時,不僅看其對國內受眾的效果,必須同時評估其對目標國公眾輿論的「反向動員」效應。
### 陷阱五:線性外推「去風險」等於「脫鉤」
北京的脅迫確實加速了歐洲的「去風險」,但去風險不等於與中國經濟脫鉤。歐洲的核心訴求是降低「單一依賴風險」,而非切斷聯繫。假設歐洲將因為北京的脅迫而完全轉向台灣、放棄中國市場,是脫離現實的。
**如何避免**:精確區分「去風險」(供應鏈多元化、限制關鍵技術出口)與「脫鉤」(全面斷絕經貿往來)。歐洲對台灣的支持,是在維持與中國經貿關係的前提下,進行的風險對沖,而非零和替代。
### 陷阱六:忽視歐盟內部的「免費搭車」問題
部分歐洲國家在享受北京對其他國家施壓所帶來的「集體防禦」(如ACI)的同時,自身仍試圖維持與中國的緊密經貿關係(如法國推動空客銷售)。這種「免費搭車」行為,削弱了歐盟集體反制北京脅迫的威懾力。
**如何避免**:評估歐盟反制措施的有效性時,必須檢查成員國的「執行一致性」,而非僅看布魯塞爾的聲明。大國的雙邊利益常成為北京突破集體防禦的破口。
---
## 仍不確定的部分
- **北京對歐洲「去風險」的內部評估**:北京是否已內部認定對歐的極限施壓(如次級制裁)得不償失,還是僅將其視為戰術挫折,未來在涉台核心利益上仍會重啟?此決策偏好不可知。
- **歐盟《反脅迫工具》(ACI)的實際威懾力**:ACI雖已立法,但其啟動需27個成員國的合格多數同意。在面對北京對特定國家的精準脅迫時,歐盟能否迅速達成共識並實施反制,仍是不確定的。
- **跨國企業的供應鏈重組速度**:雖然「去風險」成為共識,但資本的重置成本極高。跨國企業在多大程度上能實質降低對中國供應鏈的依賴,將決定北京未來經濟脅迫槓桿的保留程度。
- **2028前台海危機對歐洲的溢出效應**:若台海發生軍事衝突或極端經濟封鎖,歐洲的「去風險」將瞬間轉為「緊急脫鉤」。但歐洲對此類極端情境的應急預案與承受底線,外部難以評估。
- **美國大選對歐洲對華政策的影響**:若美國在2024後轉向更極端的保護主義,可能迫使歐洲在經濟上重新向中國傾斜,從而削弱北京對歐脅迫的反向動員效果。此外部變數高度不確定。
---
## 與其他文件的關係
- 立陶宛台灣代表處事件:`events/key_events/event_lithuania_taiwan_office_2021.md` —— 提供北京對歐脅迫最關鍵案例的事實層時間線與決策細節
- 歐盟台灣關係敘事:`narratives/international/eu_taiwan_relations.md` —— 提供歐盟整體對台政策演變的脈絡,用於對照脅迫反向強化效應
- 經濟脅迫類型學:`primitives/frameworks/economic_coercion_typology.md` —— 提供工具分類的理論基礎,用於定位對歐脅迫在五軸矩陣中的位置
- 北京對台經濟脅迫案例集:`narratives/china_side/economic_coercion_examples.md` —— 提供對台案例的對照組,用於揭示北京在不同目標上的工具選擇差異
- 關鍵歐盟成員國台灣立場:`entities/foreign_actors/key_eu_member_states_taiwan.md` —— 提供成員國內部政治板塊細節,用於驗證北京「精英分化」策略的微觀基礎
---
## 一句話濃縮
> 北京對歐洲的脅迫工具集揭示了一個從「雙邊外交降級」到「跨國供應鏈次級制裁」的升級軌跡——其核心不在於經濟損失的絕對值,而在於**製造「不確定性溢價」以重劃歐洲涉台行動的紅線**;但分析時必須穿透「以商逼政」的表象,洞察其引發的「反向制度化」悖論:脅迫越深,歐洲的集體防禦機制與對台政治連結越強,最終使北京的威懾陷入「短期有效、長期自傷」的結構性困境。
---
last_updated: 2026-10-24
@@ -0,0 +1,341 @@
# 驻美国台北经济文化代表处(TECRO)—— 运作细节
---
**类型**: 机构档案
**当前角色**: 台湾对美实质大使馆、对美外交与安全沟通枢纽
**何时调用**: 分析台美实质外交互动、军售沟通管道、国会游说运作、驻美代表政治信号、危机时刻台美热线机制、川普 2.0 时期台美交易型外交的执行细节
---
## 一句话浓缩
TECRO 是法律上的"外国非营利组织"、实质上的"台湾驻美大使馆"——它与 AIT 构成"对等但不对称"的镜像关系,其运作细节(从国会游说到军售发文)是解码台美非官方关系如何执行官方职能的钥匙。
---
## 与同名概况档案的分工说明
本档案(路径 `entities/foreign_actors/tecro.md`)聚焦于**运作细节**(Operational Details)。
若 `entities/organizations/tecro.md` 存在,其应涵盖机构历史沿革、法源依据、更名历程(从 CCNAA 到 TECRO)等概况。
本档案不重复基础历史,而是拆解 TECRO **如何运作**:组织结构与功能分配、代表角色的政治学、与 AIT 及美方的互动操作、涉台立法的协调实况,以及在重大事件与川普 2.0 时期的执行挑战。
---
## TECRO 的法律性质与运作张力
### 设立基础与法律拟制
TECRO 的设立基于美国《台湾关系法》(TRA)的授权框架,但与 AIT(美国国内注册的非营利组织)不同,TECRO 在法律上被视为**外国机构在美国的办事处**:
- 注册为哥伦比亚特区的非营利组织
- 人员具有台湾外交部(MOFA)官方身份,但在美执行"非官方"职能
- 享有部分外交特权与豁免(依据 1982 年与美国国务院签订的《特权与豁免协定》),但不等同于正式外交使团
### 双重身份的运作张力
| 维度 | 法律身份 | 实质身份 |
|---|---|---|
| 性质 | 外国非营利组织 | 台湾驻美大使馆 |
| 人员 | "代表处雇员" | 外交部派任的职业外交官或政治任命人员 |
| 特权 | 部分税务豁免、通讯保密 | 不完全的维也纳公约外交豁免 |
| 职能 | "经济文化推广" | 全范围外交、安全、军售协调 |
**分析含义**:TECRO 的法律身份导致其在美运作存在"可见性"与"合法性"的张力。例如,TECRO 人员无法以官方身份进入美国国务院主建筑,必须通过 AIT 居中协调,或在非官方场所会面。这种物理与程序的受限,是台美非官方关系的日常运作基础。
---
## 组织结构:华府主办与分处的功能分工
TECRO 的组织结构并非单一据点,而是覆盖全美的网络,各分处具有明确的地缘与功能分工。
### 华府主办(DC Headquarters)
华府主办是 TECRO 的核心,直接对台北的外交部(MOFA)与国安会负责。其内部结构实质比照大使馆编制:
- **代表处代表**(Representative):实质大使,统筹对美全盘事务
- **副代表**(Deputy Representative):通常设两位,分别侧重政治与经济
- **政治组**(Political Division):对口美国国安会、国务院,处理双边政治与安全议题
- **国会组**(Congressional Division):核心游说与立法协调单位,对口美国参众两院
- **经济组**(Economic Division):对口美国商务部、USTR,处理贸易、投资、科技(含半导体)议题
- **新闻组**(Press Division):媒体联系与公共外交
- **科技组**(Science & Technology Division):近年强化,聚焦高科技供应链与研发合作
- **领事组**(Consular Division):处理签证、侨务与急难救助
- **军事代表团**(Military Mission):由台湾国防部派任,着便装,处理军售与安全合作
**分析含义**:华府主办的组别设置反映了台美关系的实质优先级。科技组的强化与军事代表团的常设,显示安全与供应链已超越传统经济文化范畴,成为 TECRO 最核心的业务。
### 各地分处的地缘功能
TECRO 在全美设有 12 个分处(办事处),每个分处的功能不仅是领事服务,更具有地缘战略意义:
- **驻纽约办事处**:双重功能。一是联合国周边外交(虽非联合国成员,但维持与各国驻联合国代表团的非官方接触);二是金融中心对接(华尔街、主权基金等)。
- **驻旧金山办事处**:硅谷与科技供应链对接,是科技组业务的前沿延伸。
- **驻洛杉矶办事处**:侨务重镇,且对接美国娱乐与软实力产业。
- **驻芝加哥办事处**:传统工业与农业州对接,涉及供应链与贸易议题。
- **驻休士顿办事处**:能源产业与太空总署(NASA)对接。
- **驻波士顿办事处**:学术与生技产业对接。
- **驻关岛办事处**:2022 年新设,战略意义在于第二岛链安全与美军后勤协调。
**分析含义**:分析 TECRO 分处的动态(如关岛新设、旧金山业务加重)是观察台湾对美战略重心转移的指标。分处不仅是服务据点,更是台湾在美国各权力中心(金融、科技、能源、军事)的嵌入节点。
---
## 驻美代表的角色与历任演变
驻美代表是 TECRO 的核心人物,其角色在"非官方机构负责人"与"实质大使"之间摆荡。代表的风格与政治资本,直接决定 TECRO 在华府的运作效能。
### 角色的政治学
- **名义上**:TECRO 代表不能被称为"大使"(Ambassador),不能向美国总统递交到任国书。
- **实质上**:代表是台湾对美最高层级沟通管道,直接向台北总统与外长负责,需与美方内阁级官员、国安顾问、国会领袖建立互信。
- **结构性矛盾**:代表必须在不触碰"一中政策"红线的前提下,最大化台湾的实质利益与可见度。任何过度高调的"大使化"行为,都可能引发美方行政部门的限制或北京的施压。
### 历任代表与运作风格演变
| 时期 | 代表 | 风格与运作特征 | 分析含义 |
|---|---|---|---|
| 2000s | 吴钊燮 | 传统外交官风格,侧重行政体系沟通 | 体制内运作,低可见度 |
| 2016-2018 | 高硕泰 | 试图提升可见度,与国安体系对接紧密 | 蔡政府初期对台美升级的试探 |
| 2018-2020 | 陈方安生(暂代/过渡) | 过渡性质 | - |
| 2020-2023 | 萧美琴 | **转型关键**:高度政治任命,直接与国会领袖、国安会建立热线;大量使用社交媒体;公开活动"大使化" | 打破传统低调模式,以"战猫"风格在华府建立跨党派支持;代价是北京高度敌视 |
| 2023-至今 | 俞大㵢 | 职业外交官回归,延续萧美琴建立的国会网络,但风格更趋传统稳健 | 在"高调运作"与"体制内合规"间寻求平衡 |
**分析含义**:萧美琴的任期是 TECRO 运作模式的分水岭。她证明了"高可见度、直接沟通、社交媒体外交"在华府是可行的,且能有效对冲行政部门的保守倾向。俞大㵢的任务是"制度化"萧的成果,而非退回旧模式。分析时,不应将代表风格仅视为个人特质,而应视为台北对美战略的执行选择。
详见 `entities/people/hsiao_bi_khim.md`。
---
## 与 AIT 的"对等不对称"关系
TECRO 与 AIT 是台美非官方关系的镜像,但这对镜像存在结构性的不对称。
### 法律与程序的不对称
- **AIT 在台北**:享有高度实质便利,处长可与台湾总统、外长公开会面,实质待遇等同大使。
- **TECRO 在华府**:代表长期无法进入美国国务院主建筑,无法与国务卿在国务院公开会面;与国安会的接触也多在非官方场所。
### 沟通管道的不对称
- **台北对美**:主要通过 AIT 台北处长直接向台湾高层传达美方立场,管道单一且高效。
- **华府对台**:TECRO 代表需通过 AIT 华盛顿总部(AIT/DC)安排与美方官员会面,或通过国会、智库等间接管道施压。TECRO 对美方决策圈的直接穿透力弱于 AIT 对台北的穿透力。
### 近年的"不对称缩减"
2020 年后,美方逐步放宽对 TECRO 的限制:
- 2021 年,台湾代表得以进入国务院与官员会面(虽非国务卿层级)。
- 2023 年,台湾代表于华盛顿官舍接待美国会议员与官员的频率增加。
**分析含义**:这种"不对称缩减"是台美关系升级的实质指标。分析时,观察 TECRO 代表与美方官员会面的**地点**(是国务院内还是外部咖啡馆?)与**层级**(是助理国务卿还是副国务卿?),比观察公开声明更能准确判断关系温度。
详见 `entities/foreign_actors/ait.md`。
---
## 在国会、智库、政府的实际外交活动
TECRO 的运作核心,在于如何在"非官方"限制下,穿透美国三权分立体系,最大化台湾的影响力。
### 一、国会:TECRO 的主阵地
由于美国行政部门受"一中政策"约束,国会成为 TECRO 最积极经营、也最不受限制的权力中心。
- **台湾连线运作**:TECRO 国会组与"国会台湾连线"(Congressional Taiwan Caucus)紧密合作,协调议员提出友台法案(如《台湾旅行法》、《台湾保证法》)。
- **议员与助理游说**:TECRO 不仅经营议员,更深度经营议员助理(Legislative Assistants)。助理是法案起草与议程设定的关键,TECRO 定期举办简报会、酒会,提供台湾视角的资讯。
- **选区经营**:TECRO 协助安排议员访台,访台行程的安排(如参访半导体厂、与总统会面)是回馈议员、建立亲身体验的关键游说手段。
- **国防授权法(NDAA)操作**:每年 NDAA 的友台条款,是 TECRO 国会组与台湾驻美军事代表团协同游说的结果。TECRO 负责政治协调,军事代表团提供专业需求。
**分析含义**:TECRO 的国会运作是"逆势操作"——在行政关系受限时,通过国会立法倒逼行政部门放宽对台限制。分析台美关系时,国会是 TECRO 的"进攻武器",行政部门是"防守底线"。
详见 `entities/foreign_actors/us_congress_taiwan_caucus.md`。
### 二、智库:第二轨道的影子外交部
华府智库是 TECRO 绕过"非官方"限制、与美方现任官员进行实质对话的关键场域。
- **活动赞助与参与**:TECRO 常以"共同赞助"身份参与 CSIS、布鲁金斯、哈德逊研究所等智库的研讨会。在这些场合,TECRO 代表可与美方现任官员同台,实现"非官方场合的官方接触"。
- **议题设定**:TECRO 主动向智库提供议题建议与资讯,引导智库产出符合台湾利益的报告,进而影响华府政策圈的话语权。
- **人事旋转门**:TECRO 与智库人员(特别是熟悉台海议题的前官员)维持长期关系,这些人在进入政府后即成为 TECRO 的直接沟通管道。
**分析含义**:智库是 TECRO 的"影子外交部"。分析 TECRO 在智库的活跃度(赞助金额、代表演讲频率),可评估其"软性影响力"的投入程度。
### 三、行政部门:受限下的渗透
TECRO 与美国行政部门的互动,必须通过 AIT 居中,或以"非正式"方式进行:
- **国务院**:通过 AIT/DC 安排正式沟通;TECRO 政治组与国务院对口官员维持工作层级(Working level)的日常联系。
- **国防部**:TECRO 军事代表团与印太司令部、国防部印太安全事务助理部长办公室(ASD-IPA)有直接工作联系,但高度保密。
- **商务部与 USTR**:经济组与美方在供应链、贸易谈判上有直接工作接触,因经济议题的政治敏感度较低,受限较少。
**分析含义**:行政部门是 TECRO 运作最困难的场域。分析时,若发现 TECRO 与行政部门的接触突然增加或公开化,通常意味着美方对台政策的根本性调整。
---
## 涉台立法的协调角色
TECRO 在涉台立法中不仅是"游说者",更是台北与华府之间的"协调者"。
### 立法协调的流程
1. **需求确认**:台北(MOFA 与国安会)确定立法需求(如军售常态化、高层互访解禁)。
2. **TECRO 评估**:TECRO 国会组评估华府政治气候,寻找合适的议员提案人。
3. **草案协商**:TECRO 与议员助理协商草案措辞,确保条文在法律上可行,且不致引发行政部门强烈反弹。
4. **台北背书**:重大法案需台北确认,TECRO 负责传达台北的底线与期望。
5. **推动立法**:TECRO 协调听证会证人、提供资料,并在参众两院协调版本。
6. **执行监督**:法案通过后,TECRO 监督行政部门的执行力度,防止立法意旨被架空。
**分析含义**:TECRO 的立法协调角色显示,台美关系是"双向建构"——台北并非被动接受美方政策,而是通过 TECRO 主动塑造美方的法律环境。分析涉台法案时,不应只看议员表态,应追踪 TECRO 在背后的协调痕迹。
详见 `entities/organizations/mofa_taiwan.md`。
---
## 重大事件中的角色
### 一、军售沟通
TECRO 是台美军售的法定对口:
- **发价书(LOA)签署**:TECRO 代表台湾政府签署军售发价书,这是法律程序上的关键步骤。
- **需求沟通**:军事代表团通过 TECRO 向美方传达台湾的防卫需求,并在 AIT 协助下与国防部对话。
- **预算协调**:政治组与国会组协调军售预算在美方的审批流程,确保对台军售不被行政程序拖延。
**分析含义**:军售不仅是军事议题,更是政治议题。TECRO 在军售中的角色,是确保台湾的"硬需求"转化为美方的"硬承诺"。
### 二、危机沟通
在台海危机(如 2022 年裴洛西访台后的军演)中,TECRO 扮演"热线替代品":
- 由于台美无正式邦交,无官方外交热线,TECRO 代表与 AIT 及国安会的私人手机联系,成为危机时的紧急沟通管道。
- TECRO 负责向台北传达美方的"降温"要求,同时向美方解释台北的政治底线。
**分析含义**:危机时 TECRO 的沟通效能,取决于代表与美方关键人物的"个人互信"。这解释了为何驻美代表的人选常需具备与华府权力圈的深厚人脉。
### 三、总统过境
台湾总统过境美国,是 TECRO 最复杂的后勤与政治操作:
- **政治规格谈判**:TECRO 与 AIT 及国务院协商过境城市、停留时间、会见人物、媒体采访规格。这些规格是台美关系的"温度计"。
- **后勤安排**:TECRO 分处负责过境期间的维安、交通、侨宴安排。
- **危机管控**:防止过境被北京解读为"官方访问",同时最大化过境的政治宣传效果。
**分析含义**:总统过境是 TECRO "走钢丝"能力的终极测试——必须在"过境舒适度"与"政治不挑衅"之间找到平衡点。
---
## 在川普 2.0 时期的运作挑战
川普 2.0 对 TECRO 的运作带来结构性挑战,核心在于"交易型外交"与"制度性支撑"的矛盾。
### 挑战一:从"价值同盟"到"交易计价"
- 拜登时期的台美关系建立在"民主同盟"话语上,TECRO 可诉诸价值共识。
- 川普 2.0 倾向将台湾视为"安全消费者"与"半导体供应者",TECRO 必须学会用"防卫贡献"与"经济利益"(如台积电投资)来计价台湾的价值。
### 挑战二:人事动荡与管道不稳
- 川普 1.0 时期国务院与国安会人事动荡,导致 TECRO 难以建立稳定的对口关系。
- 川普 2.0 可能重演此局面,TECRO 必须更依赖国会与智库等相对稳定的管道。
### 挑战三:被卷入美国内政的风险
- 川普高度极化,支持台湾可能成为党派议题。TECRO 必须极度谨慎,避免被视为"反川普"或"亲民主党"的机构,维持跨党派支持是其生存底线。
### 挑战四:与北京的"大交易"阴影
- 若川普寻求与北京达成重大战略交易,台湾可能成为筹码。TECRO 在此情境下的角色,是提前预警并动员国会力量,设置交易的"护栏"。
**分析含义**:川普 2.0 时期,TECRO 的运作逻辑必须从"深化关系"转向"防御风险"。其国会组与军事代表团的权重将进一步提升,因为这是最不易被行政命令撤销的实质利益。
---
## 关联分析
### 关联一:与 AIT 的镜像与不对称(提供脉络层 / 用于对照)
TECRO 与 AIT 是台美非官方关系的双柱,但 AIT 在台北的穿透力远大于 TECRO 在华府的穿透力。分析台美互动时,必须将两者的"不对称"纳入考量,避免误判台北对美影响力等同于美国对台影响力。
详见 `entities/foreign_actors/ait.md`。
### 关联二:与萧美琴的路径依赖(提供事实层 / 提供脉络层)
萧美琴任内建立的"高可见度、直接沟通"模式,已成为华府对 TECRO 代表的期待基准。俞大㵢虽回归传统外交官风格,但无法完全退回萧美琴之前的低调模式。这种"路径依赖"是分析当代 TECRO 运作的关键变量。
详见 `entities/people/hsiao_bi_khim.md`。
### 关联三:与 MOFA 的指挥链张力(提供脉络层 / 用于交叉验证)
TECRO 名义上受 MOFA 指挥,但驻美代表常因直接受总统信任而拥有高度自主性。当台北总统与 MOFA 对美策略出现分歧时,TECRO 可能成为"双重指挥"的张力点。分析 TECRO 发言时,需交叉验证其是否与 MOFA 口径一致,以判断内部是否团结。
详见 `entities/organizations/mofa_taiwan.md`。
### 关联四:与国会台湾连线的共生关系(提供事实层 / 提供脉络层)
TECRO 国会组与台湾连线是"供给与需求"的共生关系。TECRO 提供立法素材与政治舞台,台湾连线提供立法杠杆。分析涉台法案时,追踪提案议员与 TECRO 的互动频率,可判断法案的"台北意志"浓度。
详见 `entities/foreign_actors/us_congress_taiwan_caucus.md`。
### 关联五:与日本对台机构(JAA)的类比(用于对照)
日本过去的"交流协会"(现日本台湾交流协会,JATA)与 TECRO 面临类似的"非官方"限制。但日方在解禁官员往来上的步伐与美方不同步。比较 TECRO 与 JATA 的运作模式,可揭示"非官方关系"在不同大国战略下的弹性差异。
详见 `entities/foreign_actors/japan_jaa_taipei.md`。
---
## 常见的分析陷阱
### 陷阱一:把 TECRO 视为完全独立的大使馆
TECRO 的运作受制于 AIT 的协调与美方的"非官方"框架。将其视为可独立决策的大使馆,会高估台湾对美外交的自主性。
### 陷阱二:忽略 MOFA 的指挥链
驻美代表虽具高度自主性,但重大政策(如军售请求、总统过境规格)仍由 MOFA 与国安会决策。将 TECRO 的所有行动视为代表个人意志,是过度个人化的分析错误。
### 陷阱三:将国会游说等同于政策制定
TECRO 能成功推动友台立法,不代表能决定行政部门的执行力度。立法胜利(如《台湾旅行法》)与行政落实(高官是否真的访台)之间常有落差。
### 陷阱四:低估军事代表团的角色
TECRO 的军事代表团虽低调,但在军售与安全合作中扮演核心角色。只看政治组与国会组的公开活动,会忽略台美关系中最实质的安全支柱。
### 陷阱五:假设"高调"等于"有效"
萧美琴的高调模式在华府特定政治氛围下有效,但在川普 2.0 的交易型氛围下,高调可能引发反弹或被索要更高对价。将"可见度"等同于"外交成效"是简化判断。
### 陷阱六:忽视分处的战略功能
将 TECRO 等同于华府主办,忽略纽约(联合国)、旧金山(科技)、关岛(军事)分处的独特功能,会导致对台湾对美全盘战略的误判。
---
## 仍不确定的部分
- **川普 2.0 时期 TECRO 与国安会的沟通管道是否畅通**:若人事动荡,TECRO 可能需完全依赖国会与二轨管道。
- **TECRO 军事代表团与印太司令部的具体协调机制**:此部分高度保密,外部难以确知其日常运作细节。
- **TECRO 对智库的实质影响力**:赞助金额与议题设定的关联性缺乏公开数据,难以量化评估。
- **台北总统与驻美代表的私人沟通频率**:这决定了 TECRO 在危机时的授权范围,但属极机密信息。
---
## 与其他文件的关系
- AIT 档案:`entities/foreign_actors/ait.md` —— 对口机构,理解不对称关系的关键
- 萧美琴档案:`entities/people/hsiao_bi_khim.md` —— 前任代表,理解当代 TECRO 运作模式转型的关键
- 台湾外交部档案:`entities/organizations/mofa_taiwan.md` —— 上级指挥机构,理解 TECRO 决策权限的关键
- 国会台湾连线档案:`entities/foreign_actors/us_congress_taiwan_caucus.md` —— 核心互动对口,理解立法游说运作的关键
- 日本对台机构档案:`entities/foreign_actors/japan_jaa_taipei.md` —— 类比参照,理解非官方关系不同运作模式的关键
- 台湾关系法概念:`primitives/concepts/taiwan_relations_act.md` —— 法源基础,理解 TECRO 法律地位的关键
- 川普 2.0 不确定性叙事:`narratives/us_taiwan/trump_taiwan_uncertainty.md` —— 当代挑战,理解 TECRO 运作风险的关键
---
## 一句话浓缩
> TECRO 是**法律上的非营利组织 + 实质上的台湾驻美大使馆 + 国会山上的游说机器**——它的运作效能取决于代表的政治资本、与 AIT 的不对称协调、以及对华府权力结构(国会 > 智库 > 行政部门)的精准穿透。
> 分析 TECRO 时不能只看华府主办的光环——要看分处的地缘布局、军事代表团的暗线操作、以及台北指挥链的张力。
> 在川普 2.0 时期,TECRO 的核心挑战是从"价值同盟的维护者"转型为"交易型外交的计价者"。
---
last_updated: 2026-05-08
@@ -0,0 +1,283 @@
# 美国涉台立法体系
---
**类型**: organization(体系)
**当前角色**: 美方对台制度性承诺的载体、行政裁量权的约束框架、跨党派共识的固化工具
**何时调用**: 分析美国对台法律基础的演变与层叠结构;评估国会立法对行政部门的实质约束力;解读NDAA涉台条款的授权-拨款落差;预判川普2.0时期美台关系的制度底线;衡量立法文本与政策执行之间的偏移度;比较美台关系与其他非邦交国关系的法律化差异
---
## 一句话浓缩
美国涉台立法体系是以《台湾关系法》为基座、后续单项法案为延伸、年度国防授权法为引擎的层叠结构——它不是对台主权承认的递进,而是对行政部门『战略模糊』与『交易主义』的制度性下限绑定,其真实效力取决于法律文本的强制性与行政官僚体系的合规性响应之间的落差。
---
## 体系的层级与性质:从基座到层叠
### 事实层:三层架构
美国涉台立法并非单一法律,而是一个随时间堆叠的层级体系:
1. **基座层**:《台湾关系法》(TRA, 1979)——提供最根本的国内法框架,确立台湾在美国法律体系中的准国家地位与军售原则。
2. **延伸层**:单项涉台法案(2018-2022)——针对特定议题(高层互访、国际空间、交往准则)对TRA框架进行细化与扩展。
3. **引擎层**:年度国防授权法(NDAA)涉台条款——利用年度必通过的国防预算法案,持续注入对台军事支持与协同的具体要求。
**脉络层**:此层级结构的本质是『增量政治』。后续立法从未试图推翻或取代TRA,而是通过叠加条款,不断压缩行政部门在TRA框架下原本享有的宽泛裁量权。每一次新立法,都是在TRA的模糊地带中打入一个具体的楔子。
**分析含义**:分析时,切勿将单项法案视为独立存在。任何单项法案的效力,都取决于它如何与TRA及NDAA条款产生交互作用。例如,《台湾旅行法》解除了高层互访的法律限制,但真正让互访常态化的,是NDAA中要求国防部派遣官员访台的拨款与报告要求。
详见 `primitives/concepts/taiwan_relations_act.md`。
---
## 主要法案清单与结构意义
### 《台湾旅行法》(Taiwan Travel Act, 2018)
**事实层**:2018年3月由川普签署生效。法案指出美国政策应允许各层级美国官员访台,并允许台湾高层官员访美会见对口官员。
**脉络层**:此法案的突破性在于打破了国务院内部《与台交往准则》的自我设限。在TRA框架下,美方从未明文禁止官员访台,但通过内部行政准则将其降级为非官方往来。台湾旅行法以国会立法形式,将『允许互访』从行政特许转化为法律要求。
**分析含义**:此法案的关键词是『should』(应)而非『shall』(必须)。它是指示性而非强制性立法。法案通过后,高层访台并未立即爆发性增加,而是随政治气氛逐步升温。这证明:**立法解禁只是必要条件,行政意愿才是充分条件**。
### 《台北法案》(TAIPEI Act, 2019/2020)
**事实层**:2019年参议院通过,2020年众议院通过并由川普签署。全名为《台湾友邦国际保护及加强倡议法》。要求行政部门支持台湾国际参与,并指示美方对于严重损害台湾安全或繁荣的国家,应考虑调整外交与经济关系。
**脉络层**:此法案是美方试图将其国内法效力外溢至第三国的罕见尝试。其逻辑是:不仅美国自己要与台湾维持实质关系,还要惩罚那些转向北京的台湾邦交国。
**分析含义**:这是涉台立法体系中『效力最弱但意图最强』的法案。由于涉及美国对第三国关系的调整,行政部门拥有极大的外交裁量权,几乎从未真正依据此法对转向国家实施严厉制裁。其真实功能是**叙事武器**——为美方在国际场合『替台湾说话』提供国内法依据,而非实质改变地缘政治版图。
### 《台湾保证法》(Taiwan Assurance Act, 2020)
**事实层**:2020年作为拨款法附件通过。要求国务卿审视并更新对台交往准则,并推动对台军售常态化。
**脉络层**:此法案直接命中了行政官僚体系的运作黑箱。国务院的对台交往准则是内部行政规章,不对外公开且不受国会直接审查。《台湾保证法》强制要求国务院进行内部检讨并提交报告,实质上是国会向行政官僚体系植入的『审计程序』。
**分析含义**:此法案是观察『立法如何驱动行政』的最佳案例。法案通过后,国务院于2021年发布了新的交往准则,解除了多项限制(如允许台湾官员展示国旗、在联邦机构内会面)。**这证明:立法的真正威力不在于强制执行,而在于迫使官僚体系启动内部合规性审查,从而改变默认的保守操作模式。**
### 《台湾政策法》(Taiwan Policy Act, 2022)
**事实层**:2022年由参议院外委会通过,但最终未以单项法案形式通过两院。其核心实质条款被拆分纳入2023财年NDAA,象征性主权条款则被大幅修改或删除。
**脉络层**:这是1979年以来最全面的涉台政策重构尝试。原始版本包含两大类条款:
1. **实质安全条款**:赋予台湾『主要非北约盟友』(MNNA)地位、提供百亿级军事融资、加速军售交付。
2. **主权象征条款**:将驻美代表处更名、指定台湾为『主要非北约盟友』、承认台湾政府合法性。
拜登政府极力游说削弱后者,理由是这可能引发北京过度反应,破坏战略模糊。最终结果:实质安全条款保留,主权象征条款被剥离或降级。
**分析含义**:此法案的折冲过程完美揭示了涉台立法体系的**结构性天花板**——国会可以无限逼近主权承认的边界,但一旦行政部门警示可能引发战争,国会就会退回安全区。**把国会涉台立法等同于美方对台主权承认是错的**,因为国会在『改变主权定位』上会向行政部门的战略考量让步。国会支持的上限,受制于美方对战略模糊的坚持。
详见 `narratives/us_taiwan/congressional_support_pattern.md`。
---
## 国防授权法(NDAA):体系运转的引擎
### 事实层:NDAA的涉台常态化
NDAA是国会每年必须通过的法案,连续六十余年成功立法。自2018年起,涉台条款成为NDAA的常态附件,且分量逐年加重:
| 年度NDAA | 关键涉台条款 | 性质 |
|---|---|---|
| 2019 | 要求加强台美军事交流、支持台湾不对称战力 | 政策宣示 |
| 2021 | 确立太平洋威慑倡议(PDI)、支持台湾维持足够自卫能力 | 战略框架 |
| 2023 | 授权对台提供100亿美元军事融资(FMF)、赋予台湾MNNA待遇 | 实质资源注入 |
| 2024 | 要求加速对台军售交付、建立美台联合军事训练机制 | 执行面加速 |
**脉络层**:NDAA的运作逻辑是『搭便车』。由于NDAA是年度国防预算授权,总统不可能否决,因此将涉台条款嵌入NDAA可避免单独立法在参议院被冗长辩论阻挡的风险。这是一种高度理性的制度操作。
**分析含义**:NDAA条款的约束力最强(因总统必须签署整体法案),但常面临『授权不拨款』或『行政部门执行迟缓』的落差。分析时必须区分NDAA中的『授权条款』与『拨款条款』——前者是许可,后者才是真金白银。例如,2023财年NDAA授权100亿军事融资,但实际拨款需在年度拨款法案中落实,且交付周期长达数年。
### 隐藏的引擎:报告要求
NDAA中真正对行政部门产生持续压力的,往往不是高额的授权数字,而是**强制性的报告要求**。例如要求国防部定期提交中国军力报告、台海冲突兵棋推演结果、对台军售交付延迟评估等。
**分析含义**:报告要求是国会监督的『牙齿』。它迫使行政官僚体系必须将台海议题纳入常规议程,且必须留下书面记录。这些报告本身成为后续立法的事实基础,形成『报告→立法→报告』的正向循环。
---
## 跨党派立法的常态:异质逻辑的汇流
### 事实层:压倒性多数的表象
从TRA的制定,到近年各项涉台法案的通过,几乎均以压倒性多数甚至全票通过。涉台立法是当前美国国会极少数不受党派极化影响的领域。
**脉络层**:这种跨党派传统的本质,是一种『负面共识』——两党未必对台湾的最终政治地位有共识,但对『不能让北京以武力或胁迫改变现状』有高度一致。两党支持台湾的驱动力异质但互补:
| 党派 | 支持台湾的核心驱动力 | 功能侧重 |
|---|---|---|
| 共和党 | 大国竞争、反共意识形态、军事威慑 | 军售加速、战略定位提升、对北京强硬 |
| 民主党 | 民主价值同盟、人权、多边主义 | 国际空间支持、非传统安全合作、叙事绑定 |
**分析含义**:分析时,切勿将『跨党派支持』等同于『两党对台目标完全一致』。这种支持是异质逻辑的汇流,意味着当议题从『防卫台湾』转向『承认台湾』时,跨党派共识可能瞬间瓦解。此模式的有效性仅限于『维持现状与威慑』区间。
详见 `entities/foreign_actors/us_congress_taiwan_caucus.md`。
---
## 立法 vs 执行的差距:体系的内在张力
### 事实层:法律文本的强制性与行政裁量权的弹性
涉台立法体系面临的最大结构性挑战,是立法文本的『应』与行政执行的『实』之间的落差。国会通过法律要求行政部门采取行动,但具体如何行动、何时行动、行动到何种程度,行政部门拥有极大的裁量权。
**脉络层**:这种落差不是制度的缺陷,而是美国三权分立的设计特征。国会负责设定政策方向与底线,行政部门负责执行并承担实际的政治与军事风险。在涉台议题上,这种风险极高,因此行政部门的避险倾向也极强。
**分析含义**:此落差构成了涉台立法体系的『弹性空间』。当看到一项新的涉台法案通过时,分析者应立即追问:此法案是强制性还是指示性?是否有拨款配套?行政部门有哪些合规选项?若行政部门选择最低限度合规,是否违法?多数情况下,最低限度合规完全合法——这是立法与执行差距的制度根源。
详见 `narratives/us_taiwan/us_actual_policy_record.md`。
---
## 川普 2.0 时期的立法约束作用:防火墙与漏洞
### 事实层:立法绑定与交易主义的对抗
川普2.0时期是检验涉台立法体系约束力的关键场景。川普的交易主义倾向与对盟友的怀疑态度,使得国会立法成为防范美台关系被『交易』的核心防火墙。
**脉络层**:国会通过NDAA等已立法的框架,川普若想削减对台军售或停止军事合作,需面临违反国内法的法律风险与国会的强烈反弹。在反中仍是两党共识的背景下,『出卖台湾』将是极具政治毒性的标签。
### 约束的局限:『声明遵守 + 实质偏移』的漏洞
**破除常见简化**:认为国会立法能完全锁死川普是错误的。行政部门拥有极大的裁量权。川普可以『声明遵守法律』,但在执行层面采取以下偏移:
- **拖延战术**:军售批准但交付无限期延后。
- **降级互动**:不取消交流但层级大幅降低。
- **预算挪用**:虽授权对台军援,但在拨款环节制造障碍。
**分析含义**:在川普2.0时期,国会涉台支持模式的效力,不取决于法律文本的完善度,而取决于国会监督的强度与行政官僚体系的抵抗意志。当总统有意规避时,法律本身无法自动执行。分析者应将注意力从『法律是否允许』转向『官僚是否执行』。
---
## 未来潜在立法方向
### 从『赋权』向『拒止』的叙事偏移
国会涉台立法的论述重心,正从『支持台湾民主』向『威慑中国扩张』偏移。台湾在此叙事中逐渐从『目的』变为『手段』。
**事实层**:近期国会讨论的涉台立法,更多聚焦于对北京的制裁(如《台湾冲突阻遏法》),而非对台湾的承认(如更名法案)。
**脉络层**:这种偏移反映了跨党派共识的演变。『赋权台湾』容易触及主权红线,引发行政部门反弹与北京过度反应;『拒止中国』则完全符合两党反中共识,且风险可控。
**分析含义**:未来涉台立法的边际效用将递减。在NDAA已常态化包含涉台条款后,国会可用的升级工具正在减少。未来的立法若不触及主权定位(天花板),则仅能增加拨款额度或微调合作机制,其政治震撼力将下降。分析时,应关注立法工具从『象征性突破』向『制裁性工具』的转型。
---
## 国际比较:其他国家的涉台立法
### 事实层:美国体系的独特性
与美国相比,其他国家的涉台立法大多停留在决议或宣言层面,缺乏国内法化的制度性承诺:
| 国家 | 涉台立法/决议性质 | 约束力 |
|---|---|---|
| 美国 | 国内法(TRA + 单项法案 + NDAA) | 强制性(虽执行有弹性) |
| 欧盟 | 议会决议(如支持台湾国际参与) | 无约束力 |
| 加拿大 | 议会动议(如支持台湾加入国际组织) | 无约束力 |
| 日本 | 安全保障法律体系中的『重要影响事态』 | 间接涉及,无直接涉台法 |
| 立陶宛 | 外交声明与实务合作 | 政策宣示 |
**脉络层**:美国涉台立法体系的独特性在于,它将台湾问题『国内法化』。TRA不仅是对台承诺,更是对美国行政部门的持续要求。这种国内法化使得美台关系具有了其他非邦交关系所不具备的制度韧性。
**分析含义**:其他国家的涉台支持更多依赖执政党的政治意愿,可随选举结果一夜翻转。而美国的涉台支持则需国会立法才能根本改变,其稳定性远高于其他国家。分析时,应将美国的涉台立法视为一种『制度性过度承诺』——它不仅约束了对手,也约束了自己。
---
## 关联分析
### 关联一:与TRA基础概念的层次关系
- **关联文件**:`primitives/concepts/taiwan_relations_act.md`
- **功能**:提供事实层与法理基础。本档案分析的后续立法体系,均是在TRA框架上的层叠与扩展。理解TRA的『国内法性质』与『战略模糊设计』,是理解后续立法为何不断试图『填补模糊』的前提。
### 关联二:与国会支持模式的叙事互补
- **关联文件**:`narratives/us_taiwan/congressional_support_pattern.md`
- **功能**:提供脉络层与动态分析。本档案侧重法律文本与制度结构,该档案侧重政治叙事与行为模式。两者交叉验证,可揭示『法律文本的静态约束』与『政治行动的动态推力』如何共同塑造美台关系。
### 关联三:与台湾连线的组织载体
- **关联文件**:`entities/foreign_actors/us_congress_taiwan_caucus.md`
- **功能**:提供组织层支撑。涉台立法并非自发产生,而是由台湾连线协调提案、降低交易成本的组织产物。理解连线的运作,有助于判断未来立法的优先级与可行性。
### 关联四:与六项保证的配套关系
- **关联文件**:`primitives/concepts/six_assurances.md`
- **功能**:提供法理与政治的对照。TRA与后续立法是法律层,六项保证是政治承诺层。六项保证比TRA更易调整,但历届政府均重申。分析时需区分法律约束与政治约束的力度差异。
### 关联五:与美方实际政策记录的验证
- **关联文件**:`narratives/us_taiwan/us_actual_policy_record.md`
- **功能**:用于交叉验证。本档案详述法律文本的要求,该档案记录实际执行的情况。两者的落差是分析『立法vs执行差距』的核心数据来源。若仅有法律文本而无执行记录,则立法仅具象征意义。
### 关联六:与战略模糊概念的结构张力
- **关联文件**:`primitives/concepts/strategic_ambiguity.md`(占位)
- **功能**:提供结构性对照。涉台立法体系的内在趋势是走向战略清晰(通过法律明确支持),这与行政部门的战略模糊存在根本张力。理解此张力,是判断未来美台关系走向的关键。
---
## 常见的分析陷阱
### 陷阱一:将授权等同于拨款
**错误**:看到NDAA授权100亿美元对台军事融资,便断定台湾将获得100亿美元。
**修正**:授权是许可,拨款才是真金白银。必须追踪后续的拨款法案是否实际拨付,以及拨付的节奏与附加条件。『授权100亿,拨付10亿』是常态,而非例外。
### 陷阱二:高估跨党派共识的深度
**错误**:认为两党在涉台议题上完全一致,不会出现倒戈。
**修正**:跨党派是『负面共识』(反北京胁迫),不是『正面共识』(挺台湾独立)。当议题从防卫转向主权承认,或当涉台支持与国内核心利益(如避免战争)冲突时,跨党派共识会迅速收窄。
### 陷阱三:将立法文本等同于政策现实
**错误**:看到法案要求行政部门采取行动,便假设行动必然发生。
**修正**:立法是下限,不是上限。行政部门可通过最低限度合规、拖延战术、降级执行等方式,在不违法的前提下实质偏移立法意图。必须追踪行政部门的合规性响应,而非仅看法律文本。
### 陷阱四:忽略国会与行政部门的共谋面
**错误**:将国会与行政部门视为单纯的对抗关系。
**修正**:国会常扮演『黑脸』,为行政部门提供与北京谈判的筹码。行政部门有时故意不阻挡国会的激进立法,是为了向北京展示国内压力。分析时必须识别这种红白脸的共生关系。
### 陷阱五:假设川普2.0会被国会完全约束
**错误**:认为NDAA等法律锁死了川普,他无法在对台议题上做出让步。
**修正**:法律无法自动执行。川普可通过拖延交付、降级互动、预算操控等方式,在『不违法』的前提下实质偏移对台支持。国会的约束力取决于其监督意愿与政治惩罚能力。
### 陷阱六:将主权象征条款的删除视为挫败
**错误**:认为《台湾政策法》中更名条款被删除,代表国会对台支持倒退。
**修正**:删除象征条款是国会向战略现实让步的理性选择。实质安全条款(如MNNA、军事融资)的保留,才是真正改变台海军事平衡的硬核。象征条款的牺牲,换取了实质条款的顺利通过。这是制度运作的务实逻辑,而非挫败。
---
## 仍不确定的部分
- **MAGA派全面主导共和党后的涉台立法走向**:当前跨党派共识仍依赖传统共和党建制派的支持。若2028年后国会共和党核心完全MAGA化,『美国第一』的孤立主义是否会压倒『对中强硬』的战略考量,仍不确定。
- **国会监督行政偏移的实际能力**:若川普2.0采取『声明遵守+实质偏移』策略,国会是否有足够的政治动能与制度工具进行有效反制,尚未经实战检验。
- **北京反制对立法节奏的冲击**:北京的制裁与威慑是否会在长期内产生寒蝉效应,降低议员参与涉台议题的意愿,目前数据不足以定论。
- **从『赋权』向『拒止』转型的长期影响**:若涉台立法越来越聚焦于制裁北京而非支持台湾,台湾在美方战略叙事中的主体性是否会被削弱,仍需观察。
- **非传统安全领域的立法空白**:当前立法体系仍以传统军事安全为主,对经济胁迫、认知作战、半导体供应链等非传统安全领域的法律覆盖仍显不足,未来立法方向不确定。
---
## 与其他文件的关系
- 台湾关系法:`primitives/concepts/taiwan_relations_act.md` —— 提供基座层法理基础
- 国会支持模式:`narratives/us_taiwan/congressional_support_pattern.md` —— 提供政治叙事与行为模式
- 国会台湾连线:`entities/foreign_actors/us_congress_taiwan_caucus.md` —— 提供组织载体与运作细节
- 六项保证:`primitives/concepts/six_assurances.md` —— 提供政治承诺层的配套对照
- 美方实际政策记录:`narratives/us_taiwan/us_actual_policy_record.md` —— 提供执行面的事实验证
- 战略模糊:`primitives/concepts/strategic_ambiguity.md`(占位) —— 提供立法清晰化与行政模糊化的结构张力对照
---
## 一句话浓缩
> 美国涉台立法体系是以TRA为基座的层叠结构——它通过单项法案细化模糊地带,通过NDAA注入实质资源,以跨党派的负面共识为动力,不断抬高对台支持的制度下限;但其真实效力始终受制于『立法授权与行政执行』的落差,在川普2.0的交易主义面前,它是不可或缺的防火墙,却绝非不可穿透的铜墙铁壁。
---
last_updated: 2026-05-08
@@ -0,0 +1,338 @@
# 司法院(含宪法法庭)
---
**类型**: 机构档案
**当前角色**: 台湾司法体系最高机关、宪法解释与违宪审查核心
**何时调用**: 分析宪政争议、大法官提名攻防、国会改革后续、司法政治化争议、行政立法冲突的司法解决、2028 前可能的法律战与选举争议
---
## 一句话浓缩
司法院(含宪法法庭)是台湾宪政体制的**最终仲裁者**——2024 年后,在「朝小野大」格局下,宪法法庭从「法律争议解决机制」转变为「行政立法对抗的终极战场」,其判决直接否决在野党立法成果,导致自身陷入「司法独立 vs. 执政打手」的政治化漩涡;而大法官的提名与同意权行使,更成为立法院控制未来宪政走向的抓手。
---
## 法律地位与制度框架
### 司法院的宪政性质
司法院依据《中华民国宪法》设立,是台湾司法体系的最高机关。其核心特征在于**「审判权与规则制定权的混合体」**:
| 制度特征 | 台湾实践 |
|---|---|
| 宪法定位 | 五权宪法下的司法权最高机关(第 77 条) |
| 实质功能 | 掌理民事、刑事、行政诉讼审判及公务员惩戒 |
| 核心权力 | 解释宪法、统一解释法律与命令(违宪审查权) |
| 组织属性 | 既是行政机关(司法院本部),也是司法审判机关(辖下各法院) |
**分析含义**:台湾的司法院制度存在**「审判权与司法行政权的内在张力」**——司法院既是最高司法行政机关(管理法院、预算、人事),又通过大法官行使宪法审判权。这种「球员兼裁判」的结构在宪政争议中常被质疑:司法院本部的行政决策是否影响审判独立?
### 从「大法官会议」到「宪法法庭」
2022 年 1 月 4 日,《宪法诉讼法》正式施行,这是台湾违宪审查制度的结构性转型:
| 维度 | 旧制:大法官会议(1948-2021) | 新制:宪法法庭(2022-) |
|---|---|---|
| 法律依据 | 《司法院大法官审理案件法》 | 《宪法诉讼法》 |
| 运作形式 | 会议制(不公开言词辩论为原则) | 法庭制(公开言词辩论为原则) |
| 解释文书 | 「释字第 X 号解释」(无主笔大法官揭示) | 「宪法法庭判决」(主笔大法官揭示) |
| 审查程序 | 抽象审查为主,门坎模糊 | 引入「宪法法庭」友庭、法庭之友制度 |
| 裁判效力 | 解释拘束全国各机关 | 判决拘束各机关,且具普通法院个案拘束力 |
**分析含义**:从「会议」到「法庭」的转型,不仅是程序变更,更是**正当性来源的转移**——旧制的正当性来自「大法官的集体权威」,新制试图通过公开辩论与判决书透明化,建立「程序理性」的正当性。但在政治高度极化的 2024 年后,这种程序理性正面临「政治化批评」的严峻挑战。
---
## 组织结构
### 司法院核心层
| 职位 | 人数 | 产生方式 | 任期 | 备注 |
|---|---|---|---|---|
| 院长 | 1 | 总统提名,立法院同意 | 无任期保障(随政党轮替有请辞惯例) | 综理院务,但**不参与**宪法法庭评议 |
| 副院长 | 1 | 总统提名,立法院同意 | 同上 | 辅助院长,**不参与**宪法法庭评议 |
| 大法官 | 15 | 总统提名,立法院同意 | 8 年,不得连任 | 组成宪法法庭,行使违宪审查权 |
**破除常见简化**:将「司法院院长」等同于「宪法法庭庭长」是错误的。院长、副院长虽为大法官会议主席(旧制),但在新制下,宪法法庭的审判长由大法官互选产生(目前实务上仍多由院长担任审判长,但其表决权重与其他大法官相同,且院长不参与评议的误解需澄清——**院长若为大法官,仍参与评议**,但 2022 年后院长、副院长是否具大法官身分,视任命时是否同时任命为大法官而定。2024 年后,院长与副院长均具大法官身分并参与宪法法庭审判)。
### 大法官的组成与分组
宪法法庭由 15 名大法官组成,审理案件需有**10 名以上大法官出席**,决议需出席人**三分之二以上同意**(即至少 8 票)才能宣告法律违宪。
**分析含义**:这个「三分之二」门坎是理解 2024 年后宪法法庭运作的关键——当大法官因任期届满或未补齐而不足额时,出席门坎与同意门坎的交互作用,将直接影响判决结果。在野党若通过同意权杯葛,使大法官长期不足额,宪法法庭可能陷入**「无法运作」或「仅能作成不受理决议」的瘫痪状态**。
---
## 大法官任命与政治考量
### 提名权与同意权的制度设计
大法官由总统提名,经立法院同意后任命。这个看似单纯的程序,在 2024 年后成为**朝野攻防的核心战场**:
1. **总统的提名权**:总统有权提名大法官人选,这是行政权对司法权构成的最直接影响力。
2. **立法院的同意权**:立法院可以否决总统提名的人选,这是立法权对司法权的反制。
详见 `entities/people/lai_ching_te.md`(提名权行使)与 `entities/organizations/legislative_yuan.md`(同意权行使)。
### 交错任期制度的崩溃
2007 年修法后,大法官采「交错任期」制度:15 名大法官分三组,每组 5 人,每 4 年改任 5 人。设计目的是**避免单一总统任命所有大法官**,维持司法独立性。
但这个制度在蔡英文时期(2020-2024)因政治运作而实质崩溃:
| 时间 | 事件 | 结果 |
|---|---|---|
| 2019 | 蔡英文提名 5 名大法官 | 2020 年就任 |
| 2022 | 蔡英文提名 4 名大法官(补足缺额) | 2022 年就任 |
| 2024 | 蔡英文任期尾声,15 名大法官中有 7 名任期届满 | 赖清德须提名 7 名新任大法官 |
**结构性命题**:交错任期制度的崩溃,使得 2024 年后的宪法法庭被在野党视为**「前朝政治遗产」**——所有 15 名大法官均由民进党籍总统(蔡英文、赖清德)提名。这种「单一政党完全掌控提名权」的现实,是宪法法庭政治化批评的结构性根源。
### 2024 年大法官提名攻防
2024 年,赖清德总统提名 7 名大法官,送交立法院行使同意权。在「朝小野大」格局下(KMT 52、DPP 51、TPP 8),在野党对提名名单进行严格审查:
- **KMT 立场**:质疑部分被提名人意识形态偏绿,要求退回部分名单
- **TPP 立场**:主张建立更透明的审查机制,对特定人选持保留态度
- **DPP 立场**:呼吁尊重总统提名权,尽快完成同意权行使
**分析含义**:大法官同意权已成为立法院**制衡总统权力的核心工具**。在野党通过否决或搁置提名,可以: (1) 阻止总统的司法影响力扩张; (2) 迫使总统提出在野党可接受的人选; (3) 若成功使大法官不足额,则可能瘫痪宪法法庭的运作能力。
---
## 近年关键宪法判决
### 国会改革判决(2024)
2024 年 7 月,宪法法庭针对立法院通过的「国会改革法案」(含总统国情报告、人事同意权、调查权、藐视国会罪等)作成 113 年宪判字第 9 号判决。这是 2024 年后最具政治冲击力的判决。
| 争议条文 | 宪法法庭判决 | 分析含义 |
|---|---|---|
| 总统国情报告 | 定期报告部分违宪;即问即答部分违宪 | 限缩立法院对总统的监督权 |
| 人事同意权审查 | 部分合宪、部分违宪 | 维持立法院人事审查权,但设下程序限制 |
| 国会调查权 | 有限度合宪,但限缩范围 | 确立立法院有调查权,但不得侵犯行政特权 |
| 藐视国会罪 | 大部分违宪 | 实质否决在野党最期待的制裁工具 |
**多视角并陈**:
- **执政党/支持者视角**:判决捍卫宪政分立,防止国会扩权破坏权力平衡
- **在野党/批评者视角**:判决是「执政党的司法保护伞」,否决了民意代表的监督工具,是司法介入政治斗争
- **法律学者视角**:判决在法理上有争议空间,特别是调查权的范围界定与权力分立原则的权衡
详见 `events/key_events/event_constitutional_court_ruling_2024.md` 与 `events/key_events/event_legislative_reform_controversy_2024.md`。
### 性别平权判决(2019 同婚释宪后续)
2019 年,司法院作成释字第 748 号解释,宣告民法未保障同性婚姻违宪,要求两年内修法。这是台湾司法能动主义的标志性判决。
**分析含义**:同婚判决展示了宪法法庭在**立法僵局时的替代性决策功能**——当立法院无法通过争议性法案时,宪法法庭的判决可以强制推动社会变革。但这种「司法能动」也埋下了「司法政治化」的种子:反对同婚的群体将大法官视为「脱离民意的精英」。
### 死刑存废判决(2024)
2024 年,宪法法庭作成 113 年宪判字第 8 号判决,宣告死刑制度**「有条件合宪」**——仅限于故意杀人且情节最严重之罪,且须符合最严密程序保障。
**分析含义**:死刑判决是宪法法庭在**社会共识与宪法价值之间寻求平衡**的典型案例。判决未直接废除死刑(避免与社会多数意见正面冲突),但通过严格限缩适用范围,实质上提高了死刑执行的难度。这种「渐进式司法造法」的策略,既是宪法法庭的自我保护,也是其政治智慧的展现。
---
## 在国会改革争议中的角色
### 从「仲裁者」到「参与者」的质变
2024 年的国会改革争议,使宪法法庭的角色发生质变:
1. **传统角色**:宪法法庭是「法律争议的最终仲裁者」,在政治争议中保持超然
2. **当代角色**:宪法法庭成为「政治冲突的参与者」——其判决直接决定了行政与立法的权力分配
**结构性内在矛盾**:宪法法庭的判决具有**「法律效力」与「政治效果」的双重性**。在国会改革判决中,宪法法庭的法律判断(限缩调查权)产生了直接的政治效果(削弱在野党的监督工具)。这种双重性使得宪法法庭无法避免被政治化——即使判决在法理上完全正确,其政治效果仍会引发争议。
### 判决后的政治反弹
国会改革判决后,在野党的反弹包括:
- **公开批评宪法法庭**:KMT 党团批评判决是「执政党的司法打手」
- **修法反制**:提出修改《宪法诉讼法》,提高判决门坎
- **同意权杯葛**:以否决大法官提名人选作为反制手段
**分析含义**:在野党的反制策略,从「批评判决内容」升级为「改变宪法法庭的运作规则」——这是一种**制度性反制**,旨在通过修改游戏规则来限缩宪法法庭的权力。这种策略若成功,将根本改变台湾的宪政权力平衡。
---
## 立法院与宪法法庭的张力
### 「政治问题」与「司法审查」的界线
立法院与宪法法庭的张力,核心在于**「政治问题」与「司法审查」的界线**:
- **立法院立场**:国会改革是「政治问题」,应由民意代表决定,不应由非民选的大法官干预
- **宪法法庭立场**:权力分立是宪法问题,大法官有违宪审查权,不受「政治问题原则」绝对限制
**分析含义**:这种张力在台湾的宪政体制中特别尖锐——因为大法官不是民选产生,但其判决可以否决民选立法院的立法。当宪法法庭频繁否决在野党的立法成果时,其**民主正当性**必然受到质疑。
### 《宪法诉讼法》修法攻防
2024 年后,在野党提出修改《宪法诉讼法》的方案,主要包括:
1. **提高出席门坎**:将大法官出席门坎从「10 人」提高至「12 人」
2. **提高决议门坎**:将宣告违宪的同意门坎从「出席三分之二」提高至「全体三分之二」(即 10 票)
3. **暂时处分限缩**:限缩宪法法庭作成暂时处分的权力
**分析含义**:这些修法方案的本质是**「通过程序手段改变实质结果」**——若出席门坎提高,当大法官不足额时,宪法法庭将无法运作;若决议门坎提高,宣告法律违宪的难度将大幅增加。这是立法权对司法权的**结构性反制**。
---
## 与行政、立法的宪政关系
### 三权互动的动态平衡
台湾的宪政体制(名义五权,实质三权)中,司法院与行政、立法的关系是**动态平衡**:
| 关系 | 互动机制 | 张力来源 |
|---|---|---|
| 司法 vs. 行政 | 违宪审查、行政诉讼 | 大法官否决行政命令/处分 |
| 司法 vs. 立法 | 违宪审查、法律统一解释 | 大法官否决立法成果 |
| 行政 vs. 立法 | 覆议、预算审议 | 在野党否决执政党法案 |
**分析含义**:在「朝小野大」格局下,行政权在立法院受挫后,倾向于寻求司法权(宪法法庭)的救济——这形成了**「行政-司法联盟对抗立法」的结构**。虽然这种「联盟」并非刻意结成,但客观效果是:宪法法庭成为行政权反制立法权的工具。
### 总统的间接影响力
总统虽无直接干预司法的权力,但通过**大法官提名权**,可以对宪法法庭的组成产生深远影响:
- 蔡英文 8 年任期内提名了所有 15 名大法官
- 赖清德上任后继续行使提名权
- 在野党质疑:所有大法官均由民进党籍总统提名,如何保证独立性?
**分析含义**:总统的提名权是理解宪法法庭「政治化」的关键——虽然大法官一旦上任后,其判决未必符合提名总统的期待(历史上不乏大法官作出让执政党失望的判决),但**「任命的印象」**已经足以削弱宪法法庭的政治公信力。
---
## 对 2028 政治议题的可能影响
### 选举争议的最终裁决者
2028 年总统与立委选举,可能出现以下需要宪法法庭介入的争议:
1. **当选无效之诉**:若选举结果争议极大,可能提起当选无效之诉
2. **选举罢免法争议**:罢免案的程序与实质争议
3. **政党违宪解散**:极端情况下,政党是否违宪的争议
**分析含义**:宪法法庭在选举争议中的角色,是**「民主的最后防线」**——但其公信力在 2024 年后已受损。若 2028 年选举争议提交至宪法法庭,其判决能否被社会接受,将是一个重大不确定因素。
### 两岸与国安立法的审查
2028 年前,立法院可能通过涉及两岸关系与国家安全的重大立法:
- **两岸监督条例**:可能由在野党推动较宽松版本
- **反渗透法修正**:可能由执政党推动较严格版本
- **国家安全法修正**:涉及言论自由与国家安全的平衡
这些立法若通过,极可能被提交至宪法法庭审查。宪法法庭的判决将直接影响台湾的两岸政策走向。
**分析含义**:宪法法庭在两岸议题上的判决,将面临**「国家安全 vs. 基本权利」的终极权衡**——这种权衡不仅是法律问题,更是政治选择。宪法法庭的判断,将实质决定台湾在两岸冷对抗时期的法律边界。
---
## 关联分析
### 关联一:与立法院的宪政张力
立法院是宪法法庭判决的**直接受影响者**——国会改革判决否决了立法院的扩权立法。这种张力在 2024 年后达到高峰,在野党通过修法反制与同意权杯葛,试图限缩宪法法庭的权力。
功能:提供脉络层——理解宪法法庭判决的政治后果。
详见 `entities/organizations/legislative_yuan.md`。
### 关联二:与 2024 国会改革判决
2024 年的国会改革判决是理解当前宪法法庭政治化争议的**核心事件**。判决的实质效果是削弱在野党的监督工具,这直接引发了在野党的制度性反制。
功能:提供事实层——判决内容与政治反应的具体细节。
详见 `events/key_events/event_constitutional_court_ruling_2024.md`。
### 关联三:与 2024 国会改革争议
国会改革争议是宪法法庭判决的**前置事件**。立法院的立法冲突、行政院的覆议、公民团体的声请——这些过程构成了宪法法庭介入的背景。
功能:提供脉络层——理解宪法法庭为何介入以及介入的正当性争议。
详见 `events/key_events/event_legislative_reform_controversy_2024.md`。
### 关联四:与总统提名权
赖清德总统的大法官提名权,是理解宪法法庭组成的关键。在「朝小野大」格局下,提名权与同意权的拉锯,直接影响宪法法庭能否正常运作。
功能:用于交叉验证——总统的政治考量与立法院的反制策略。
详见 `entities/people/lai_ching_te.md`。
### 关联五:与行政院的互动
行政院是宪法法庭的**重要声请者**——当立法院通过行政院认为违宪的法案时,行政院可通过覆议失败后声请释宪的路径,寻求宪法法庭救济。这种「行政-司法」互动,是 2024 年后的重要宪政模式。
功能:提供事实层——理解宪法法庭案件的来源与政治动机。
### 关联六:与公民社会的连结
宪法法庭的「法庭之友」制度,为公民团体提供了直接参与宪法辩论的管道。在国会改革判决中,多个公民团体提交法庭之友意见书,试图影响判决结果。
功能:用于对照——公民社会的法律动员与政党的政治动员,如何共同塑造宪法法庭的运作环境。
---
## 常见的分析陷阱
### 陷阱一:把「大法官是某党的人」当结论
虽然所有现任大法官均由民进党籍总统提名,但**提名不等于控制**。大法官的判决常出现与执政党期待不符的结果(例如 2024 年死刑判决未直接废除死刑,让部分绿营支持者失望)。分析时应区分「任命的政治印象」与「判决的实质内容」。
### 陷阱二:假设宪法法庭判决能终结政治争议
宪法法庭的判决有法律终局性,但**不一定有政治终局性**。国会改革判决后,在野党转向修法反制与同意权杯葛——政治争议只是换了战场,并未消失。分析时应追踪判决后的政治动态,而非假设「判决出来就结束了」。
### 陷阱三:忽略《宪法诉讼法》的程序性攻防
在野党修改《宪法诉讼法》的方案(提高出席与决议门坎),其政治影响可能比任何单一判决都更深远。若修法成功,宪法法庭的运作能力将被根本性限缩。分析时应关注「游戏规则的改变」,而非仅关注「游戏结果」。
### 陷阱四:把司法院院长等同于宪法法庭
司法院院长虽具大法官身分并参与审判,但其主要角色是司法行政。将院长的政治立场等同于宪法法庭的立场,是过度简化。宪法法庭的判决是 15 名大法官的合议结果,非院长一人决定。
### 陷阱五:忽视「不足额」的系统性风险
若立法院持续否决大法官提名,导致大法官长期不足额,宪法法庭可能因无法达到出席门坎(10 人)而瘫痪。这种「制度性瘫痪」的风险,是 2024 年后台湾宪政体制的最大潜在危机之一。
### 陷阱六:用美国最高法院框架理解台湾宪法法庭
美国最高法院大法官为终身职,且由参议院简单多数同意;台湾大法官有任期限制(8 年),且须立法院过半同意。两者的政治运作逻辑根本不同——台湾的大法官提名更易受政党轮替与立法院席次结构的直接影响。
---
## 仍不确定的部分
- **大法官提名僵局的解决时机**:赖清德提名的大法官人选能否在 2025 年前通过立法院同意权,仍不确定。若持续僵持,宪法法庭的运作将面临实质困难。
- **《宪法诉讼法》修法的最终版本**:在野党提出的修法方案(提高门坎)能否通过,以及通过后宪法法庭如何适应,仍待观察。
- **宪法法庭在极端两岸危机中的角色**:若立法院通过涉及统独的极端法案(如变更国号、或接受一国两制),宪法法庭将如何审查,是未经测试的领域。
- **2028 选举争议的司法解决能力**:若 2028 年选举结果争议极大,宪法法庭的公信力是否足以支撑其判决被社会接受,仍不确定。
- **「暂时处分」的政治效应**:宪法法庭在国会改革案中首次广泛使用「暂时处分」冻结法律生效,此一工具的未来使用频率与政治反弹,尚待观察。
---
## 与其他文件的关系
- 2024 宪法法庭判决:`events/key_events/event_constitutional_court_ruling_2024.md` — 核心判决事实与政治反应
- 立法院档案:`entities/organizations/legislative_yuan.md` — 宪政张力与同意权行使
- 2024 国会改革争议:`events/key_events/event_legislative_reform_controversy_2024.md` — 判决前置事件脉络
- 赖清德档案:`entities/people/lai_ching_te.md` — 提名权行使与政治考量
---
## 一句话浓缩
> 司法院(含宪法法庭)是台湾宪政体制的**最终仲裁者**,但在 2024 年后的「朝小野大」格局中,它已从法律争议的解决者,转变为政治冲突的参与者——其判决直接否决在野党立法成果,引发在野党的制度性反制(修法限权、杯葛人事),使宪法法庭自身陷入「司法独立 vs. 执政打手」的政治化漩涡。分析宪法法庭,不能只看判决的法理逻辑,更要看大法官提名与《宪法诉讼法》修法的制度攻防——这些「游戏规则的改变」,才是决定台湾未来宪政走向的关键。
---
last_updated: 2026-05-08
@@ -0,0 +1,364 @@
# 立法院(机构概览)
---
**类型**: 机构档案
**当前角色**: 台湾最高立法机关
**何时调用**: 分析台湾立法制度、国会运作机制、宪政体制设计、立法流程、委员会与党团制度、预算审查结构、半总统制下的行政立法关系
---
## 一句话浓缩
立法院是台湾半总统制下的最高立法机关,其制度设计在名义五权与实质三权间存在张力,且选举制度塑造了「两党主导、第三党杠杆」的席次结构,使得党团协商往往凌驾于委员会专业审查之上——理解立法院,必须看穿其「委员会中心主义」的文本设计,把握「党团协商决断」的实质运作。
---
## 与既有档案的分工说明
本档案(`entities/institutions/legislative_yuan_overview.md`)聚焦于立法院的**制度、组织与职能**——即「游戏规则」本身。
既有档案(`entities/organizations/legislative_yuan.md`)聚焦于立法院的**当代政治结构与攻防**——即 2024 年后「关键平衡」下的实际玩法。
分析立法院时,应先调用本档案理解制度框架与运作逻辑,再调用既有档案理解当前政治格局下的具体博弈。两份档案互为表里:制度塑造了博弈的空间,博弈也不断试探制度的边界。
---
## 法律地位与制度框架
### 半总统制下的立法机关
台湾采半总统制(双首长制),立法院在此体制中的定位与纯粹内阁制或总统制下的国会有根本差异:
| 制度特征 | 内阁制国会(如英国) | 总统制国会(如美国) | 台湾立法院 |
|---|---|---|---|
| 行政首长产生 | 由国会多数党领袖担任 | 人民直选,与国会无关 | 总统直选,行政院长由总统任命 |
| 倒阁与解散 | 互为对称(信任投票/解散国会) | 皆无 | 存在但不对称(倒阁门槛高,总统被动解散) |
| 行政立法关系 | 融合(执政党控制国会) | 分立(不同党派常态) | 摆荡(视总统与国会多数是否同党) |
| 法案提案权 | 以政府提案为主 | 议员提案为主 | 行政院与委员皆可提案,行政院主导预算 |
**分析含义**:台湾立法院的宪政角色是「摆荡」的——当总统与立法院多数同党时,立法院倾向「行政院橡皮图章」;当不同党时,立法院则成为「在野党对抗总统的堡垒」。这种摆荡性使得立法院的制度设计必须同时包含「配合执政」与「制衡在野」的双重机制,导致规则解释的弹性极大。
### 名义五权与实质三权的张力
立法院的法源来自《中华民国宪法》,该宪法原始设计为五权分立(行政、立法、司法、考试、监察),但在历经七次修宪(1991-2005)后,实质运作已趋近三权分立:
- **监察院**:1992年修宪后委员由总统提名、立法院同意,不再由省市议会选举;丧失民意基础,实质转化为「准司法」的弹劾与审计机关。
- **考试院**:人事与考铨权独立,但委员同样须经立法院同意,预算受立法院审查。
- **立法院**:取得对司法院、考试院、监察院的人事同意权,实质成为唯一民意机关。
**分析含义**:立法院在宪法文本上仍是「五权中的立法权」,但在实际运作上已具备「三权分立下国会的完整权限」——甚至更广(因握有考试与监察的人事同意权)。分析时若仅引用宪法本文,将严重低估立法院对其他宪政机关的控制力。
---
## 立法院组成:选举方式与席次结构
### 席次分配与选举制度
2005年第七次修宪后,立法院席次减半至113席,采「单一选区两票制」(并立制):
| 类别 | 席次 | 选举方式 | 当选门槛 |
|---|---|---|---|
| 区域立委 | 73席 | 单一选区相对多数决 | 该选区最高票 |
| 原住民立委 | 6席 | 复数选区单记不可让渡投票(3平地+3山地) | 该组别最高票 |
| 不分区及侨选立委 | 34席 | 政党比例代表制 | 政党票达5% |
**分析含义**:并立制意味着区域选票与政党选票分别计算,互不影响。这使得:
1. 区域立委选举倾向于两党对决(杜瓦杰法则),第三党难以在区域选区生存。
2. 不分区立委成为小党生存的关键——5%的政党票门槛是第三党的生死线。
3. 不分区立委的34席占全体约30%,足以让跨越门槛的第三党在立法院内拥有党团运作的筹码。
### 不分区立委的双重功能
不分区立委的制度设计本意是引入专业代表与弱势群体声音,但在实际政治运作中,其功能发生异化:
- **功能一:政党奖赏与安抚**:不分区名单常用于安排退居幕后的资深政治人物或辅选有功者,使其无需面对选举压力。
- **功能二:党纪铁票**:不分区立委的当选完全依赖政党名单,无选区压力,因此在党团表决时受党纪约束最强,是各党总召最可依赖的投票部队。
- **功能三:专业象征**:名单中常纳入少数学者或社运代表,以彰显政党价值,但这类委员在实质决策中影响力有限。
**结构性命题**:不分区立委的「党纪铁票」效应,使得立法院内的妥协空间被压缩。区域立委可能因选区利益而在特定议题上跑票,但不分区立委极少跑票——这强化了党团总召的集权,削弱了委员会中跨党派协商的可能性。
---
## 院长与副院长:职能与政治角色
### 院长的制度性权力
立法院院长看似仅为议事主持人,但其权力在制度运作中具有关键的「议程设定」功能:
1. **议程排定权**:决定哪些法案优先列入院会报告事项或讨论事项。
2. **程序裁决权**:对议事争议的裁定,虽可被覆议推翻,但在冲突当下具有决定性效果。
3. **协商召集权**:主持党团协商,决定协商的时机与形式。
4. **人事提名权**:参与院长协调的各委员会召委选举安排。
5. **纪律执行权**:对破坏议场秩序的委员,有权命令其退出或施以惩戒。
**分析含义**:院长权力本质上是「程序性权力」,但在高度对抗的立法院中,「程序即实质」。谁控制了议程,谁就控制了法案的生死与节奏。院长的中立性因此常受质疑——在朝野激烈对抗时,院长的每一次程序裁决都会被视为偏袒。
### 历任院长的政治演变
| 时期 | 院长 | 政党 | 政治特征 | 与行政权关系 |
|---|---|---|---|---|
| 1999-2016 | 王金平 | KMT | 「乔王」协商共主,蓝绿通吃 | 与马英九总统关系紧张(九月政争) |
| 2016-2020 | 苏嘉全 | DPP | 首位DPP院长,强调议事中立 | 配合执政党 |
| 2020-2024 | 游锡堃 | DPP | 资深政治人物,象征意义大于实质 | 配合执政党 |
| 2024- | 韩国瑜 | KMT | 民粹型政治人物转任院长,高度争议 | 朝野分立(总统DPP,院长KMT) |
**破除常见简化**:将韩国瑜视为「纯粹的政治性院长」是简化判断。韩国瑜的院长角色实际上暴露了立法院制度设计的深层矛盾——当院长来自与总统不同的政党时,其「程序裁决权」与「协商召集权」不再是行政权的润滑剂,而可能成为在野党对抗行政权的武器。详见 `entities/people/han_kuo_yu.md`。
---
## 委员会制度:文本设计与实质运作的落差
### 八个常设委员会
| 委员会 | 对应部会/议题 | 关键政治议题 |
|---|---|---|
| 内政 | 内政部、陆委会、NCC | 两岸条例、选制改革、NCC人事 |
| 外交及国防 | 外交部、国防部 | 军购预算、邦交国、两岸外交 |
| 经济 | 经济部、国发会、农委会 | 能源转型、产业政策、两岸经贸 |
| 财政 | 财政部、金管会 | 税制改革、金融监管、国债 |
| 教育及文化 | 教育部、文化部 | 课纲争议、高教资源分配 |
| 司法及法制 | 法务部、司法院 | 司法改革、国会改革、宪法诉讼法 |
| 社福及卫环 | 卫福部、劳动部、环保署 | 长照、健保改革、劳保破产危机 |
| 交通 | 交通部、通传会 | 轨道建设、5G频谱、观光政策 |
### 召委选举与「双头马车」现象
每个常设委员会设2名召集委员(召委),由委员会成员互选产生。
**关键制度特征**:由于立法院的政党比例分配,多数情况下两大党各取得一席召委,形成「双头马车」。
**分析含义**:「双头马车」导致委员会运作出现以下结构性问题:
1. **排案权分裂**:两名召委可各自排案,导致同一法案可能出现两个审查版本,或召委间互相抵制对方排案。
2. **散会权滥用**:召委有权宣布散会,当某党不愿审查某法案时,其召委可直接宣布散会,使委员会停摆。
3. **委员会审查空洞化**:当委员会因召委对抗而无法实质审查时,法案常以「保留送院会」处理,实质审查权转移至院会党团协商。
**洞察**:立法院的「委员会中心主义」是文本假象。在制度设计上,委员会应是最专业的法案审查场域;但在实际运作中,委员会常沦为政党对抗的第一线,专业审查功能被党团协商取代。这使得立法院的立法质量高度依赖党团协商的密室妥协,而非委员会的公开辩论。
### 程序委员会:隐形的守门人
程序委员会虽非常设委员会,但具有关键的议程控制功能:
- 决定法案是否列入院会报告事项
- 排定院会讨论事项的顺序
- 审查提案是否符合程序要件
**分析含义**:程序委员会是「法案的生死第一关」。在野党常通过程序委员会阻挡执政党提案排入院会,而执政党则试图突破程序委员会的封锁。程序委员会的攻防,往往比院会表决更早决定法案的命运。
---
## 党团运作与多党协商机制
### 党团的成立与地位
立法院党团的成立门槛为3人(须属同一政党)。党团在立法院内享有以下特权:
- 参与党团协商的权利
- 提案的权利(非党团成员提案须连署)
- 议场发言的优先权
- 召委选举的协商权
**分析含义**:3人的低门槛意味着即使是小型政党,只要跨越5%政党票门槛取得3席以上不分区立委,即可成立党团,享有与两大党对等的协商地位。这是TPP(8席)在2024年后能成为「关键少数」的制度基础。
### 党团协商(朝野协商)的制度化与异化
党团协商是台湾立法院最具特色、也最受争议的制度。其法源为《立法院职权行使法》第68-73条:
**制度设计**:
- 争议法案经院会表决前,可交付党团协商
- 协商由院长主持,各党团代表参与
- 协商结论须经全体党团签名
- 协商结论提报院会,不经讨论径付表决
- 协商冷冻期:协商结论提出后,须等待一定期间(原为4个月,2024年修法缩短)始得表决
**制度的原始意图**:提升立法效率,避免议会暴力,让少数党有参与决策的机会。
**制度的实际异化**:
1. **密室政治**:协商过程不公开,结论常是各党利益交换的结果,而非专业审查的产物。
2. **委员会架空**:法案在委员会审查数月,最终在党团协商中一夕翻盘。
3. **少数绑架多数**:只要有一党拒绝签字,协商就无法达成,实质上赋予小党否决权。
4. **拖延战术**:冷冻期成为在野党拖延法案的工具。
**分析含义**:2024年的国会改革争议,核心之一即针对党团协商的透明化与冷冻期缩短。详见 `events/key_events/event_legislative_reform_controversy_2024.md`。党团协商是理解台湾立法效率与质量的关键——它既是避免议场瘫痪的安全阀,也是牺牲立法透明度的黑箱。
---
## 立法流程:从提案到三读
### 标准立法流程
```
提案 → 程序委员会排案 → 一读(朗读标题) → 交付委员会审查 → 委员会询答/审查/公听会 → 委员会保留或送出 → 议事处整理条文 → 党团协商(如有争议) → 二读(逐条讨论/修正) → 三读(表决通过) → 总统公布
```
| 阶段 | 实质内容 | 政治操作点 |
|---|---|---|
| 一读 | 朗读标题,决定交付何委员会 | 程序委员会的排案是关键 |
| 委员会审查 | 询答、逐条审查、修正 | 召委排案权、散会权、保留送院会 |
| 党团协商 | 各党团代表密室协商 | 院长召集、签字同意、冷冻期 |
| 二读 | 逐条讨论、修正案表决 | 法案实质内容的决定点 |
| 三读 | 全案表决 | 原则上不再修正,仅做文字调整 |
### 复议:行政权的反制工具
行政院对立法院通过的法案,如认为窒碍难行,可经总统核可后移请立法院复议:
- 须于法案送达后15日内提出
- 复议期间立法院休会,总统得咨请召开临时会
- 立法院须在15日内作成决议
- **维持原决议门槛**:全体立委1/2以上(57席)
**破除常见简化**:将复议等同于「否决」是错误的。美国的总统否决是主动的,且需国会2/3绝对多数才能推翻;台湾的复议是被动的,且只需立法院1/2相对多数即可维持原决议。这意味着,只要在野党在立法院拥有过半席次,复议几乎注定失败。
**分析含义**:2024年后,KMT+TPP合计60席,远超57席门槛。行政院若提出复议,在数学上几乎不可能成功。复议在此格局下不再是有效的反制工具,而是政治表态——行政院借此向支持者宣示「我们尽力了」,但实质上无法改变立法结果。
### 司法审查:宪政体制的最后防线
当立法院通过的法律涉及违宪争议时,声请释宪成为最后的制衡手段。详见 `entities/institutions/judicial_yuan.md`。
2024年国会改革法案通过后,DPP党团与行政院等机关即声请释宪及暂时处分,这是立法院与司法院互动的最新案例。司法院的判决将实质界定立法院的职权边界。
---
## 预算审查机制:在野党的最大武器
### 预算案的特别流程
预算案的审查与法律案有显著差异:
1. **提案权专属**:行政院是预算案的唯一提案者,立委不能自行提出预算案(仅能提案删减或冻结)。
2. **一读后直接付委**:预算案不经程序委员会排案,一读后直接拨交各委员会联席审查。
3. **不能提复议**:2000年修宪后,预算案不适用复议程序(但实务上仍有争议空间)。
4. **限期完成**:会计年度开始前(1月1日)应完成审议。
### 删减、冻结与统删
立法院审查预算的主要工具:
- **删减**:直接删除某项预算额度,行政机关不得动支。
- **冻结**:预算额度保留,但行政机关须满足特定条件(如提出报告、经委员会同意)始得动支。这是最常用的工具,因既保留谈判筹码,又不致完全瘫痪政务。
- **统删**:针对同一类别的预算(如出国费、委办费)统一删减一定比例,是快速削减预算的手段。
**分析含义**:预算审查是立法院制衡行政权最实质、最直接的武器。法律案的通过需要漫长的协商与表决,但预算冻结只需委员会召委配合即可达成。在朝野对立时期,在野党常以冻结特定部会预算作为政策谈判筹码,迫使行政院在特定议题上让步。
---
## 国际比较:半总统制下的国会困境
### 与议会内阁制的比较
在内阁制(如英国、日本)下,行政权与立法权融合,首相由国会多数党领袖担任,国会多数党即执政党。若执政党失去多数,必须解散国会重新选举或更换首相。
台湾的半总统制打破了这种融合:
- 总统有固定任期,不因立法院多数易主而下台
- 行政院长由总统任命,虽对立法院负责,但不须立法院同意
- 立法院缺乏有效的倒阁诱因(倒阁后总统可解散立法院,立委面临失业风险)
**结构性命题**:台湾的半总统制创造了一种「不对称僵局」——当总统与立法院多数不同党时,行政权与立法权陷入结构性对抗,但制度缺乏有效的解套机制。内阁制有倒阁/解散的对称设计,总统制有否决/覆决的制衡,但台湾的半总统制在朝野分立时,只能依赖非正式的党团协商或议场物理冲突来消化僵局。
### 与美国总统制的比较
美国总统制下,国会与总统分属不同政党是常态(分裂政府),制度设计有丰富的制衡与妥协机制(如否决权、冗长辩论、委员会专业审查)。
台湾立法院与美国的根本差异在于:
1. **党纪强度**:台湾党纪远强于美国,跨党派投票极少,妥协空间小。
2. **委员会专业度**:美国委员会有庞大专业幕僚,台湾委员会幕僚资源匮乏,审查质量受限。
3. **协商文化**:美国依赖委员会与院会公开协商,台湾依赖党团密室协商。
**分析含义**:直接将美国国会的运作逻辑套用于台湾立法院,会导致严重误判。台湾立法院的决策核心不在委员会,而在党团;妥协机制不在公开辩论,而在密室协商。
---
## 关联分析
### 关联一:与行政院的预算与法案博弈
立法院与行政院的关系是台湾宪政运作的主轴。行政院掌握预算提案权与法案提案权,立法院掌握审议权与删减权。在朝野分立时期,这种关系从「配合」转为「对抗」,预算冻结与复议成为双方的主要武器。
**功能**:提供行政立法关系的制度脉络。
### 关联二:与司法院的职权界定争议
立法院的职权行使边界,最终由司法院(大法官)界定。2024年国会改革争议的实质,即是立法院的调查权、听证权与人事同意权行使方式的合宪性争议。详见 `entities/institutions/judicial_yuan.md`。
**功能**:提供宪政争议的最终裁决机制脉络。
### 关联三:与韩国瑜院长的议程控制
韩国瑜的院长角色,在2024年后的朝野分立格局中,其程序裁决权与协商召集权成为关键变量。其主持风格与议程安排,直接影响法案审议节奏。详见 `entities/people/han_kuo_yu.md`。
**功能**:提供当前院长角色的个人化变量分析。
### 关联四:与2024年国会改革争议的制度根源
2024年的国会改革争议,表面是法案内容的对抗,深层是立法院制度设计的结构性问题——委员会审查空洞化、党团协商黑箱化、复议机制失效。这些制度缺陷是争议爆发的温床。详见 `events/key_events/event_legislative_reform_controversy_2024.md`。
**功能**:提供当前重大事件的结构性解释。
### 关联五:与选举制度的互动
单一选区两票制塑造了立法院的席次结构,席次结构决定了党团协商的筹码分配。任何选举制度的变革(如选区重划、门槛调整、联立制改采)都将根本性地改变立法院的运作逻辑。
**功能**:提供制度变迁的因果脉络。
### 关联六:与 `entities/organizations/legislative_yuan.md` 的互补
本档案提供制度框架,既有档案提供当前政治格局。分析具体法案或争议时,需同时调用两份档案,理解制度规则与政治博弈的交互作用。
**功能**:提供分析工具的分工与整合指引。
---
## 常见的分析陷阱
### 陷阱一:把委员会当成立法核心
台湾立法院的委员会审查常因政党对抗而停摆,实质决策转移至党团协商。只看委员会的排案与审查,会错过真正的决策场域。
### 陷阱二:把复议当成有效的反制工具
在野党掌握过半席次时,复议在数学上注定失败。将行政院提出复议解读为「有效反制」,是误判政治现实——复议只是政治表态,不是实质工具。
### 陷阱三:忽略不分区立委的党纪效应
不分区立委因无选区压力,受党纪约束最强。分析投票行为时,若假设所有立委都受选区利益驱动,将严重误判不分区立委的投票模式。
### 陷阱四:用内阁制逻辑预期倒阁
台湾立法院缺乏倒阁的诱因——倒阁成功后总统可解散立法院,立委面临失业风险。用内阁制逻辑预期立法院以倒阁解决僵局,是制度误读。
### 陷阱五:把党团协商结论当成最终定案
党团协商结论虽具政治约束力,但仍须经过院会二读、三读表决。若协商结论引发强烈反弹,院会表决时仍可能翻盘。
### 陷阱六:假设院长完全中立
立法院院长虽应议事中立,但其议程排定权与程序裁决权具有实质政治效果。在朝野分立时期,院长的每一次程序决定都带有政治意涵,不能假设其完全超然。
---
## 仍不确定的部分
- **委员会中心主义能否重建**:2024年国会改革试图强化委员会听证权与调查权,但能否真正将实质审查权从党团协商转移回委员会,仍不确定。
- **党团协商的透明化成效**:冷冻期缩短与协商直播能否实质改变密室政治文化,或仅是形式合规,仍待观察。
- **预算冻结的实质影响边界**:在野党以冻结预算作为谈判筹码的极限何在?过度冻结是否会导致政务停摆与民意反噬,仍不确定。
- **司法院对国会职权的界定范围**:2024年国会改革释宪案的结果,将实质界定立法院的调查权与听证权边界,此为当前最大的制度不确定性。详见 `entities/institutions/judicial_yuan.md`。
- **选举制度变革的可能性**:单一选区两票制是否可能调整为联立制?5%门槛是否可能下调?这些变革将根本性改变立法院结构,但目前缺乏足够政治动力。
---
## 与其他文件的关系
- 立法院当代结构与攻防:`entities/organizations/legislative_yuan.md` — 互补档案,本档案提供制度框架,该档案提供当前政治格局
- 韩国瑜档案:`entities/people/han_kuo_yu.md` — 现任院长,其个人风格与议程控制影响制度运作
- 2024年国会改革争议:`events/key_events/event_legislative_reform_controversy_2024.md` — 制度缺陷爆发的关键事件
- 司法院档案:`entities/institutions/judicial_yuan.md` — 职权争议的最终裁决者
---
## 一句话浓缩
> 立法院是台湾半总统制下的最高立法机关,其制度设计在名义五权与实质三权间存在张力,且选举制度塑造了「两党主导、第三党杠杆」的席次结构,使得党团协商往往凌驾于委员会专业审查之上——理解立法院,必须看穿其「委员会中心主义」的文本设计,把握「党团协商决断」的实质运作,并认清半总统制下「不对称僵局」的结构性困境。
---
last_updated: 2025-05-20
@@ -0,0 +1,434 @@
# 国防部(机构概览)
---
**类型**: 机构档案
**当前角色**: 台湾军事政策核心执行机构、国防预算分配枢纽、对美军事合作对口、军政军令一元化制度载体
**何时调用**: 分析台湾国防政策走向、军购案结构与延迟、兵役改革、两岸军事态势升级、文人军政关系演变、美台军事合作实质、国防预算排挤效应、军种竞争与不对称作战阻力、全民国防体系建构
---
## 一句话浓缩
国防部是台湾安全体系的技术执行核心,但其内部并非单一行动者——部长、参谋本部、三军司令部各有利益与视角,文人控制与军人专业之间的张力塑造了当代国防决策的基本动力学;理解国防部的关键在于拆解其『多核心』结构,而非将其视为黑箱,特别是在『不对称作战』与『全民国防』的战略转型期,战略口号与军种利益的碰撞决定了政策的实质落地程度。
---
## 与既有档案的分工说明
本档案路径为 `entities/institutions/mnd.md`,议题分组为 `taiwan_security_defense`。
既有档案 `entities/organizations/mnd.md`(P1 优先级,分组为 `knowledge_expansion_long_tail`)与本档案存在路径与名称重叠。基于国防部在台湾安全防御体系中的核心地位,不应被归类为『长尾』知识。**建议后续知识库整合时,以本档案(institutions 路径)为基准合并,覆盖 organizations 路径下的旧档案,并将议题分组统一修正为 `taiwan_security_defense`。**本档案在旧档案基础上,深化了法律制度的内在矛盾、军种利益分配的微观机制、以及文人控制的实质局限,并强化了与 2028 前战略转型期的关联分析。
---
## 国防部的法律与制度性质
### 法律基础与双重法制
国防部的运作受两部核心法律框架制约,这两部法律之间存在微妙的制度张力:
- **《国防法》**:确立国防体制的基本原则,包含『军政军令一元化』、『军队国家化』、『文人领军』等宪政层次规范。
- **《国防部组织法》**:规范国防部的内部组织架构、职掌与编制,属于行政层次的执行细节。
**分析含义**:《国防法》赋予国防部长统合军政、军令、军备的广泛法律授权,但《国防部组织法》在具体组织设计上,仍保留了参谋本部与各军种司令部的庞大实体。这种『上位法原则性授权、下位法保留军人体系实体』的结构,是理解国防部『形式一元化、实质双轨』的法律根源。
### 『军政军令一元化』的制度设计与其局限
2000 年代《国防法》修订前,参谋总长直接指挥军队,国防部长实质上仅负责军政(行政、预算),形成『军政军令二元』。修法后确立『军政军令一元化』:
| 制度面向 | 一元化前(二元时期) | 一元化后(现行制度) |
|---------|---------|---------|
| 指挥权归属 | 参谋总长直接指挥三军 | 国防部长统合,参谋总长为幕僚长 |
| 参谋总长角色 | 实质指挥官,具军令权 | 国防部长之军事幕僚长,无独立指挥权 |
| 军令系统 | 独立于部长之外 | 纳入部长指挥链,受部长监督 |
| 文人控制 | 间接且薄弱 | 直接法律授权,制度性强化 |
**结构性命题**:『一元化』在法律上确立了文人控制,但在实务上,军人体系仍保有高度专业自主性。参谋总长虽法律上为幕僚长,但作为最高现役军官,其实质影响力建立在『作战专业不可替代性』上。文人部长若无军事背景,在作战计划、战术选择、军种人事等高度专业议题上,无法绕过参谋本部独立决策。这种『形式一元化、实质双轨』的特征,是理解国防部内部张力的基础。
### 国防部『不是什么』
- **不是纯粹文官机关**:核心决策层虽文人领军,但执行层与信息生产端高度军事化,文官体系在部本部占比极低。
- **不是单一行动者**:三军、参谋本部、军备局、政战局等各有独立预算诉求与组织利益,对外常发出矛盾信号。
- **不是美国国防部的直接对应**:美台无正式邦交,军事合作须经 AIT、TECRO 等多层中介,且台湾国防部在联合作战指挥机制(MND vs. Indo-Pacific Command)的对接上,缺乏正式制度管道。
---
## 组织结构:多核心体系与权力节点
### 部长与副部长:文人控制的顶端
**国防部长**:
- 法律地位:行政院长提名、总统任命,向立法院负责。
- 角色:军政、军令、军备三权统合,为国防部最高决策者。
- 现任:顾立雄(2024 迄今)——首位无军人背景的纯文人部长,此前任国安会秘书长。
- 关键权力:预算分配核定、上将人事案提名、政策方向最终决定、对外军事交流代表。
**军政副部长**:
- 角色:协助部长处理政策、立法、对外协调、预算等行政事务。
- 传统上由退役上将或资深文人出任,现任为柏鸿辉(文人背景)。
**军备副部长**:
- 角色:掌理军备采购、国防产业、武器研发与生产。
- 传统上由具工程或采购背景的退役上将出任,主导对美军购执行与国防自主项目。
**分析含义**:部长与副部长的组合反映『文人控制』与『军人专业』的平衡。顾立雄的任命标志着文人控制的深化,但也引发军人体系的调适问题——当部长缺乏军种背景时,三军司令在部务会报中的专业话语权反而可能上升,形成『部长管政策与预算、军种管战训与人事』的默契分治。详见 `entities/people/wellington_koo.md`。
### 参谋本部:军事专业的堡垒
参谋本部是国防部的『军事专业核心』,也是实质的作战规划中枢:
- **参谋总长**:法律上为部长之幕僚长,实质上为最高现役军官,掌握作战指挥的实质发动权与建议权。
- **副参谋总长**:通常二至三位,分管联合作战与战略规划。
- **各联参次长室**:联一(人事)、联二(情报)、联三(作战)、联四(后勤)、联五(计划)、联六(通信电子资讯)、联七(教育训练)。
- **实质角色**:作战计划拟定、演习规划、日常战备指挥、情报研判。
**分析含义**:参谋本部是国防部内『军人专业』的堡垒。虽然法律上从属于部长,但其专业权威让其在作战议题上有高度话语权。文人部长若缺乏军事背景,在危机时刻与作战议题上高度依赖参谋本部的信息供给与方案拟定——这种信息不对称,是文人控制最根本的脆弱点。详见 `entities/organizations/general_staff_taiwan.md`。
### 三军司令部:利益分配的实体
| 军种 | 司令部位置 | 核心利益与预算诉求 | 传统影响力与当代挑战 |
|------|-----------|---------|-----------|
| 陆军 | 桃园龙潭 | 地面防卫、兵员规模、传统装甲战力、后备动员 | 历史政治影响力最大(将领最多),但在『不对称作战』框架下面临角色边缘化危机 |
| 海军 | 台北大直 | 海域控制、潜舰国造、反封锁、反舰飞弹 | 技术门槛高、预算比重增加,潜舰为旗舰项目但预算排挤严重 |
| 空军 | 台北公馆 | 空域控制、战机采购、防空飞弹 | 预算比重高、与美方合作最密切,但在不对称逻辑下昂贵战机存续受质疑 |
**三军的利益差异与结构性冲突**:
- **陆军**:传统上政治影响力最大(退役转任公职、退辅会系统者众),但在『不对称作战』框架下,若资源转向机动飞弹与无人机,陆军传统装甲部队(如 M1A2 坦克)的正当性受挑战。陆军因此积极将『城镇战』与『后备动员』纳入自身主责,以维持兵员规模与预算份额。
- **海军**:反封锁与台海阻绝的核心,潜舰国造为跨党派共识的旗舰项目,但预算压力巨大,且排挤了其他水面舰的更新时程。
- **空军**:防空识别区(ADIZ)防卫主力,与美方合作最密切,但在不对称作战逻辑下,是否持续采购昂贵战机(如 F-16V)成为美方智库与台湾内部争议点。
**分析含义**:三军各有利益,国防预算分配是核心战场。『不对称作战』理念的推动,意味着资源重新分配——这触动三军的相对地位,因此产生结构性阻力。详见 `narratives/domestic/military_service_branches_tensions.md`。
### 关键附属机构
- **军备局**:国防产业、武器研发、采购执行,是对美军购的实质执行单位。
- **政治作战局**:军中政治教育、心战、保防,近年任务扩展至应对认知作战与假讯息,但在军队国家化后其政治监控角色仍具争议。
- **宪兵指挥官**:首都卫戍、军事司法、部分国内治安支援,在镇暴与反斩首任务中具特殊地位。
- **后备指挥部**:后备动员、教召执行。2022 年组织调整后降编为陆军下辖单位,引发『后备是否沦为陆军附庸』的争议。
---
## 文人 vs 军人张力:历史与当代
### 历史脉络:从军政强人到文人领军
- **1949-1987(戒严时期)**:军人是政权核心力量,参谋总长权力极大(如郝柏村时期),国防部长多为象征性角色。
- **1990-2000(民主化初期)**:军队国家化逐步推进,但军令系统仍由军人实质掌控。
- **2000-2008(首次政党轮替)**:文人控制经受测试,唐飞出任首位文人国防部长,但任期极短,显示军政体系磨合困难。
- **2008-2016(国民党执政)**:高华柱、严明等退役将领出任部长,文人控制议题相对缓和,但实质上仍是『军人转任文职』模式。
- **2016迄今(民进党长期执政)**:冯世宽、严德发、邱国正皆为退役上将,直至 2024 年顾立雄出任首位无军人背景的纯文人部长,文人控制进入新阶段。
### 顾立雄任命的结构性意义
2024 年顾立雄出任国防部长,不仅是人事更迭,更是制度运作逻辑的转换:
- **打破军种妥协惯例**:过去由退役上将出任部长,常需平衡三军利益(特别是陆军传统优势)。顾立雄无军种包袱,理论上可更强势推动资源重分配(如不对称作战)。
- **国安逻辑凌驾军种逻辑**:顾立雄来自国安会,其决策视角更贴近总统的战略意图与外交考量,而非单一军种的战训需求。
- **信息不对称的挑战**:缺乏军事背景,使顾在作战计划审定、演习验测等高度专业议题上,必须高度依赖参谋本部的判断,存在被『技术性包围』的风险。
**分析含义**:顾立雄的任命是文人控制的里程碑,但也带来新挑战——部长能否有效领导军人体系?军人体系如何调适?这种张力在推动改革时(如后备动员、联兵营调整)尤为明显。详见 `entities/people/wellington_koo.md`。
### 文人控制的『形式与实质』
| 维度 | 形式层面(法律与制度) | 实质层面(实务与运作) |
|------|---------|---------|
| 法律授权 | 部长统合军政军令军备 | 军人在作战议题上仍以『专业不可替代性』保有实质否决权 |
| 人事任命 | 部长核定上将人事 | 军人体系(参谋总长与军种司令)主导提名名单,部长多仅行使同意权 |
| 政策制定 | 部长核定政策方向 | 政策草案与选项多由参谋本部拟定,部长在有限选项中选择 |
| 预算分配 | 部长主导预算编列 | 各军种有各自争取管道,且预算执行具高度技术性,文官难以细部干预 |
**破除常见简化**:将『文人出任部长』等同于『文人控制已实现』是错误的。文人控制的实质,不在于部长是否为军人,而在于部长能否独立于军人体系的信息垄断,形成自己的战略判断与政策选项。顾立雄的挑战不在于他不懂军事,而在于他能否建立独立于参谋本部的政策评估能力。
---
## 与总统府、国安会的决策链
### 正式决策链与实质影响力
```
总统(三军统帅) → 国安会(战略协调与方向设定) → 行政院 → 国防部(政策与预算)
↓ ↓
国安局(情报支援) 参谋本部(作战执行)
```
**国安会的角色**:
- 国防政策的战略方向由国安会协调,国安会秘书长是总统与国防部之间的关键桥梁。
- 重大国防决策(如潜舰国造定案、不对称作战方针、重大军购建案)需经国安会。
- 在顾立雄曾任国安会秘书长的背景下,国安会与国防部的协作关系更为紧密,但也可能产生『国安会过度微观管理国防部』的疑虑。
### 实际决策流程:议题主导权的分化
| 议题类型 | 主导者 | 协调者 | 核定者 | 分析关注点 |
|---------|-------|-------|-------|-------|
| 日常战备与应对 | 参谋本部 | 国防部长 | — | 军人专业自主性最高,文人介入最少 |
| 年度国防预算 | 国防部(各军种提出) | 行政院、立法院 | 立法院 | 三军利益分配的年度战场 |
| 重大军购建案 | 国防部(军备局执行) | 国安会、行政院 | 总统 | 政治信号与军事需求的权衡 |
| 战略方针调整 | 国安会提出方向 | 国防部拟定 | 总统核定 | 文人战略与军人专业的碰撞点 |
| 危机应对 | 参谋本部执行 | 国安会协调 | 总统指挥 | 指挥链的顺畅度与信息传递速度 |
**分析含义**:国防决策不是单一环节——不同议题有不同的主导者。分析时要识别『这个议题由谁主导』。在顾立雄任内,国安会与国防部的界限趋于模糊,战略主导权上移至总统府与国安会,国防部更偏向执行端。
---
## 国防预算:分配结构与排挤效应
### 预算规模与结构
近年国防预算持续成长,但结构问题限制了战力转化效率:
- 2016 年约新台币 3,200 亿元
- 2024 年约新台币 4,400 亿元(不含特别预算)
- 特别预算(如潜舰国造、F-16V 采购、海空战力提升计划)另计,金额庞大。
**预算结构的三分法**:
| 项目 | 比例 | 说明 | 结构性问题 |
|------|------|------|-------|
| 人员维持 | 约 40-45% | 薪饷、退抚、福利 | 募兵制实施后人员成本居高不下,形成预算排挤 |
| 作业维持 | 约 25-30% | 训练、演习、后勤、油料 | 装备增加但维保预算未等比成长,导致妥善率下降 |
| 军事投资 | 约 25-30% | 装备采购、研发、建案 | 金额看似成长,但被昂贵平台(潜舰、战机)占据,排挤不对称装备 |
**结构性命题**:国防预算的成长,很大一部分被『人员维持费』与『昂贵传统平台采购』吸收,真正能用于不对称作战(机动飞弹、无人机、城镇战装备)的预算增量有限。这种『预算成长 ≠ 战力转型』的现象,是评估台湾防卫能力时最常被忽略的盲点。
### 特别预算的政治与财政功能
重大军购常以特别预算形式编列(如『海空战力提升计划采购特别预算』):
- **财政功能**:金额巨大,无法在年度预算中消化,且可规避《预算法》举债限制。
- **政治功能**:特别预算需立法院同意,具高度政治对抗性,常成为朝野攻防焦点。
- **分析含义**:特别预算是观察国防政策『优先级』的放大镜——哪些项目被列为特别预算,反映政治共识与战略急迫性。但特别预算的常态化,也隐含年度预算结构僵化、无法灵活调配的深层问题。
### 不对称作战预算的争议
『不对称作战』理念要求资源重新配置,但实际执行面临三军诠释分歧:
- **美方视角**:应大幅削减传统平台(坦克、战机)采购,将资源转移至大量机动飞弹、无人机与反舰武器。
- **陆军视角**:城镇战与后备动员所需装备(如刺针飞弹、标枪飞弹)即为不对称战力,但传统装甲仍为反登陆核心。
- **空军视角**:制空权是一切作战的前提,F-16V 即为不对称战力(以质胜量)。
- **海军视角**:潜舰与反舰飞弹即为不对称战力(以小博大、反封锁)。
**分析含义**:分析国防预算时,要看『不对称预算』是否真正成长,还是各军种将原有采购项目重新包装为『不对称』——这种『标签替换』是军种应对战略转型的常见策略。详见 `narratives/us_taiwan/porcupine_strategy.md`。
---
## 国防改革的当代议题
### 兵役制度:募兵制的困境与义务役的回归
**募兵制实施(2018 起)**:
- 目标:建立专业志愿役部队,提升人员素质与战技。
- 问题:招募不足、留营率偏低、成本高昂,导致基层军官与士官严重缺员。
- 影响:部队员额不足、后备战力堪忧,常备兵力无法满足战时需求。
**义务役延长(2024 起)**:
- 2004 年后出生役男,役期由 4 个月(军事训练役)延长至 1 年。
- 政治敏感性高:直接影响年轻选民,在野党批评为『将青年推向战场』。
- 军事意义:补充基层战力、强化后备动员基础,但 1 年役期是否足以培养合格战斗兵员,仍受质疑。
**分析含义**:兵役制度改革是国防议题中最具政治敏感性的——直接影响选民。分析时要区分『政策宣示』与『实际执行成效』,特别是 1 年期役男是否接受到符合不对称作战需求的训练(如反甲武器操作、城镇战),而非仅执行传统卫哨与公差勤务。
### 不对称作战的推动与阻力
『不对称作战』成为官方战略关键词,但推动过程存在结构性阻力:
- **概念**:以小博大、机动、低成本、高存活、量多价廉、让入侵者付出高昂代价。
- **具体项目**:刺针飞弹、标枪飞弹、海马斯多管火箭、反舰飞弹、无人机。
- **美方压力**:美方智库与官方长期推动,甚至质疑台湾采购传统平台的正当性。
**阻力来源**:
1. **军种利益**:不对称装备多属陆军与海军(海巡)范畴,空军的角色在不对称框架下相对边缘化。
2. **训练体系**:传统训练侧重平台操作(战机、舰艇),不对称装备需要不同的战术与指挥逻辑。
3. **后勤维保**:大量小型装备的后勤网络建立,比少量昂贵平台更为复杂。
详见 `narratives/us_taiwan/porcupine_strategy.md`。
### 全民防卫与后备动员
后备战力改革是近年焦点,但也是『容易宣示、难于落实』的领域:
- 后备指挥部组织调整(2022 年降编为陆军下辖单位),引发『后备沦为陆军附庸』争议。
- 教召延长至 14 天(新制教召),但教召品质与装备到位情况仍受质疑。
- 民防体系与军事后备的协调不足,地方政府与国防部在动员时的权责划分不清。
**分析含义**:后备改革的关键不在教召天数,而在『装备到位率』与『动员计划的可执行性』。分析时应追踪:教召部队是否领到实装?动员计划是否与地方民防体系对接?
### 国防自主与潜舰国造
潜舰国造是『国防自主』的旗舰项目,也是预算与政治风险最高的项目:
- 预算规模:逾新台币 4,000 亿元。
- 时程:首艘原型舰 2024 下水、2025 交舰(时程有延后风险)。
- 技术来源:多方技术协助(包括美方、欧洲厂商),但关键技术(如战斗系统、声纳)仍受制于人。
- 争议点:预算透明度、技术风险、政治介入、后续舰建造与否。
**分析含义**:潜舰国造是『国防自主』的象征——但也是预算黑洞。其成败将影响后续国防自主政策的可信度。若原型舰性能未达预期,将引发『国防自主是否可行』的根本性质疑,进而影响整体防卫战略的走向。
---
## 与美国国防部的合作管道
### 非官方但实质的合作
由于美台无正式邦交,军事合作须经特殊管道,这影响合作效率与信息流通:
**美国在台协会(AIT)**:
- 国防部与 AIT 安全合作组对接,是美台军事合作的『日常窗口』。
- 军售协调、训练合作、资讯分享皆经此管道。
**驻美军事代表团**:
- 台湾驻美军事代表团(隶属 TECRO)与美方沟通,负责华盛顿的军购协调与政策沟通。
**美国印太司令部**:
- 与国防部参谋本部的战术层级合作,演训观察、情报分享、联合训练规划。
### 军售流程与延迟问题
美台军售是核心合作领域,但流程冗长且近年出现严重延迟:
1. 台湾提出需求(需求会议)
2. 美方评估(国务院、国防部协调)
3. 国会通知(一定金额以上)
4. 发价书(LOA)签署
5. 交付与训练
**分析含义**:军售不是单一事件,而是长流程——从需求提出到交付可能历时数年甚至十年。近年因乌克兰战争导致美方产能排挤,台湾多项军购(如刺针飞弹)交付严重延迟。分析时必须区分『已签约』、『已编预算』与『已交付』的巨大落差。
---
## 对外公开发言机制与信息解读
### 国防部发言人系统
国防部设有发言人室,由文职或军职发言人对外说明。公开发言是重要观察窗口,但信息质量需谨慎解读:
- **军事态势说明**:对共机共舰扰台的例行公布,公布频率与详细程度具政治考量。
- **政策方向宣示**:国防改革、预算规划,多为原则性宣示,缺乏执行细节。
- **危机回应**:重大事件时的官方立场,旨在稳定民心与传递信号。
**分析含义**:国防部公开发言需与参谋本部协调——若发言内容与实际军情有落差,可能反映文人控制与军人专业之间的张力。分析国防部信息时,要看『什么被公布、什么没被公布』——后者往往更有意义。例如,共机扰台的架次公布,是否包含距离本岛的距离与机型?这些细节的增减,反映国防部对局势严重性的政治判断。
---
## 国防部在两岸军事压力中的角色
### 应对北京军事压力的核心机构
国防部是台湾应对北京军事压力的第一线执行者:
- **常态性应对**:共机共舰扰台的监控与应对,消耗国军作业维持费与装备寿期。
- **演训回应**:对北京军演的战备提升,如 2022 年裴洛西访台后的应对。
- **灰色地带应对**:无人机、民船、认知作战等新型态威胁,跨及政战局与文宣领域。
### 『守势战略』的制度体现
台湾国防政策在法律上为『守势战略』:
- 不主动挑衅、不发动第一击(重层吓阻、防卫固守)。
- 国防部是此战略的执行机构,在两岸军事互动中必须维持『不挑衅、不示弱』的平衡。
**分析含义**:『第一击』的定义权是关键——若将共机越过海峡中线视为第一击,则可能引发冲突升级;若仅视为骚扰,则可能被北京切香肠。国防部对『第一击』的诠释,直接决定两岸军事互动的门槛。
---
## 关联分析
### 关联一:与参谋本部的指挥链与信息链张力
参谋本部是国防部的军事专业核心,两者关系决定国防决策的实质走向。
- 功能:提供脉络层——理解国防部决策时,必须区分『部长的政治意志』与『参谋本部的专业判断』。当两者冲突时,参谋本部可能以『技术不可行』或『战备风险』为由,迟滞部长意志的执行。
- 详见 `entities/organizations/general_staff_taiwan.md`。
### 关联二:与军种张力的资源分配战
三军利益分配是国防部内部最核心的结构性矛盾,直接影响不对称作战的落实。
- 功能:用于对照——分析国防预算与军购案时,必须识别该案对哪一军种有利、对哪一军种构成威胁。例如,海马斯火箭的采购,实质上强化了陆军在联合打击中的角色,可能引发海军的疑虑。
- 详见 `narratives/domestic/military_service_branches_tensions.md`。
### 关联三:与『不对称作战』战略的执行落差
国防部是『不对称作战』战略的执行机构,但内部对如何落实有不同看法。
- 功能:用于交叉验证——将美方的『豪猪战略』期待与国防部的实际采购清单对比,可发现两者存在显著落差。台湾仍持续采购昂贵传统平台,显示军种利益与战略口号的脱节。
- 详见 `narratives/us_taiwan/porcupine_strategy.md`。
### 关联四:与顾立雄文人领导的磨合期
顾立雄的任命是理解当代国防部运作的关键变量。
- 功能:提供事实层——顾立雄的国安背景、与总统的信任关系、对军人的沟通方式,直接影响文人控制的实质成效。
- 详见 `entities/people/wellington_koo.md`。
### 关联五:与既有 P1 档案的重叠与整合
本档案与 `entities/organizations/mnd.md` 存在路径重叠。
- 功能:提供事实层——建议以本档案为基准合并,因国防部不应归类为长尾知识,且本档案深化了制度张力与利益分配的分析。
### 关联六:与总统府、国安会的战略指导关系
国安会是国防部在战略决策上的对口,决定国防政策的政治方向。
- 功能:提供脉络层——理解国防部的决策,不能仅看国防部内部,必须追踪国安会的战略意图。国安会与国防部的立场若不一致,政策推动将受阻。
---
## 常见的分析陷阱
### 陷阱一:把国防部当单一行动者
国防部内部有多核心——部长、参谋本部、三军各有立场。分析时要区分『谁在说什么』,避免将某一军种的立场等同于国防部的官方立场。
### 陷阱二:假设文人控制已完全实现
法律上文人控制已确立,但实质上军人专业仍高度自主。文人部长若无军事背景,在作战议题上高度依赖军人幕僚,存在信息不对称的风险。将『顾立雄出任部长』等同于『文人控制胜利』是过度简化。
### 陷阱三:忽略三军利益竞争对战略的扭曲
国防预算分配不是纯军事理性决策——三军各有利益,影响资源分配。不对称作战的推动受阻,部分原因在于军种利益。将战略调整的迟滞归因于『官僚主义』是浅层分析,必须看到具体的军种利益计算。
### 陷阱四:把预算成长等同于战力提升
国防预算成长,但人员维持费占比高,且采购交付有落差。预算数字不等同于即战力。分析时必须区分『已编预算』、『已签约』与『已交付且形成战力』三个阶段。
### 陷阱五:忽略美台军事合作的非官方性质与延迟问题
美台无正式邦交,军事合作须经 AIT、TECRO 等中介——这影响合作效率与信息流通。近年军售延迟严重,将『军购案通过』等同于『战力提升』是危险的误判。
### 陷阱六:将国防部的公开声明视为完整信息
国防部公开发言经过筛选,敏感信息不会公开。在两岸军事互动中,国防部选择公布哪些共机动向、隐瞒哪些动向,本身就是政治决策。分析时要看『什么没被说』。
---
## 仍不确定的部分
- **顾立雄文人领导的具体成效**:仍在观察期,文人控制与军人专业的磨合结果未定。特别是当面临重大危机时,顾能否有效指挥参谋本部,尚未经实战检验。
- **不对称作战的落实程度**:口号与实际采购、训练之间的落差难以量化。各军种将原有项目重新包装为『不对称』的现象,使得预算审查难以辨别真伪。
- **后备动员的实际效能**:教召改革刚起步,真正动员能力未经实战测试。装备到位率与动员计划的可执行性,仍属黑箱。
- **潜舰国造的后续发展**:技术风险与预算争议仍在,原型舰测试结果与后续舰建造决策存在不确定性。
- **2028 前国防改革的优先级**:选举考量可能影响改革节奏,特别是涉及兵役与后备的敏感议题,可能在选前趋缓。
- **军售延迟的长期影响**:美方产能不足导致的交付延迟,是否会造成台湾防卫空窗期,仍不确定。
---
## 与其他文件的关系
- 顾立雄档案:`entities/people/wellington_koo.md` —— 现任部长背景与政治角色,理解文人控制深化的关键
- 参谋本部档案:`entities/organizations/general_staff_taiwan.md` —— 国防部内军事专业核心,理解军令系统运作的必读
- 豪猪战略叙事:`narratives/us_taiwan/porcupine_strategy.md` —— 不对称作战的战略背景与美方期待
- 军种张力叙事:`narratives/domestic/military_service_branches_tensions.md` —— 三军利益分配与资源竞争的微观机制
- 既有国防部档案:`entities/organizations/mnd.md` —— P1 既有档案,建议以本档案为准进行合并
---
## 一句话浓缩
> 国防部不是单一行动者——部长、参谋本部、三军司令部各有利益与视角,文人控制与军人专业的张力塑造了当代国防决策的基本动力学;分析国防政策时,必须拆解『谁在什么议题上有话语权』——预算分配、人事升迁、作战计划各有不同主导者;国防改革(兵役、不对称作战、后备动员)容易宣示、难于落实——追踪执行进度与军种利益计算,比追踪政策口号更重要。
---
last_updated: 2026-05-08
@@ -0,0 +1,275 @@
# 中资在台湾媒体的影响(Chinese Capital Influence in Taiwan Media)
---
**类型**: 媒体生态结构档案
**当前角色**: 台湾媒体所有权与资金依赖的隐性结构分析框架
**何时调用**: 分析台湾媒体立场转向的结构性原因、追踪特定报导框架的资金源头、评估两岸资讯战中的媒体载体角色、解读 NCC 媒体并购与换照争议、识别「红媒」论述背后的归因等级、分析 2028 选战中的媒体不对称作战
---
## 一句话浓缩
中资对台湾媒体的影响并非单一的「收购接管」,而是一个从直接持股、广告依赖到市场准入人质的**结构性依赖光谱**;其中最深远的影响往往不需要北京的直接指令,而是媒体所有者为了保护其中国大陆商业利益所产生的**预期性顺从**——分析时必须区分「立场偏蓝」、「商业依赖」与「实质控制」三个不同等级,并严守归因的证据等级。
---
## 核心本体:影响形式的类型学
### 事实层:四种影响载体
中资或中国利益对台湾媒体的影响,并非只有「买下媒体」这一种形式。在实务上,它呈现为一个从硬到软的光谱:
| 影响形式 | 运作机制 | 法律管制难度 | 证据可及性 | 典型案例/现象 |
|---|---|---|---|---|
| **直接持股** | 透过离岸公司、第三地(如维京群岛、萨摩亚)或本土人头间接持有台湾媒体股权 | 极高(需穿透实质控制人认定) | 极低(商业机密保护) | 早期部分地下电台与特定小报的股权争议 |
| **广告收入依赖** | 中国本土企业、在台中资企业或高度依赖中国市场的台商,投放不成比例的广告预算 | 中等(商业行为难以限制) | 中等(可追踪公开标案与广告版面) | 特定媒体接收大量中国航空、观光、酒类广告 |
| **内容协议与新闻交换** | 与中国官媒或省级台签订「新闻合作协议」,接收预置的影音或文字专题 | 低(视为正常新闻交流) | 高(协议文本常公开或可追踪) | 旺报与新华社、海峡导报的论坛与合作机制 |
| **置入性行销与业配** | 以「专案」、「特刊」形式,包装中国政策宣传(如惠台措施、一带一路)为新闻 | 低(若未标明业配则难辨识) | 中等(版面配置与异常用语可识别) | 特定报纸整版报导中国省市招商,用语同官媒 |
### 脉络层:从「硬控制」到「软约束」的演化
2008 年旺旺集团收购中时集团是「硬控制」的标志性事件(详见 `entities/media/china_times.md`)。然而,随着台湾社会对中资警觉性提高以及 NCC 审查趋严,直接持股的边际效益递减且风险极高。当前的结构性趋势是向「软约束」移动:
1. **不需要买下媒体**:只要掌握媒体金主的核心利益(中国市场),就能产生约束力。
2. **不需要下达指令**:媒体所有者基于对自身商业利益的保护,会自动进行「预期性顺从」(Anticipatory Compliance),过滤对北京不利的新闻。
3. **不需要统一所有媒体**:只需在光谱的两端制造不对称的惩罚机制——亲北京者获得广告奖赏,反北京者面临商业制裁。
**结构性命题**:中资对台湾媒体的最大影响,不在于「它让媒体说了什么」,而在于「它让媒体不敢说什么」。这是一种基于市场理性的自我审查,比直接的政治指令更难察觉与归因。
### 分析含义
分析特定媒体的立场时,不要只寻找「中资持股」的硬证据。必须评估其所有权人的「利益结构」——如果所有权人的核心利润来源高度依赖中国市场的行政审批(如食品、地产、制造业),那么即使没有一分钱中资入股,该媒体的立场也会向北京倾斜。这是「市场准入人质效应」。
---
## 旺旺集团的中国市场依赖问题:范式案例
### 事实层:利益结构的硬约束
旺旺中时媒体集团是分析此议题的核心范式。其关键不在于蔡衍明是否「主观上认同北京」,而在于其商业帝国的客观结构:
- **营收结构**:旺旺集团(控股公司注册于开曼群岛,主要上市于香港)的绝大部分营收来自中国大陆的食品与饮料市场。
- **行政特许依赖**:食品制造业在中国涉及土地审批、环保评估、税务查核与食品安全检验,每一个环节都受制于地方政府的行政裁量权。
- **补贴与政策红利**:旺旺集团历年在中国多省获得过地方政府的税收优惠、土地补贴与「台企标杆」的政治荣誉。
- **媒体事业的财务地位**:相较于食品本业,媒体事业(中时、中天、旺报)在集团总营收中占比极小。媒体事业的政治功能远大于其商业盈利功能。
### 脉络层:作为「政治保险」的媒体事业
**中等模型自己想不到的洞察**:从纯商业逻辑看,在台湾经营亏损的媒体事业是不理性的。但若将媒体事业视为一种「政治保险费」,逻辑便完全通顺。蔡衍明收购并维持台湾媒体集团,向北京传递的信号是:**我愿意在台湾的舆论场上为您守住阵地,请您在大陆的市场准入与行政特许上予以关照。** 媒体不是赚钱的工具,而是保护核心商业利益的护身符。
这种结构解释了为何旺中集团的立场转向如此彻底且不可逆——因为一旦失去北京的政治信任,其在大陆的食品帝国可能面临系统性的行政风险。
### 分析含义
分析旺中系媒体时,**不要假设存在日常的「编辑台指令」**。更可能的运作机制是:高层设定不可逾越的红线(如不可批评习近平、不可支持台独、必须支持九二共识),而在红线之内,编辑台保有有限的日常操作空间。这种「红线管理」比逐条指令更有效率,也更难被外部查证。
---
## 广告主结构对内容的间接影响:隐性筛选机制
### 事实层:商业制裁与奖赏的不对称性
除了所有权层面的依赖,广告投放是更普遍、更隐蔽的影响机制。此机制不只作用于单一媒体,而是作用于整个媒体生态:
1. **抽广告(制裁)**:当某媒体报导对北京不利的内容(如揭露人权问题、支持特定候选人),在中国有利益的本土企业或中资背景企业会集体抽掉该媒体的广告。这种抽广告行动常是「默契」而非明令,企业基于自身安全自动配合。
2. **投广告(奖赏)**:对立场友好的媒体,投放不成比例的高单价广告(如中国各省市的观光招商、航空、白酒、地产广告)。这些广告常附带内容要求(如指定版面、指定新闻搭配)。
### 脉络层:寒蝉效应的经济学计算
**破除常见简化**:把「媒体不报导某事」直接等同于「北京下令封杀」是过度简化的。真实的运作逻辑是经济学上的「寒蝉效应」:
- 媒体经营者计算:报导 X 议题的收益(新闻专业声誉、小众读者认同)远小于成本(损失数百万年度广告预算、面临金主关切)。
- 在此计算下,即使没有中资背景,只要广告主结构中「中国敏感企业」占比够高,媒体就会自动规避敏感议题。
- 这种机制使得北京无需直接介入台湾媒体的编辑台,只需控制台湾大型广告主的中国市场命脉,就能间接过滤台湾的舆论场。
### 分析含义
实务分析时,若观察到某家非深蓝媒体在特定涉中负面新闻上异常低调或使用软化框架,不应立刻跳到「中资控制」的结论。应先检查该媒体近期的广告主名单——若其主力广告来自高度依赖中国市场的科技代工厂、航空业或金融业,则「广告主寒蝉效应」是更符合奥坎剃刀原则的解释。
---
## NCC 的监管与限制:法律防线与穿透难题
### 事实层:法规框架与执行落差
台湾对中资介入媒体的监管主要由《两岸人民关系条例》、《广播电视法》与《卫星广播电视法》构成,主管机关为 NCC(国家通讯传播委员会,详见 `entities/organizations/ncc_taiwan.md`)。
- **持股限制**:现行法规明文禁止大陆地区人民直接或间接持有台湾广播、电视事业之股份。
- **实质控制认定**:NCC 在审查并购或换照案时,会考量是否有「实质控制力」。
- **2020 中天换照案**:NCC 否决中天新闻台换照,部分理由涉及内部控管机制失灵与外部干预疑虑,但 NCC 在法律上并未直接以「中资媒体」名义撤销其执照,而是以新闻专业标准作为处分依据。
### 脉络层:穿透公司面纱的极限
NCC 监管面临的结构性困境在于「穿透难题」:
1. **法人与自然人分离**:蔡衍明是台湾公民,其个人投资媒体完全合法。NCC 无法仅因其「在中国赚钱」就撤销其媒体所有权。
2. **离岸架构**:中资常透过多层控股公司、境外信托或本土人头(名义股东)持股,资金追踪在实务上极为困难。
3. **政治干预的红线**:NCC 若过度积极追查「中资」,极易被批评为「政治迫害」、「打压言论自由」,引发社会对立。这使得 NCC 在执法上常处于被动,必须等待具体违规事证(如新闻造假、违反比例原则)才能出手,而难以针对「所有权人意图」开罚。
### 分析含义
分析 NCC 的监管行动时,必须区分「法律上的中资」与「实质上的中资影响」。NCC 只能处理前者,而后者是结构性问题。将 NCC 的不作为直接等同于「纵容中资」,忽略了其面临的法治限制与政治压力;但将 NCC 的监管行动等同于「绿营打压异己」,同样忽略了媒体所有权依赖敌国市场对国家资讯安全的客观威胁。
---
## 『红媒』论述与本议题的关系:标签的政治功能
### 事实层:论述的武器化
「红媒」(受中共控制或收买的媒体)标签在台湾政坛已被高度武器化。详见 `narratives/cross_strait/red_media_discourse.md`。
- **绿营/本土派视角**:「红媒」是客观存在的威胁,是中共对台认知作战的在地协力者,必须通过法律与群众运动(如反红媒游行)予以反制。
- **蓝营/统派视角**:「红媒」是绿营打压异己、制造寒蝉效应的政治标签,目的是垄断台湾的诠释权,将所有主张两岸交流的媒体污名化。
- **北京视角**:利用台湾的言论自由与内部矛盾,反控台湾政府实施「绿色恐怖」,打压主张统一的正当声音。
### 脉络层:标签的遮蔽效应
**结构性内在矛盾**:「红媒」标签在动员本土选民时极为有效,但在分析媒体行为时却极为有害。标签遮蔽了三个关键区别:
1. **「立场偏蓝」vs「受中资控制」**:许多媒体基于历史传统、受众结构或反绿情绪,自然产生亲北京框架,这不需要中资介入。
2. **「商业依赖」vs「政治效忠」**:媒体老板是为了保住中国生意而妥协,还是真心认同共产主义体制?两者行为表现可能一致,但动机与脆弱点完全不同。
3. **「框架共振」vs「协同作战」**:台湾媒体的「疑美论」与北京的「反霸权」叙事高度共振,但这可能是各自逻辑的产物,而非协调行动(详见 `playbooks/narrative_attribution_protocol.md`)。
### 分析含义
作为分析者,**严禁在分析报告中使用「红媒」作为分类标签**。应使用功能性描述,例如:「该媒体因所有权人的中国市场依赖,在涉两岸议题上呈现与北京高度一致的框架,且缺乏对北京政策的批判性报导」。这种描述虽然冗长,但能准确捕捉结构,避免标签带来的认知捷径与情绪偏见。
---
## 归因证据的等级:从结构动机到直接指令
### 事实层:证据力的光谱
在指控「中资影响媒体」时,证据的强度差异极大。分析时必须明确标示当前判断所依据的证据等级,避免将低等级证据推演为高等级结论。
| 证据等级 | 类型 | 描述 | 证明力 | 典型案例 |
|---|---|---|---|---|
| **Level 1: 结构动机** | 间接推断 | 所有权人在中国有重大商业利益,具备向北京靠拢的动机 | 极低(只能证明可能性,不能证明行为) | 旺旺集团的中国营收占比 |
| **Level 2: 框架同步** | 内容分析 | 媒体报导框架、用语与北京官媒高度一致,且偏离台湾主流共识 | 低至中(可能源于编辑立场或同温层效应) | 旺报使用「两岸融合」等北京官方词汇 |
| **Level 3: 资金关联** | 财务追踪 | 追踪到中资背景企业投放异常广告,或有关联企业的资金往来 | 中(证明商业利益输送,但不必然证明内容控制) | 特定中资企业在特定时间点大量投放广告 |
| **Level 4: 程序协调** | 行为模式 | 媒体在特定议题上的发稿时间、叙事角度与北京对台系统的操作节奏高度吻合,呈现协同行为(CIB)特征 | 中至高(需排除巧合,需大量数据比对) | 特定新闻在 PTT 与中时同步以特定框架扩散 |
| **Level 5: 直接指令** | 确凿证据 | 有内部文件、录音或吹哨者证明北京官员直接指示编辑方针 | 极高(实务上极难取得) | 2019 年蔡衍明受访时提及的「接受命令」说(但其后否认诠释) |
### 脉络层:归因的「证据鸿沟」
详见 `primitives/frameworks/disinformation_attribution.md`。在媒体所有权影响的分析中,最常出现的是「Level 1 + Level 2」的组合:有动机,且结果符合预期。但这在逻辑上不构成充分条件——因为「立场偏蓝」或「反绿」同样可以产生 Level 2 的结果。
**分析陷阱**:许多本土派分析者习惯将 Level 1 + Level 2 直接跳跃至 Level 5 的结论(「就是中共在控制」),这在逻辑上是无效的。而蓝营支持者则习惯用「缺乏 Level 5 证据」来完全否认 Level 1-4 的结构性影响,这同样是逃避现实。
### 分析含义
在撰写分析报告时,必须诚实面对「证据鸿沟」。对于旺中系以外的媒体,若只有 Level 1 的证据(如某网络媒体有中资背景的创投投资),绝不能推论其内容受北京控制;但可以合理推论其在中国敏感议题上会有较高概率的自我审查。
---
## 实务分析:如何识别可能的中资影响
### 应用一:利益结构穿透法
分析任何一家台湾媒体前,先建立其「利益结构图」:
1. 最终受益人是谁?其核心事业是否依赖中国市场的行政特许?
2. 主要广告主是谁?这些广告主的中国营收占比是否极高?
3. 是否有异常的业配专案(如突然大量承接中国省市观光专案)?
如果三个问题的答案都是肯定的,则该媒体在涉中议题上的可信度应被降级,视为「受市场准入约束的媒体」。
### 应用二:框架与信号词异常检测
建立该媒体的「信号词字典」(对照 `reference/code_words_and_euphemisms.md`)。当一家非传统蓝媒突然开始使用北京对台系统的标准婉辞时,需提高警觉:
- 将「总统」降级为「台湾地区领导人」或「蔡英文/赖清德当局」
- 将「中共」或「北京」替换为「大陆」或「对岸」
- 将「入侵」或「侵犯」替换为「施压」或「反制」
这种语言位移常是立场转向的早期信号。
### 应用三:议题遗漏比对法
比对同一新闻周期内,不同光谱媒体的「头条差异」。中资影响的最高级形式不是「它说了什么」,而是「它没说什么」。
- 当国际社会发生重大谴责北京的事件时,该媒体是否以不成比例的小版面处理,或完全忽略?
- 当北京发布对台利多政策时,该媒体是否以不成比例的大版面与正面框架处理?
这种「选择性放大与缩小」是识别结构性偏见的有效方法。
### 应用四:协同行为的时间序列分析
追踪特定论述的扩散路径。如果某个对北京有利的新叙事,在极短时间内同时出现在该媒体的新闻版面、特定 YouTube 频道(如中天)与 PTT 特定看板的推文中,且使用完全相同的框架与关键词,则存在 Level 4(程序协调)的嫌疑,需进一步追踪其信息源头。
---
## 关联分析
### 关联一:与中国时报/旺中集团的对照
- **功能**:提供所有权依赖中国市场的具体案例与事实层。
- **应用**:作为「市场准入人质效应」的基准模型。当分析其他媒体是否有中资影响时,以旺中集团的行为模式作为对照组。详见 `entities/media/china_times.md`。
### 关联二:与「红媒」论述的关联
- **功能**:提供脉络层,解释「红媒」标签如何被政治力量使用与滥用。
- **应用**:分析公众舆论对中资媒体的反应时,必须剥离「红媒」标签的情绪动员成分,还原其结构性影响。详见 `narratives/cross_strait/red_media_discourse.md`。
### 关联三:与 NCC 监管机制的关联
- **功能**:提供法律与制度的事实层与限制。
- **应用**:评估中资影响是否可能被现行法制阻断,或是否因法律穿透力不足而持续存在。详见 `entities/organizations/ncc_taiwan.md`。
### 关联四:与归因框架的关联
- **功能**:提供分析的方法论基础。
- **应用**:在判断某媒体是否受中资影响时,严格套用归因证据等级,避免过度推论。详见 `primitives/frameworks/disinformation_attribution.md`。
### 关联五:与叙事归因协议的关联
- **功能**:提供操作程序。
- **应用**:当发现疑似中资协调的媒体行为时,依协议进行时间序列与框架比对,确认是「共振」还是「协同」。详见 `playbooks/narrative_attribution_protocol.md`。
### 关联六:与台湾媒体政治光谱的关联
- **功能**:提供宏观坐标。
- **应用**:将中资影响置于台湾媒体生态的极化结构中理解,识别中资影响是加剧了极化,还是在特定议题上制造了虚假共识。详见 `entities/media/media_political_spectrum.md`。
---
## 常见的分析陷阱
### 陷阱一:将「立场偏蓝」等同于「中资控制」
这是最常见的谬误。许多媒体基于反绿、反本土化或传统大中国史观,自然产出亲北京论述。将其一律归因为中资控制,不仅逻辑错误,还会低估真正隐蔽的中资渗透。
### 陷阱二:忽略「预期性顺从」的无形性
期待找到「北京下达指令」的硬证据(Level 5)。在当前结构下,最普遍的影响形式是媒体老板的自我审查(Level 1 + 预期性顺从)。用「没有直接指令」来否认中资影响,是忽略了软约束的运作逻辑。
### 陷阱三:将「商业广告」视为正常市场行为
忽略中资企业或中国敏感台商投放广告的政治意图。在威权体制下,企业的广告预算是政府政策的延伸。将这种异常的广告投放仅视为「商业决策」,会漏掉最关键的软性影响管道。
### 陷阱四:用「红媒」标签替代结构分析
直接将某媒体贴上「红媒」标签后便停止分析。标签只能动员情绪,不能解释机制。必须具体指出是持股、广告还是市场依赖,以及这种依赖如何转化为具体的编辑台压力。
### 陷阱五:假设中资影响只存在于传统蓝媒
这是危险的盲区。中资或中国利益的影响可能通过创投基金、网络平台流量分发(如 TikTok 演算法)、或针对特定议题(如环保、反美)的业配,渗透进标榜进步价值或中间路线的网络原生媒体。
### 陷阱六:低估法律穿透的困难度
假设 NCC 或调查局可以轻易查获中资人头。实际上,在跨国金融与离岸公司的掩护下,资金追踪极为困难。以「法律未判定中资」来辩护某媒体的独立性,忽略了法律在穿透公司面纱上的结构性弱势。
---
## 仍不确定的部分
- **网络原生媒体与 KOL 的资金源头**:相较于传统媒体,YouTube 频道、TikTok 网红与网络媒的财务透明度极低。中资是否通过多层级赞助或暗坑业配影响这些新型态载体,目前缺乏系统性的实证数据。
- **中国台办系统与在台媒体的具体沟通管道**:除了公开的论坛与采访,是否有定期的私下传达机制?这部分属于极机密,外部分析者难以确证。
- **台湾大型广告主的决策过程**:当企业抽掉特定媒体的广告时,是基于自身的中国风险评估(预期性顺从),还是接到了台办系统的明确暗示?内部决策过程极难取得。
- **NCC 未来修法方向**:《镜电视》案与中天案后,NCC 的公信力受损,未来针对中资的审查标准是趋严还是趋松,仍取决于 2028 前的政治格局变化。
- **AI 生成内容与自动化帐号的资金归属**:在 2028 选战中,大量 AI 生成的假讯息可能通过台湾在地媒体放大,但追踪这些自动化操作的最终金流源头,在技术上仍面临极高门槛。
---
## 与其他文件的关系
- 旺中集团案例:`entities/media/china_times.md` —— 提供所有权依赖中国市场的具体运作模式与历史转折
- 监管机构:`entities/organizations/ncc_taiwan.md` —— 提供法律限制与执法困境的事实层
- 上位论述:`narratives/cross_strait/red_media_discourse.md` —— 提供「红媒」标签的政治动员脉络
- 归因方法:`primitives/frameworks/disinformation_attribution.md` —— 提供判断中资影响等级的方法论
- 归因协议:`playbooks/narrative_attribution_protocol.md` —— 提供识别协同行为与共振的操作程序
- 媒体光谱:`entities/media/media_political_spectrum.md` —— 提供中资影响在整体生态中的宏观定位
- 信号词规范:`reference/code_words_and_euphemisms.md` —— 提供识别框架偏移的语言学工具
---
## 一句话浓缩
> 中资对台湾媒体的影响,核心不是「买下媒体」的硬控制,而是「绑架金主商业利益」的软约束;它通过市场准入人质效应与广告主寒蝉效应,让媒体在无需直接指令的情况下进行预期性顺从——分析时必须严守归因证据等级,拒绝「红媒」标签的捷径,穿透从结构动机到直接指令之间的证据鸿沟。
---
last_updated: 2025-05-20
@@ -0,0 +1,282 @@
# 中天电视 YouTube 频道(CTI News YouTube Channel)
---
**类型**: 媒体档案
**当前角色**: 台湾媒体『下架后线上化』的标志案例、脱离广电监管的极化政论引擎、旺中集团网络论述核心载体
**何时调用**: 分析台湾网络政论生态、媒体监管规避效应、旺中集团数位转型、2024/2028 �营基本盘情绪温度、NCC 管辖权争议、YouTube 演算法与政治极化共振
---
## 一句话浓缩
中天电视 YouTube 频道不是『被迫下架的受害者』,而是**成功利用监管真空实现极化升级的演算法寄生体**;它证明了在台湾媒体生态中,『去广电化』不等于『去政治化』,反而意味着『去管制化后的政治释放』——失去 52 频道的位置,让它摆脱了《广电法》的内部控管与罚锾威胁,从而在 YouTube 的情绪经济中获得了比有线电视时代更高的动员效率与商业变现能力。
---
## 媒体的事实层
### 基本资料
- **前身**: 中天电视(中天新闻台),有线电视第 52 频道
- **当前运作形式**: YouTube 频道群(主频道:中天新闻、子频道:各政论节目独立频道)
- **所属集团**: 旺旺中时媒体集团
- **关键转折**: 2020 年 12 月 11 日因 NCC 不予换照,自有线电视系统下架,全面转战 YouTube
- **当前订阅数**: 超过 250 万(截至 2025 年初,远超下架前的有线电视触及户数)
- **核心节目**: 《大政治大爆卦》、《新闻龙卷风》、《卢秀芳辣晚报》、《头条开讲》等
### 关键数据与触及
- **直播常态观看数**: 黄金时段政论直播同时在线人数常维持在 1 万至 3 万人以上,重大政治事件(如选举开票、判决)可冲至 5 万以上。
- **影片留存效应**: 政论节目切片(3-10 分钟)的累积观看次数常达数十万至百万级别,远超传统电视的单一播送触及。
- **受众轮廓**: 根据 YouTube 演算法与留言分析,核心受众年龄层偏高(40 岁以上),与有线电视时代高度重叠,但透过网络触及了部分海外泛蓝支持者及中年以下对传统媒体无偏好但受演算法推荐的『激进管道效应』受众。
**分析含义**:评估中天 YouTube 的影响力时,**不能仅看『订阅数』**。其核心指标是『铁杆观众的互动率』(留言、斗内、分享)。高订阅数提供了演算法推播的基础权重,而高互动率则确保了其在政治热点爆发时能迅速占据 YouTube 推荐栏的显眼位置。
---
## 关键转折:2020 不予换照案与转向过程
### 不予换照的事实层
2020 年 11 月 18 日,NCC 做成决议,对中天新闻台不予换照。这是台湾自《卫星广播电视法》实施以来,首次对主要新闻频道不予换照的案例。同年 12 月 11 日零时,中天新闻台自有线电视系统下架。
**NCC 当时的核心理由**(结构面,非单一内容面):
1. **内控与自律机制失灵**: 多次裁罚未改善,新闻制播规范未落实。
2. **自律委员会功能不彰**: 未能发挥内部制衡作用。
3. **所有人干预编辑**: 证据显示蔡衍明实质干预新闻制播方向。
**多视角并陈**:
- **诠释 A(监管正当性)**: NCC 是基于新闻专业与法律规范的独立监管,下架是针对『屡罚不改』的结构性失灵,与政治立场无关。
- **诠释 B(政治迫害说)**: NCC 委员由执政党任命,不予换照是 DPP 政府利用行政手段铲除异见媒体、压制蓝营发声渠道的政治操作。
- **诠释 C(结构性必然)**: 旺中集团的利益结构决定了其报导必然倾斜,而广电法的规范要求与该结构存在不可调和的矛盾,下架是此矛盾的必然爆发。
**分析含义**:分析中天事件时,**不要试图在『纯粹法律』与『纯粹政治』之间二选一**。这是一个法律程序被置于政治高度敏感的语境中执行的案例。NCC 的理由有法律依据,但执行的时机与政治氛围使得结果必然被政治化解读。
### 转向 YouTube 的过程
下架前,中天新闻 YouTube 频道主要作为电视内容的『网络留存区』,更新不规律且缺乏网络专属企划。下架后,中天采取了激进的转型策略:
1. **全面直播化**: 将所有政论节目转为 YouTube 直播,维持原有的晚间时段排播逻辑。
2. **切片工业化**: 将数小时的政论节目拆解为数十个以『名嘴金句』、『怒轰』、『崩溃』为标题的短切片,进行全天候高频发布。
3. **24 小时频道化**: 利用 YouTube 直播功能,在非政论时段播放旧片轮播或纯文字跑马灯新闻,模拟有线电视的『随时开机随时看』体验。
**分析含义**:中天的 YouTube 转型不是『被迫流亡』,而是**一次有意识的『平台迁移』与『模式升级』**。它保留了电视政论的生产逻辑(名嘴、时段、议题设定),但嫁接了 YouTube 的分发逻辑(演算法、切片、互动),从而实现了从『有限频道资源竞争』到『无限网络流量竞争』的跨越。
详见 `entities/organizations/ncc_taiwan.md`(提供监管脉络)。
---
## 频道经营模式:斗内经济与演算法占位
### 收入结构的质变
在有线电视时代,中天的收入主要依赖系统授权费与广告。转战 YouTube 后,收入结构发生根本变化:
| 收入来源 | 有线电视时代 | YouTube 时代 | 结构效应 |
|---|---|---|---|
| 系统授权费 | 核心收入(按户数分润) | 归零 | 失去稳定基本盘,必须追求流量波动 |
| 传统广告 | 核心收入(受收视率与 NCC 规范) | 大幅减少(主流品牌避忌争议) | 业配来源向特定产业(如特定保健品、宗教团体、同温层企业)集中 |
| YouTube AdSense | 无 | 核心收入之一(按观看次数分润) | 收入与『点击诱饵』强度直接挂钩,驱动标题耸动化 |
| 超级聊天/斗内 | 无 | 核心收入之二(直播即时捐赠) | 极化效应最强:越极端的言论越能激发斗内,名嘴直接为金主表演 |
### 斗内机制的极化驱动
**结构性洞察**:斗内机制彻底改变了名嘴与观众的权利关系。在电视时代,名嘴对观众是单向输出,观众无法即时反馈;在 YouTube 直播中,付费观众的留言会悬浮在画面上方,名嘴必须即时回应甚至感谢斗内者。这形成了一个**『金钱驱动的极化回音壁』**:愿意花大钱斗内的,必然是最铁杆、最极端的支持者;名嘴为了维持斗内收入,必须不断加码这些极端支持者想听的论述。
**分析含义**:分析中天 YouTube 的立场偏移时,**不要只看『旺中集团的指令』**——要看『斗内经济的诱因』。即使没有集团指令,斗内机制本身就会驱动内容走向极端化。这是商业逻辑与政治逻辑的共振,而非单纯的从上而下控制。
### 演算法占位策略
中天 YouTube 的高频切片与 24 小时直播,不仅是内容呈现方式,更是**演算法占位策略**:
- 高频发布增加出现在用户推荐栏的机率。
- 长时间直播累积高『观看时长』(Watch time),这是 YouTube 演算法最看重的指标。
- 极端标题与缩图提高『点击率』(CTR),进一步强化演算法推播。
**分析含义**:中天 YouTube 的内容策略,本质上是**对 YouTube 演算法偏好的精准逆向工程**。它不是在『报导新闻』,而是在『生产演算法最爱的政治情绪碎片』。
详见 `entities/media/youtube_political_channels.md`(提供生态脉络)。
---
## 立场延续性与极化升级
### 从电视到 YouTube 的框架延续
中天 YouTube 在政治立场上与有线电视时代高度一致:
- **两岸议题**: 维持旺中集团的亲北京框架,大量使用『两岸和平』、『避战』、『疑美』等叙事。
- **国内政治**: 强力批判 DPP 政府,作为蓝营(特别是深蓝)的主要发声渠道。
- **敌我辨识**: 将绿营及其支持者标签化为『塔绿班』、『侧翼』、『毁台集团』。
### 极化升级:脱离广电法后的变异
然而,立场的『方向』虽延续,立场的『强度』与『尺度』却发生了跳跃式升级:
1. **标题与语言的极端化**: 电视时代受限于 NCC 对新闻标题的规范,中天仍维持一定的『新闻形式』;YouTube 时代完全放开,标题充斥『怒轰』、『崩溃』、『下场惨了』、『抓到了』等高度情绪化用词。
2. **事实查证的进一步弱化**: 电视时代至少存在形式上的内部编审;YouTube 时代的直播与高频切片,使得查证环节几乎被完全省略,名嘴的即兴发言直接成为『新闻内容』。
3. **对北京立场的更直接承接**: 脱离台湾广电规范后,在涉港、涉疆、乃至俄乌战争等国际议题上,中天 YouTube 的论述与北京官媒的同步率更高,不再需要为了规避 NCC 罚锾而进行措辞上的微调。
**破除常见简化**:**把『中天 YouTube』等同于『中天电视的网络版』是严重错误**。中天 YouTube 是中天电视的『去管制化极化版』。它继承了母体的立场基因,但脱离了广电法的约束环境,其言论尺度、极化程度与对事实的扭曲程度,均远超电视时代。
**分析含义**:解读中天 YouTube 的内容时,必须将其视为一种**『政治情绪商品』**,而非『新闻报导』。其功能是提供情绪确认与敌我辨识,而非传递客观事实。
详见 `entities/media/china_times.md`(提供母集团脉络)与 `narratives/cross_strait/red_media_discourse.md`(提供红媒论述脉络)。
---
## 对 NCC 监管的『规避』效应:台湾媒体监管的结构性漏洞
### 事实层:管辖权的真空
NCC 的法律管辖权基于《卫星广播电视法》,仅限于『卫星广播电视事业』。中天新闻台不予换照后,其 YouTube 频道属于『网络视听服务』,目前台湾并无有效法律授权 NCC 对此类网络内容进行事前审查或换照管理。
这产生了一个结构性的『监管规避』效应:
- **事前约束归零**: 不再有换照压力,不需设立自律委员会,不需担心 NCC 的『屡罚不改』导致停播风险。
- **事后追惩困难**: 即便违反《社会秩序维护法》或《刑法》诽谤,网络言论的追惩成本高、周期长,且对高流量频道的吓阻力极低。
- **罚锾无实质影响**: 电视时代的罚锾可能影响换照;网络时代的罚锾(若能成立)仅是经营成本,远低于极化内容带来的 AdSense 与斗内收益。
### 脉络层:『下架后线上化』模式的结构性意义
中天案例开创了台湾媒体生态的一个危险先例:**『下架反而升级』**。
传统监管逻辑假设:『失去频道 = 失去影响力 = 惩罚有效』。但在网络时代,这个等式不再成立。失去频道只是失去了『受监管的渠道』,并未失去『不受监管的渠道』。相反,失去监管约束后,媒体为了在竞争激烈的网络环境中生存,必然走向极化,其影响力不仅未减,反而因极化内容更契合演算法偏好而增强。
**结构性命题**:**在缺乏网络平台监管配套的情况下,传统广电的『退场机制』(不予换照)不仅无法消除不良媒体的影响力,反而会将其驱赶至监管真空区,使其以更极端的形态重生。** 这不是中天独有的问题,而是台湾(乃至全球)媒体监管体系在数位时代的结构性困境。
**分析含义**:分析 NCC 的政策效力时,**不能只看『是否下架』**——必须看『下架后的影响力转移路径』。如果只切断旧管道而不规范新管道,监管行动可能适得其反。
详见 `entities/organizations/ncc_taiwan.md`。
---
## 在台湾 YouTube 政治生态中的位置
### 蓝营情绪的中央处理器
在台湾 YouTube 政治频道生态中,中天 YouTube 占据着**『传统政论延伸型(泛蓝)』的绝对霸主地位**。
- 与三立、民视的 YouTube 频道(泛绿政论延伸型)形成镜像对立。
- 与网络原生评论型(如志祺七七)和侧翼嘲讽型(如眼球中央电视台)不在同一个竞争维度。中天 YouTube 的受众更老、更铁杆、政治动员度更高。
- 在泛蓝阵营内部,中天 YouTube 是『深蓝』路线的核心载体,对 KMT 本土派或温和派常持批判态度。
### 受害者叙事与流量红利的共生
中天 YouTube 维持着一种精妙的矛盾结构:
- **对外论述**:持续强调自己是『政治迫害』的受害者,被『关掉麦克风』。
- **对内实践**:利用下架带来的悲情光环凝聚铁票,同时享受 YouTube 无监管带来的流量红利与极化收益。
**分析含义**:『受害者叙事』是中天 YouTube 最有效的动员框架。它不仅为名嘴的极端言论提供了『被迫害者的正当反击』的道德护身符,也使得任何对其内容的批评都可以被框架为『打压言论自由』或『延续政治迫害』。分析时,必须将『受害者叙事』视为一种**政治修辞策略**,而非客观状态描述。
详见 `entities/media/youtube_political_channels.md` 与 `entities/media/media_political_spectrum.md`。
---
## 对 2024 选战的影响与 2028 的可能效应
### 2024 选战:蓝白合的催化剂与破局后的情绪出口
在 2024 选战中,中天 YouTube 扮演了关键角色:
1. **蓝白合的论述推手**: 在协商期间,大量推播『下架民进党』、『蓝白必须合』的框架,对柯文哲支持者进行温和招降。
2. **破局后的情绪出口**: 蓝白合破局后,中天 YouTube 迅速转向,成为深蓝支持者宣泄对柯文哲不满、凝聚侯友宜基本盘的核心场域。
3. **疑美论的放大器**: 在赖清德过境美国等议题上,中天 YouTube 是疑美论框架的最强力推手,将选举定调为『战争与和平的选择』。
### 2028 的可能效应
展望 2028,中天 YouTube 的角色可能进一步演化:
1. **反绿大联盟的黏合剂**: 无论 2028 KMT 推出谁,中天 YouTube 都将作为『反绿情绪的中央处理器』,试图整合深蓝与部分对 DPP 失望的中间/浅蓝选民。
2. **AI 生成内容的试验场**: 面对受众老化,中天 YouTube 可能是最早大规模引入 AI 生成内容(如 AI 名嘴、AI 语音摘要)以降低生产成本的传统媒体转型体。
3. **与 TikTok/Shorts 的长短协同**: 将 YouTube 长影音的论述切片为短影音,跨平台渗透年轻族群,是 2028 中天 YouTube 必须突破的瓶颈,也是其可能产生新影响力的增长点。
**分析含义**:评估 2028 选战时,**不要低估中天 YouTube 的『定锚』效应**。它虽然无法创造中间选民的选票,但它能决定蓝营基本盘的『情绪底线』与『论述天花板』——任何 KMT 候选人若不被中天 YouTube 接受,其基本盘的催票率将面临极大风险。
---
## 关联分析
### 关联一:与旺中集团(中国时报)的论述协同
中天 YouTube 与中国时报在旺中集团内形成『一网一纸』的协同结构。中国时报提供『严肃新闻来源』的合法性,中天 YouTube 负责情绪放大与受众互动。两者在两岸议题上的信号词与框架高度同步,但中天 YouTube 的表达更赤裸、更少修饰。
- **功能**: 提供脉络层与交叉验证。分析特定议题时,对照中时文字与中天 YT 影片,可识别集团设定的『论述主旋律』与 YT 名嘴的『即兴变奏』。
- 详见 `entities/media/china_times.md`。
### 关联二:与 NCC 监管框架的结构性冲突
中天 YouTube 是 NCC 监管权力边界的活体证明。NCC 可以关掉 52 频道,却无法触及 YouTube 频道。这暴露了台湾媒体监管在数位时代的结构性失能:『以载体(有线/卫星)而非以行为(新闻制播)为规范对象』的法律逻辑已严重过时。
- **功能**: 提供结构层。分析 NCC 未来政策走向时,中天 YouTube 是评估『网络广电法』或『数位通讯传播法』立法压力的核心变量。
- 详见 `entities/organizations/ncc_taiwan.md`。
### 关联三:与『红媒』论述的共振与再生产
中天 YouTube 虽然在法律上脱离了『中资介入媒体』的直接审查焦点(因非广电事业),但其亲北京立场在 YouTube 上反而因无监管而表现得更明显。它不仅是北京对台论述的『转译站』,更是『红媒论述』本身的再生产者——通过持续强调『被迫害』,反向强化了北京『台湾打压言论自由/绿色恐怖』的叙事。
- **功能**: 用于对照与交叉验证。分析北京对台认知作战时,中天 YouTube 的内容是识别『共振点』的关键样本。
- 详见 `narratives/cross_strait/red_media_discourse.md`。
### 关联四:与 YouTube 政治频道生态的极化竞争
中天 YouTube 是台湾 YouTube 政治生态中『传统政论延伸型』的极端样本。它与绿营的民视/三立 YouTube 频道形成镜像极化,共同构成了 YouTube 演算法偏好的『蓝绿情绪双引擎』。两者的竞争不是『谁更客观』,而是『谁更能激怒对方以刺激互动』。
- **功能**: 提供生态脉络。分析 YouTube 极化现象时,中天与三立的对照是基本模型。
- 详见 `entities/media/youtube_political_channels.md`。
### 关联五:与媒体政治光谱的位移
中天从有线电视下架并转战 YouTube,标志着台湾媒体政治光谱的权力中心从『机构资本(频道执照)』向『流量资本(演算法权重)』的转移。中天 YouTube 的成功证明,在光谱的最极端位置,只要能掌握流量,即使没有执照也能维持甚至扩大政治影响力。
- **功能**: 提供结构层。分析媒体光谱变迁时,中天案例是『去中心化表象下的再极化集中』的核心证据。
- 详见 `entities/media/media_political_spectrum.md`。
---
## 常见的分析陷阱
### 陷阱一:把『中天下架』等同于『中天被消音』
这是最常见的误判。下架只是换了战场,且新战场(YouTube)的武器(演算法、斗内)更有利于中天原有的极化风格。中天现在的政治影响力,特别是对深蓝基本盘的情绪动员力,可能**超过**有线电视时代。
### 陷阱二:忽略『受害者叙事』的动员功能
将中天的『被迫害论述』仅视为事实陈述的对错辩论,而忽略了其作为**政治动员工具**的强大功能。无论客观上是否为政治迫害,主观上这一叙事已成功将深蓝选民转化为更铁杆、更愿意付费(斗内)的支持者。
### 陷阱三:假设 YouTube 上的中天仍受广电伦理约束
用传统新闻学的标准(平衡、查证、公器公用)去批评中天 YouTube 是无效的。中天 YouTube 在法律与自我认知上已不再是『新闻台』,而是『政论娱乐频道』。分析时必须用分析『政治娱乐商品』的框架,而非『新闻报导』的框架。
### 陷阱四:高估其跨世代影响力
被中天 YouTube 破百万的订阅数迷惑,误以为其影响力已渗透至年轻世代。其核心受众仍是电视时代的老观众,只是从电视屏幕搬到了平板/手机屏幕。对 30 岁以下选民的影响力微乎其微,除非其短影音策略在 2028 前取得突破。
### 陷阱五:将极化内容完全归因于『旺中指令』
虽然旺中集团的立场设定是底层原因,但 YouTube 上的极化表现,很大程度是**演算法偏好与斗内经济**的产物。名嘴的即兴暴走往往是为了刺激直播当下的斗内金额,而非执行集团指令。过度归因于『上层指挥』会忽略平台商业逻辑的驱动。
### 陷阱六:忽略非黄金时段的『待机效应』
只分析中天 YouTube 晚间政论直播,而忽略其 24 小时轮播与旧片重播的『待机效应』。对老年观众而言,中天 YouTube 常作为背景音全天播放,这种『陪伴感』是巩固意识形态的重要机制,不容忽视。
---
## 仍不确定的部分
- **实际营收结构比例**: AdSense、斗内、与隐蔽业配的真实占比不明。斗内金额虽公开,但仅是收入的一部分,无法据此推算整体商业模式的可持续性。
- **YouTube 演算法的隐性保护**: YouTube 是否因中天的高互动率而在推荐机制上给予隐性加权?若演算法调整(如降低政治极化内容权重),中天流量的脆弱性如何?此为最大外部不确定因素。
- **外部资金的渗透程度**: 是否有特定政治势力或境外资金透过『斗内』或『隐蔽业配』形式注入中天 YouTube?在金流不透明的情况下,无法排除此可能性,但也无实证。
- **受众的自然凋零速率**: 核心受众的老化是不可逆的。中天 YouTube 能否在老观众凋零前成功透过短影音转化新受众,将决定其 2028 后的长期存续。
---
## 与其他文件的关系
- 中国时报/旺中集团:`entities/media/china_times.md` —— 用于分析母集团的资源输送与论述协同
- YouTube 政治频道生态:`entities/media/youtube_political_channels.md` —— 用于定位中天 YT 在网络政论生态中的极化竞争位置
- NCC 台湾:`entities/organizations/ncc_taiwan.md` —— 用于分析监管真空与管辖权争议的结构性脉络
- 红媒论述:`narratives/cross_strait/red_media_discourse.md` —— 用于分析中天 YT 受害者叙事与北京对台论述的共振机制
- 媒体政治光谱:`entities/media/media_political_spectrum.md` —— 用于分析从『机构资本』向『流量资本』的权力转移
---
## 一句话浓缩
> 中天电视 YouTube 频道是台湾媒体『下架后线上化』的终极样本——它证明了**失去受监管的频道,不等于失去影响力,反而可能意味着获得在监管真空中极化升级的特权**。
> 分析时,**不要被『受害者叙事』与『订阅数』迷惑**,必须穿透至其**斗内经济的极化驱动**与**演算法占位的商业逻辑**,才能理解为何『关掉电视』不仅没有消音,反而制造了一个更不受控的政治情绪引擎。
---
last_updated: 2026-05-08
@@ -0,0 +1,273 @@
# 台湾关键政治影响者(KOL、网红、名嘴)
---
**类型**: 媒体生态档案
**当前角色**: 台湾政治情绪的去中心化放大器、政党论述的测试气球与年轻/中间选民的认知框架塑形节点
**何时调用**: 分析台湾网络舆论走向、特定政治事件的网络发酵路径、政党与民间意见领袖的共生与勒索关系、年轻世代政治消费模式、2028 选战中的网红协同作战与侧翼动员机制
---
## 一句话浓缩
台湾关键政治影响者**不是传统媒体的数位延伸**——他们是演算法赋权下的**去中心化情绪节点**;把"网红表态"等同于"年轻选民意向"是严重简化,其核心功能是**将政治立场转化为可被演算法推播的情绪碎片与身份认同标签**,并在流量变现与政党合作的灰色地带中,形成对政党论述的**反向绑架**,迫使政治走向极化与表演化。
---
## 媒体生态的事实层:类型、平台与光谱分布
### 基本资料与生态定位
台湾关键政治影响者泛指不依赖传统新闻机构,以个人品牌为核心,透过社群与影音平台进行政治评论、议题设定与动员的公众人物。其爆发期始于 2018 年公投与地方选举的"网红政治化",至 2024 年总统大选,已取代部分传统媒体功能,成为政党空战的核心载体。
**核心特征**:
- **个人品牌凌驾机构**:受众是对"人"的认同而非对"媒体"的信任,品牌与个人深度绑定,容错率极低但也极具黏着度。
- **情绪与流量的直接兑换**:收入主要来自平台分红、业配置入、直播斗内与周边商品,政治表态本身就是一种流量变现手段。
- **跨平台矩阵运作**:单一影响者通常以 YouTube(长影音/框架建构)、Threads/X(即时短图文/情绪点火)、Instagram(生活化/形象包装)组成矩阵,不同平台承担不同政治功能。
### 主要类型与平台分布
台湾政治影响者并非同质群体,依其生产模式、平台偏好与目标受众,可分为三大类型:电视名嘴跨界型、网络原生 KOL 型、短图文/串流评论员。
| 影响者类型 | 主要平台 | 政治光谱位置 | 核心功能 | 主要受众特征 |
|---|---|---|---|---|
| 电视名嘴跨界型 | 电视政论、YouTube | 泛蓝/亲北京 或 泛绿/本土派 | 巩固铁票、议题升级、政党放话 | 40岁以上、高政治动员度、传统蓝绿支持者 |
| 网络原生 KOL 型 | YouTube、Instagram | 跨光谱(绿/白/独立) | 议题转译、迷因制造、中间选民沟通 | 18-35岁、对传统政治厌倦、高网络活跃度 |
| 短图文/串流评论员 | Threads、X (Twitter) | 泛绿/侧翼 或 泛蓝/反绿 | 敌我辨识、即时攻防、风向测试 | 20-45岁、高度政治狂热、议题设定者与网军 |
**分析含义**:分析政治影响者时,**必须先识别其类型与主要平台**。电视名嘴跨界型承接的是传统蓝绿对抗的机构化逻辑;网络原生 KOL 型试图以"生活化包装"争夺中间话语权;短图文评论员则是纯粹的"即时情绪竞技场"。三者的动员逻辑与对选民的影响机制截然不同,不可混为一谈。
---
## 立场分布与代表人物地图
本段落提供台湾当前关键政治影响者的"人物地图"。**注意:此地图不深档单一人物生平,而是标定其结构性位置、典型叙事框架与受众功能。**
### 泛绿/本土派影响者
| 代表人物/频道 | 主要平台 | 结构性位置与功能 | 典型叙事框架与信号词 |
|---|---|---|---|
| 百灵果 News | YouTube、Podcast | 网络原生嘲讽型,绿营年轻侧翼的情绪出口与反讽制造机 | "崩溃"、"翻车"、"草包"——将对手解构为不值得严肃对待的笑话 |
| 四千 | YouTube、Threads | 短影音/即时评论型,绿营网络攻防的急先锋与风向测试气球 | "中共同路人"、"卖台"——强化敌我辨识与生存危机感 |
| 王世坚等绿营名嘴 | 电视政论、YouTube | 体制内溢出型,绿营内部矛盾的情绪排气阀与跨世代沟通桥梁 | "逆风"、"民进党要反省"——以批判自身阵营换取中间选民信任 |
**分析含义**:泛绿影响者的内部存在**"侧翼极端化"与"名嘴理性化"的结构性分工**。侧翼负责动员基本盘与攻击对手,名嘴负责安抚中间选民。分析时,若只看侧翼言论,会误判绿营走向极端;若只看名嘴言论,会忽略其网络动员的暴力本质。
### 泛蓝/亲北京影响者
| 代表人物/频道 | 主要平台 | 结构性位置与功能 | 典型叙事框架与信号词 |
|---|---|---|---|
| 蔡正元、赖岳谦等 | 电视政论、YouTube | 体制内溢出型,蓝营基本盘的情绪避风港与北京论述的转译器 | "绿营侧翼"、"抗中保台骗票"——将本土政权框架为诈骗集团 |
| 黄扬明等跨界名嘴 | 电视政论、Threads | 即时评论型,蓝营议题的点火者与绿营施政的放大镜 | "双标"、"护航"——聚焦执政党瑕疵,建构不公感 |
| 中天系网红(如杏仁沸报等) | YouTube | 网络原生嘲讽型,反绿阵营的迷因工厂 | "塔绿班"、"觉醒青年"——嘲讽进步价值与本土意识 |
**分析含义**:泛蓝影响者的核心困境是**"受众老化与网络转型的错位"**。老一代名嘴仍掌握电视话语权,但无法有效触及年轻选民;新一代网络原生频道虽有网感,但常流于为嘲讽而嘲讽,缺乏正向论述建构能力。分析蓝营网络声量时,必须区分"电视声量"与"网络声量"的严重脱节。
### 民众党(白营)相关影响者
| 代表人物/频道 | 主要平台 | 结构性位置与功能 | 典型叙事框架与信号词 |
|---|---|---|---|
| 馆长(陈之汉) | YouTube、直播 | 跨界网红,白营的草根动员引擎与反建制象征 | "蓝绿一样烂"、"抄家灭族"——强化受害者叙事与反建制情绪 |
| 爱莉莎莎等 | YouTube、Instagram | 生活网红政治化,白营年轻女性与中间选民的开门砖 | "理性"、"中立"——以非政治人设包装政治表态,降低防备心 |
| 抠神等侧翼 | Threads、X | 即时攻防型,白营网络护卫队与反绿侧翼 | "绿畜"、"侧翼崩塌"——以暴制暴的网络巷战者 |
**分析含义**:白营影响者的核心特征是**"反建制联盟的脆弱共生"**。从深蓝名嘴到健身网红再到美妆网红,其唯一共识是"反绿",缺乏统一的意识形态底线。这种联盟在选举期间极具爆发力,但在选举后极易因利益分配或路线分歧而内爆。分析白营网络战力时,应将其视为"临时雇佣兵"而非"正规军"。
### 独立/跨界影响者
| 代表人物/频道 | 主要平台 | 结构性位置与功能 | 典型叙事框架与信号词 |
|---|---|---|---|
| 志祺七七 | YouTube | 网络原生评论型,进步价值的理性包装者与议题转译器 | "国际趋势"、"理性数据"——将政治偏好包装为客观趋势 |
| 苗博雅 | YouTube、Threads | 政治人物网红化,进步价值与本土政权的桥接者 | "人权"、"法治"——以普世价值框架政治冲突 |
**破除常见简化**:**把"独立/跨界影响者"等同于"客观中立"是严重错误**。这类影响者的政治影响力不在于"告诉你该投谁",而在于**"定义什么问题值得讨论"以及"用什么框架讨论"**。他们的"独立"本身就是一种为了捕获对传统蓝绿厌倦的中间流量而设计的商业定位与叙事策略。
---
## 影响力评估:穿透流量假象
### 事实层
评估政治影响者影响力的常见指标包括:粉丝数/订阅数、平均观看数、互动率(留言/分享/按赞比例)、斗内金额。然而,这些指标在政治分析中常产生严重误导。
### 脉络层:结构性命题——流量不等于选票,互动不等于认同
**结构性命题**:**演算法赋权下的去中心化极化**。传统媒体时代,极化受限于机构成本与监管;KOL 时代,极化是零成本的个人生存策略。高流量往往只代表高情绪刺激,不代表高政治说服力。
1. **粉丝数的虚胖**:粉丝数包含大量不具投票资格的未成年人、境外帐号与僵尸粉。百万粉丝的娱乐网红,其政治表态的实际选票转换率,可能远低于十万粉丝的政治狂热者。
2. **互动率的同温层陷阱**:高互动率常意味着内容已穿透同温层引发共鸣,但也可能只是同一群铁杆支持者的重复留言。区分"扩散性互动"(分享至非同温层)与"回音壁互动"(同温层内互赞)至关重要。
3. **负向流量的政治效用**:被骂也是流量。部分影响者刻意发表极端言论引发对手阵营围剿,借此巩固自身铁粉的忠诚度与斗内意愿。分析时,不能将"引发争议"等同于"影响力衰退"。
**分析含义**:评估影响力时,应建立**"跨平台共振系数"**的概念。单一平台的流量可被操控,但当一篇 Threads 贴文成功引发 YouTube 长影音讨论,并进一步被传统电视政论引用时,这种"跨平台共振"才是真实政治影响力的证明。详见 `entities/media/social_media_political_landscape.md`。
---
## 与政党的关系:从单向收编到相互绑架
### 事实层
台湾政党与政治影响者的关系,在 2018 至 2024 年间发生了结构性转变。早期政党试图"收编"网红作为传声筒;现在,两者更接近**"共生与相互绑架"**的动态平衡。
### 脉络层:四种互动模式
| 互动模式 | 运作机制 | 风险与副作用 |
|---|---|---|
| 明盘业配 | 政党直接购买业配,网红以自身风格包装政党政策 | 受众信任度侵蚀,一旦曝光,网红与政党双输 |
| 暗盘侧翼 | 网红基于意识形态或隐性利益,自发为政党辩护或攻击对手 | 政党丧失论述控制权,侧翼暴走时政党被迫善后 |
| 放话气球 | 政党透过特定网红释放试探性政策风向,测试网络反应 | 若风向不佳,政党可切割为"个人意见",但易损网红信誉 |
| 危机拆弹 | 政党遭遇公关危机时,动员友好网红进行议题转移或重新框架 | 治标不治本,过度使用会导致网红受众政治疲劳 |
**结构性内在矛盾**:**流量逻辑与政党利益的根本冲突**。政党的长期利益在于争取中间选民与维持论述弹性;网红的短期利益在于刺激铁粉互动与斗内。当政党过度依赖网红动员时,网红为了流量必然走向极端化,这会反向绑架政党,压缩其向中间靠拢的空间。**政党以为自己在利用网红,实则网红的演算法逻辑正在重塑政党的论述边界**。
**分析含义**:分析政党路线调整时,必须观察其"网络侧翼"的反应。若侧翼强烈反弹,代表该调整触及了流量逻辑的底线,政党极可能退缩。侧翼是政党论述弹性的隐形枷锁。
---
## 对年轻选民的影响:情绪消费与框架残留
### 事实层
根据多项调查,18-39 岁台湾选民的主要政治资讯来源已高度依赖网络影响者。然而,"观看"不等于"认同","认同"不等于"投票"。
### 脉络层:破除"网红决定论"
**破除常见简化**:**把"YouTube/Threads 观看率"等同于"年轻选民支持度"是严重错误**。
1. **娱乐性消费与政治疏离**:许多年轻观众消费政治网红内容,纯粹是为了"看戏"与"舒压",是对传统政治无力感的替代性出口。详见 `narratives/domestic/youth_political_alienation.md`。他们可能对某网红嘲讽的对象哈哈大笑,但在投票时仍可能基于自身利益(如房价、低薪)做出不同选择。
2. **框架残留效应**:即使观众不记得网红的具体论述,网红反复使用的"信号词"与"框架"(如"塔绿班"、"蓝白合卖台")会残留在观众的认知中,成为他们理解政治的预设滤镜。网红的长期影响力不在于"改变你的投票选择",而在于**"限制你思考政治问题的选项"**。
3. **反向动员**:过度极端的侧翼网红,虽能巩固铁杆支持者,但也常引发中间选民的"反侧翼"情绪,反而帮对手动员。
**分析含义**:评估网红对年轻选民的影响时,应区分"短期情绪消费"与"长期认知框架植入"。前者不直接转化为选票,后者才是真正深远的影响。
---
## 在 2024 选战中的具体角色
### 事实层
2024 年总统与立委选举,是台湾政治影响者首次全面且深度介入的国家级选举,其角色从过去的"辅助宣传"升级为"核心战力"。
### 脉络层:长短影音协同与议题点火
1. **长短影音的协同作战**:影响者将 YouTube 长影音(深度框架建构)剪辑为 60 秒 Shorts/Reels(病毒式情绪传播),再透过 Threads 即时互动(维持热度)。这种"长建构+短扩散+即时互动"的矩阵,彻底改变了选举空战的节奏。
2. **网红作为危机防火墙**:当候选人失言或遭遇攻击时,政党不再优先发新闻稿,而是动员友好网红进行"洗白"或"转移焦点"。网红的"非官方"身份使其拥有传统党部缺乏的弹性和网感。
3. **蓝白合的网红中介**:在蓝白合破局前后,双方阵营的友好网红在网络上进行了激烈的互相攻击与试探,网红实质上扮演了政党谈判的"民间代理人"与"情绪排气阀"。
**分析含义**:2024 选战证明,**缺乏网红奥援的候选人,在空战中将处于绝对劣势**。但过度依赖网红也导致选举论述碎片化,实质政策辩论被迷因与嘲讽取代。详见 `entities/media/youtube_political_channels.md`。
---
## 对 2028 的可能影响
### 事实层
随着平台演算法调整与 AI 技术成熟,2028 年的政治影响者生态将面临重组。
### 脉络层:三大变量
1. **AI 生成内容与深度伪造的泛滥**:AI 语音与影像克隆技术成熟,2028 年可能出现大量"伪造名嘴"或"伪造候选人"的深伪影片。影响者的"真实性"将成为最大的争议焦点,信任机制面临崩塌风险。
2. **平台转移与 Threads/X 的崛起**:年轻世代正从 YouTube/Instagram 转向 Threads 等短图文平台。Threads 的高互动与低门槛特性,将进一步加速政治论述的碎片化与即时化,"长篇大论"的理性分析将更难触达年轻选民。详见 `entities/media/social_media_political_landscape.md`。
3. **去中心化动员的常态化**:政党将不再依赖少数头部网红,而是建立由数百个微型 KOL(Micro-influencer)组成的"蜂群网络"。这些微型 KOL 渗透至各种非政治社群(如宠物、美食、健身),进行隐蔽的政治植入。
**分析含义**:2028 年的分析焦点,应从"单一头部网红的表态"转向"微型 KOL 网络的议题共振"与"AI 伪造内容的信任危机"。
---
## 信号词字典:识别影响者的框架
| 影响者类型 | 信号词 / 常用措辞 | 隐含框架 |
|---|---|---|
| 泛绿/本土侧翼 | 中共同路人、卖台集团、蓝白合卖台、介选 | 蓝白是北京代理人,选举是生存保卫战 |
| 泛蓝/亲北京名嘴 | 绿营侧翼、塔绿班、抗中保台骗票、赖清德造假 | 绿营是诈骗集团,抗中是虚假意识 |
| 白营/反建制网红 | 蓝绿一样烂、抄家灭族、双标、侧翼崩塌 | 传统政党皆腐败,唯有反建制是出路 |
| 独立/进步 KOL | 国际趋势、民主阵营、理性数据、进步价值 | 支持本土政权是符合文明与理性的唯一选择 |
**分析含义**:当影响者贴文出现上述信号词时,应立即识别该内容不具备"传递新事实"的功能,而是在**启动既有的情绪框架进行动员**。详见 `reference/code_words_and_euphemisms.md`。
---
## 关联分析
### 关联一:与 YouTube 政治频道的载体依赖
政治影响者与 YouTube 政治频道(详见 `entities/media/youtube_political_channels.md`)存在**寄生与共生关系**。多数网红依赖 YouTube 长影音建构论述框架,但 YouTube 的演算法偏好极化内容,迫使网红在"理性中立"的人设与"极端流量"的生存需求间挣扎。分析网红立场偏移时,常需回溯其 YouTube 流量结构的变化。
### 关联二:与社群媒体政治版图的位移
影响者是社群媒体政治版图(详见 `entities/media/social_media_political_landscape.md`)中的关键节点。过去版图由新闻媒体粉丝页主导,现在由个人 KOL 主导。权力中心从"机构"向"个人"转移,导致政治论述的"责任归属"变得模糊——网红闯祸,政党切割;政党政策失误,网红甩锅给体制。
### 关联三:与传统政论节目的人才旋转门
电视政论节目(详见 `entities/media/political_talk_shows_overview.md`)与网络影响者之间存在"旋转门"。名嘴在电视上点火,网红在网络上添柴;网红在网络上爆红,电视台邀请上节目扩大声量。两者形成"电视固老,网络打青"的跨世代分工,但也导致政治语言的劣化——电视名嘴网络化(更极端),网红电视化(更表演)。
### 关联四:与媒体政治光谱的极化驱动
政治影响者的兴起,正在撕裂传统的媒体政治光谱(详见 `entities/media/media_political_spectrum.md`)。传统光谱由报纸与电视台主导,存在隐形的中间地带;网红生态的演算法逻辑则要求绝对的敌我界线,导致光谱两端膨胀、中间萎缩。网红不是反映光谱,而是**重塑光谱**。
### 关联五:与年轻世代政治疏离的替代性出口
年轻世代的政治疏离(详见 `narratives/domestic/youth_political_alienation.md`)是网红政治的温床。年轻人对传统政治体制失望,转而将政治效能感寄托于网红的"嘴炮"与"嘲讽"。网红提供了"我不参与体制,但我能影响体制"的幻觉。分析时必须区分:网红是缓解了政治疏离,还是将其转化为更虚无的情绪消费?
### 关联六:与地下金流的隐蔽性
网红的业配与置入,比传统媒体更不透明。政治人物或阵营可透过"无痕业配"植入讯息。加上斗内机制的洗钱风险,网红的金流是当前台湾媒体生态中最不透明的黑箱。分析网红立场时,必须假设其背后存在未曝光的利益交换,而非纯粹的意识形态驱动。
---
## 常见的分析陷阱
### 陷阱一:把网红流量等同于选票
高订阅数与高互动率不等于高投票率。许多观众是"看爽的"而非"支持者"。将网红流量直接换算为政治实力,会严重高估其选举影响力。
### 陷阱二:忽略受众的年龄与身份断层
误以为网络原生 KOL 能影响中老年选民,或误以为电视名嘴能吸引年轻首投族。不同类型影响者的受众断层极为明显,跨圈层影响力极弱。
### 陷阱三:假设网红有自主性
多数政治网红的表态,背后都有明盘或暗盘的利益交换。假设网红是"独立思考的个体",忽略其作为"传声筒"或"侧翼"的功能性角色,会导致分析失焦。
### 陷阱四:将"被骂"等同于"影响力衰退"
在流量逻辑下,被对手阵营围剿是网红巩固铁粉的最佳时机。分析时,不能将网络上的负评风暴等同于该网红或其支持阵营的溃败,这常常是反向动员的起点。
### 陷阱五:高估"中道/理性"网红的动员力
在演算法惩罚温和的逻辑下,标榜理性客观的网红往往流量远低于极端网红。分析选举动员时,若过度加权理性网红的论述,会忽略真正驱动基本盘投票的"情绪引擎"。
### 陷阱六:忽略 Threads/X 等短图文平台的"议题设定"功能
短图文平台的流量虽不及 YouTube,但因其即时性与高互动性,常是政治议题的"发源地"与"风向测试场"。忽略短图文平台,等于只看到了选举空战的后半段。
---
## 仍不确定的部分
- **平台演算法未来的调整方向**:若 YouTube 或 Meta 大幅调整推荐机制(如降低政治内容权重),将直接重塑网红生态,但目前无法预测其政策时机与力度。
- **AI 生成内容对网红信任机制的冲击**:2028 年前,深伪技术是否会导致受众对所有网红内容的信任度崩塌,进而回归传统媒体,仍是高度不确定变量。
- **年轻世代对"政治娱乐化"的疲劳期**:当前年轻选民对嘲讽戏谑的政治消费模式是否会产生疲劳?何时会转向要求实质政策讨论?此转折点无法预测。
- **微型 KOL 网络的实际动员效率**:政党建立的微型 KOL 蜂群网络,在实战中的选票转换率仍缺乏实证数据支撑。
- **隐藏的业配与金流规模**:各阵营透过网红进行隐蔽性资讯操作的金额与规模,是分析其立场"红线"的最大黑箱。
---
## 与其他文件的关系
- YouTube 政治频道生态:`entities/media/youtube_political_channels.md` —— 用于分析网红长影音的框架建构与演算法极化逻辑
- 社群媒体政治版图:`entities/media/social_media_political_landscape.md` —— 用于定位网红在跨平台矩阵中的节点功能与短图文攻防
- 政论节目总览:`entities/media/political_talk_shows_overview.md` —— 用于对照电视名嘴与网络网红的旋转门机制与跨世代分工
- 媒体政治光谱:`entities/media/media_political_spectrum.md` —— 用于理解网红如何驱动光谱极化与权力中心转移
- 年轻世代政治疏离:`narratives/domestic/youth_political_alienation.md` —— 用于分析网红政治作为疏离感替代性出口的局限与风险
- 两岸婉辞与代码词:`reference/code_words_and_euphemisms.md` —— 用于识别网红信号词的隐含框架与情绪动员意图
---
## 一句话浓缩
> 台湾关键政治影响者是演算法赋权下的**去中心化情绪节点**,其核心功能不是"传递事实"而是**"框架植入"与"情绪变现"**。
> 分析时,**不要被流量数字与独立人设迷惑**,必须穿透至其**商业变现机制**与**政党共生结构**,才能理解为何极化与表演化是其生存的必然,而非偶然的病征。
---
last_updated: 2026-05-08
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff. Show more