
Baibaiaigc
- 24 installs
- 942 repo stars
- Updated May 15, 2026
- polehansen/baibaiaigc
Helps with ai & agent building tasks.
About
baibaiaigc is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- baibaiaigc
- AI & Agent Building
- AI-coding skill
Baibaiaigc by the numbers
- 24 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #9,876 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/polehansen/baibaiaigc --skill baibaiaigcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 942 |
| Last updated | May 15, 2026 |
| Repository | polehansen/baibaiaigc ↗ |
What it does
Helps with ai & agent building tasks.
Files
Paper AIGC Reducer
说明:仓库根目录下的这份 SKILL.md 就是本项目唯一的正式 skill 入口。对话 skill、脚本 API、Web 和 app 模式共用同一套 prompts、references、scripts 与记录约定。你是一名处理中文或英文论文、学术写作与技术文档的改写编辑。你的目标不是规避检测器,而是通过按模式定义的顺序改写,降低文本中的模板化、机械化和常见 AI 写作痕迹,让表达更自然,同时保持原意、事实、术语和结构稳定。
适用范围
当用户有以下需求时,必须调用本 skill:
- 降 AIGC
- 论文去 AI 味
- 人性化改写论文或技术文档
- 按多个提示词顺序改写同一段文本
- 多轮降低论文 AI 痕迹
关键约束
- 中文模式必须严格按顺序执行两轮改写,但每次调用本 skill 只执行其中一轮。
- 中文模式顺序固定为:
prompts/baibaiaigc1.md->prompts/baibaiaigc2.md,禁止跳轮或逆序。 - 英文模式只执行一轮,固定使用
prompts/baibaiaigc-en.md。 - 每一轮的输出,必须作为下一轮的输入,轮次之间通过“降 AIGC 记录”在多个对话中串联。
- 在开始本轮改写之前,必须先读取“降 AIGC 记录”,并结合当前模式自动选择本轮应使用的 prompt;如果记录中没有该文档,则中文模式默认本轮为第 1 轮,英文模式默认且仅执行第 1 轮。
- 在开始下一轮之前,必须先完成当前轮改写,不能提前综合后续轮次要求。
- 不允许将两份提示词总结成一个混合提示后一次性处理,也不允许在一次 skill 调用中合并多轮。
- 单轮内部不允许将整篇论文一次性整体改写,必须走项目现有的分块处理流程。
- 不得新增事实、数据、案例、文献、引文或实验结论。
- 必须保留原文的专业术语、逻辑关系、编号结构、段落结构和关键结论。
- 如果某一轮提示词与原文场景冲突,优先保留原文事实与论文语体,不要为了降 AIGC 牺牲准确性。
降 AIGC 记录
本 skill 依赖工作区根目录下的 finish/aigc_records.json 维护跨对话轮次状态。
- 记录至少要能恢复:文档标识、已经完成的轮次、每轮对应 prompt、每轮输入输出路径、manifest 路径。
- 如果当前文档不存在任何记录,则中文模式默认执行第 1 轮,英文模式默认执行唯一一轮。
- 如果当前文档已完成第 1 轮但未完成第 2 轮,则中文模式本次执行第 2 轮,并以上一轮输出作为输入。
- 如果当前文档已经完成当前 prompt profile 的全部轮次,则默认不再继续新的标准轮次。
- 每完成一轮,就立即写入或更新对应 round 记录。
推荐记录结构与 scripts/aigc_records.py 保持一致,例如:
{
"origin/毕业论文_原始_utf8.txt": {
"origin_path": "origin/毕业论文_原始_utf8.txt",
"rounds": [
{
"round": 1,
"prompt": "prompts/baibaiaigc1.md",
"prompt_profile": "cn",
"input_path": "origin/毕业论文_原始_utf8.txt",
"output_path": "finish/intermediate/毕业论文_原始_utf8_round1.txt",
"chunk_limit": 850,
"input_segment_count": 12,
"output_segment_count": 12,
"manifest_path": "finish/intermediate/毕业论文_原始_utf8_round1_manifest.json",
"timestamp": "2026-03-27T10:01:23Z"
}
]
}
}每次对话完成一轮降 AIGC 后,回复中需要明确提醒用户:如果希望对同一篇文档继续下一轮降重,应新开一个聊天窗口,在新对话中再次触发降 AIGC,本 skill 会依据“降 AIGC 记录”为该文档自动衔接到下一轮。
记录维护脚本
优先复用 scripts/aigc_records.py 管理 finish/aigc_records.json:
python scripts/aigc_records.py showpython scripts/aigc_records.py show origin/毕业论文_原始_utf8.txtpython scripts/aigc_records.py update-round <doc_id> <round> <prompt> <input_path> <output_path>
如果 app/Web 进入局部修订流程,记录里还可能出现:
revisionsrevision_numbertarget_paragraph_indexesbased_on_output_pathbased_on_manifest_path
这些字段属于当前项目已实现能力的一部分,不要在 skill 中忽略它们。
标准化流程优先走脚本
当前项目的标准化流程优先走现有脚本,而不是在对话中手工重写切块、记录维护和落盘逻辑。
相关职责如下:
scripts/skill_round_helper.py:服务对话 skill 模式,负责判定轮次、准备.txt/.docx输入、生成本轮output_text_path与manifest_path,并调用共享 round service。scripts/aigc_round_service.py:共享单轮处理引擎,负责读取 prompt、构建 manifest、逐块调用改写逻辑、还原文本、写入中间文件,并更新finish/aigc_records.json。scripts/run_aigc_round.py:服务脚本 API 模式,基于aigc_round_service.py读取输入文本并调用外部 OpenAI 兼容接口;当未提供完整 API 配置时,只允许显式--dry-run做切块与 prompt 校验。scripts/docx_pipeline.py:负责.docx与纯文本之间的提取和导出。
实现上的标准化流程以脚本实际行为为准:
- 输入文本先按段落切分,再按脚本内置规则继续拆块。
- 每个处理块逐块改写。
- 块结果按 manifest 还原为整篇文本。
- 本轮结果默认写入
finish/intermediate/。 - 记录默认写入
finish/aigc_records.json。
注意:当前实现会尽量按段落、句子和较自然的分隔位置切块,但在极长片段场景下,底层脚本仍可能继续做更细粒度拆分。不要在 skill 文案中承诺比代码更严格的切块保证。
对话模式与脚本模式边界
当用户在聊天框中直接提出“降 AIGC”“论文去 AI 味”“继续下一轮”“按记录接着改”等请求时,默认视为对话 skill 模式。
- 对话 skill 模式不要求用户提供
BAIBAIAIGC_API_KEY、BAIBAIAIGC_MODEL、BAIBAIAIGC_BASE_URL。 - 对话 skill 模式应优先复用
scripts/skill_round_helper.py和scripts/aigc_round_service.py的既有流程,不要在对话中临时发明新的切块、命名、记录或恢复规则。 - 只有当用户明确要求运行
scripts/run_aigc_round.py、要求走脚本/API/命令行批处理模式,或者要求生成相应脚本命令时,才进入脚本 API 模式讨论。 - 如果脚本 API 模式缺少完整配置,脚本应直接报错或只做显式
--dry-run;不要把这类缺参错误误表述成“对话 skill 模式也无法执行”。
如果只是需要确认当前文档会进入哪一轮、对应输入输出路径是什么,可以直接使用 scripts/skill_round_helper.py 中的 dump_round_plan(...) 查看。
输入处理
如果用户直接提供文本:直接处理。
如果用户提供文件路径:优先按工作区根目录下的 origin/ 目录理解输入文件位置,先读取文件内容,再根据“降 AIGC 记录”决定本次执行哪一轮改写。
如果用户没有提供明确文件路径,但任务明显是基于文件进行处理:默认到工作区根目录下的 origin/ 目录查找原始文件。
- 如果
origin/中存在对应原始文件:直接读取并继续执行。 - 如果
origin/中不存在对应原始文件:如果用户是在聊天中直接上传附件,则先自动保存到origin/chat-uploads/后继续执行;否则提示用户上传文件,或先将原始文件放入origin/目录,再继续执行。
如果用户上传的是 .docx 文件:按项目当前实现处理。
scripts/skill_round_helper.py会在需要时通过scripts/docx_pipeline.py的读写能力把.docx提取为finish/intermediate/*_extracted.txt再进入单轮处理。- 聊天中上传的
.txt/.docx会先自动落盘为origin/chat-uploads/下的受管源文件,并继续复用现有 records/intermediate 流程。 - 本轮处理中间结果默认以
.txt落在finish/intermediate/。 - 如果需要把结果再导出为
.docx,应复用现有脚本或 app/Web 导出流程,而不是假定每次对话都会自动生成最终.docx。
如果用户提供多段内容:逐段处理,但保持整体段落顺序和编号格式不变。
如果用户提供的是整篇论文或长文档:单轮内部也必须先走项目现有分块流程,不能整篇一次性改写。
执行流程
本 skill 的整体目标仍然是完成两轮顺序降 AIGC,但为了控制单次对话的上下文长度,每次调用本 skill 只执行其中一轮。两轮之间通过“降 AIGC 记录”和中间文件在多个对话中串联。
单次调用时,必须显式遵循以下模式:
读取降 AIGC 记录并确定当前文档应执行的轮次 -> 读取对应轮次的提示词 -> 读取当前文本(原始文件、上一轮结果,或 docx 提取出的中间 txt) -> 调用标准化脚本流程完成切块与恢复 -> 将本轮结果和 manifest 写入中间目录 -> 更新降 AIGC 记录 -> 在回复中提示如需下一轮需新开对话
其中,“中间目录”统一约定为工作区根目录下的 finish/intermediate/:
- 如果
finish/或finish/intermediate/不存在,先创建对应目录。 - 约定文件命名示例:
- 第 1 轮:
finish/intermediate/原文件名_round1.txt - 第 2 轮:
finish/intermediate/原文件名_round2.txt - 每一轮还应同时写出结构清单,例如
finish/intermediate/原文件名_round1_manifest.json。 - 当输入来自
.docx时,中间结果可以只以.txt形式落盘。
禁止使用以下做法:
- 先浏览两份提示词,再一次性给出综合改写结果。
- 把第二轮的规则提前应用到第一轮结果中。
- 跳过中间结果,直接从原文生成终稿。
第 1 轮
当“降 AIGC 记录”中尚未存在当前文档的记录时,默认本次执行第 1 轮。读取工作区文件 prompts/baibaiaigc1.md。
执行要求:
- 按该文件中的规则进行第一轮改写。
- 改写前优先通过现有脚本流程完成切块,不要在对话中手工重写切块逻辑。
- 优先处理论文和技术文档中的书面化、凝练化、过于整齐的表达。
- 保持字数不要明显膨胀。
- 生成“第 1 轮结果”,并按原段落结构还原后写入
finish/intermediate/中对应文件。
第 2 轮
当“降 AIGC 记录”中显示当前文档已完成第 1 轮但尚未完成第 2 轮时,本次执行第 2 轮。读取工作区文件 prompts/baibaiaigc2.md。
将“第 1 轮结果”作为输入,执行第二轮改写。
执行要求:
- 重点清除 AI 套话、空泛提升、宣传腔、机械连接词、三段式列举、否定式排比和破折号滥用。
- 进一步调整句式节奏,让文本更自然。
- 生成“第 2 轮结果”,并按原段落结构还原后写入
finish/intermediate/中对应文件。
局部续跑与修订
当前项目除了标准的 1 -> 2 顺序处理外,还支持基于已有中间结果的局部续跑与修订能力,主要供 app/Web 使用:
current_round_revision:在同一轮结果上,对指定段落生成revN修订版。next_round_partial:基于上一轮结果,仅对选定段落进入下一轮处理。
这些模式依赖 scripts/skill_round_helper.py、scripts/aigc_round_service.py 和记录文件中的额外字段。如果用户没有明确要求局部续跑或修订,默认仍按标准整轮流程处理。
输出文件
- 单轮 skill 处理的标准落盘位置是工作区根目录
finish/intermediate/。 - 如果需要导出最终文本或
.docx,优先复用现有 app/Web 导出流程或scripts/docx_pipeline.py,其输出通常位于finish/或finish/web_exports/。 - 除非用户明确要求其他文件名,否则应沿用项目现有命名约定,不要在对话中自创另一套文件布局。
输出格式
文本直接输入场景
当用户是直接在对话框里粘贴一段(或多段)待改写文本时,默认输出当前这一轮的改写结果。可以按需补充非常简短的说明,但不要强制附带项目中未自动生成的评分表。
如果用户要求展示过程,可以额外提供:
1. 第 1 轮结果 2. 第 2 轮结果
默认不要主动展示中间轮次全文。
基于文件的场景
当用户给出的是文件路径(尤其是 origin/ 目录下的论文、报告等),默认以“单轮处理中间结果”为主:
1. 本轮正文会写入 finish/intermediate/ 下对应输出文件。 2. 对话中可以简要告知当前轮次、输入输出路径和是否需要新开对话继续下一轮。 3. 如果用户明确要求查看正文,再决定是否在对话中展开;否则优先引用落盘文件路径。
工作原则
- 重写时优先做减法,去掉明显 AI 痕迹,而不是无节制扩写。
- 改写后的文本需要在大声朗读时听起来自然。
- 句子结构要有变化,但不能破坏逻辑。
- 优先使用具体表达,少用模糊判断。
- 适当使用简单句式,不要为了显得复杂而复杂。
- 若原文本身已经较自然,应最小化修改。
交付前自检
在输出前,必须确认:
- 对于已完成的文档,已通过多次对话完成 2 轮顺序处理,每次调用本 skill 只执行一轮。
- 中间轮次在时间上是串行完成的,而不是在单次调用中合并处理的。
- 如需标准化切块、恢复、记录更新,已优先复用项目内现有脚本流程。
- 如果输入是
.docx,已通过项目现有.docx提取/导出能力处理,而不是把.docx当普通文本读取。 - 如需输出文件,结果已写入项目约定目录。
- 未编造信息。
- 未破坏原有术语和结论。
- 最终文本自然、克制、符合论文语体。
推荐调用方式
当用户没有给出特殊格式要求时,按以下方式理解任务:
- 输入是一段或多段待改写文本,或
origin/中的一篇论文/报告文件。 - 每次调用本 skill 只执行一轮降 AIGC,通过“降 AIGC 记录”在多次对话中按 1 -> 2 顺序推进。
- 对于文本直接输入场景,默认交付当前这一轮的改写结果;如果用户明确要求看中间版本、局部修订或导出文件,再按项目现有能力补充处理。
如果用户明确说“只给终稿”,则只输出本轮正文;无论如何,每次完成一轮后都要提醒用户:如需继续下一轮降重,请新开一个聊天窗口再次调用本 skill。
# Python
__pycache__/
*.py[cod]
.venv/
# Node / Vite
app/node_modules/
app/dist/
# Tauri / Rust build output
app/src-tauri/target/
# Generated runtime outputs
finish/
origin/
# Editor / local config
.vscode/
.agents/
# OS junk
.DS_Store
Thumbs.db
# GitHub metadata (CI, skills, etc.)
.github/
/.idea
tests/
.codex/
dev-doc/
interface:
display_name: "baibaiAIGC"
short_description: "多轮降 AIGC 改写 skill,支持中文两轮与英文单轮"
default_prompt: "Use $baibaiaigc to 对当前论文或技术文本执行当前应执行的一轮降 AIGC 改写,并按记录衔接后续轮次。"
policy:
allow_implicit_invocation: true
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>baibaiAIGC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "baibaiaigc-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:web": "vite --mode web",
"build": "tsc && vite build",
"build:web": "tsc && vite build --mode web",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-dialog": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@tauri-apps/cli": "2.10.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}fn main() {
tauri_build::build()
}{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Allow the main window to use core IPC commands and dialog open/save operations.",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save"
]
}[package]
name = "baibaiaigc_app"
version = "0.1.0"
description = "Baibai AIGC desktop app"
authors = ["GitHub Copilot"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-dialog = "2"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
{"main-capability":{"identifier":"main-capability","description":"Allow the main window to use core IPC commands and dialog open/save operations.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save"]}}#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tauri::async_runtime::spawn_blocking;
use tauri::{Emitter, Window};
const ROUND_PROGRESS_EVENT: &str = "round-progress";
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TestConnectionResult {
ok: bool,
offline_mode: bool,
message: String,
endpoint: String,
model: String,
api_type: Option<String>,
status: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PythonEventEnvelope {
event: String,
payload: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelConfig {
base_url: String,
api_key: String,
model: String,
api_type: String,
temperature: f64,
offline_mode: bool,
prompt_profile: String,
}
fn workspace_root() -> Result<PathBuf, String> {
let current_dir = std::env::current_dir().map_err(|error| error.to_string())?;
if current_dir.ends_with("app") {
current_dir.parent().map(Path::to_path_buf).ok_or_else(|| "Cannot resolve workspace root".to_string())
} else if current_dir.ends_with(Path::new("app").join("src-tauri")) {
current_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or_else(|| "Cannot resolve workspace root".to_string())
} else {
Ok(current_dir)
}
}
fn script_path(root: &Path, relative_path: &str) -> String {
root.join(relative_path).to_string_lossy().replace('\\', "\\\\")
}
fn python_executable(root: &Path) -> PathBuf {
let venv_python = root.join(".venv").join("Scripts").join("python.exe");
if venv_python.exists() {
return venv_python;
}
PathBuf::from("python")
}
fn run_python_json(args: &[String]) -> Result<String, String> {
let root = workspace_root()?;
let python = python_executable(&root);
let mut command = Command::new(python);
command.current_dir(&root);
command.env("PYTHONIOENCODING", "utf-8");
command.env("PYTHONUTF8", "1");
command.arg("scripts/app_service.py");
for arg in args {
command.arg(arg);
}
let output = command.output().map_err(|error| error.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() { stderr } else { stdout };
return Err(if message.is_empty() { "Python command failed".to_string() } else { message });
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_python_json_streaming(window: Window, args: &[String]) -> Result<serde_json::Value, String> {
let root = workspace_root()?;
let python = python_executable(&root);
let mut command = Command::new(python);
command.current_dir(&root);
command.env("PYTHONIOENCODING", "utf-8");
command.env("PYTHONUTF8", "1");
command.arg("scripts/app_service.py");
for arg in args {
command.arg(arg);
}
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|error| error.to_string())?;
let stdout = child.stdout.take().ok_or_else(|| "Cannot capture Python stdout".to_string())?;
let stderr = child.stderr.take().ok_or_else(|| "Cannot capture Python stderr".to_string())?;
let mut final_payload: Option<serde_json::Value> = None;
for line in BufReader::new(stdout).lines() {
let raw_line = line.map_err(|error| error.to_string())?;
let trimmed = raw_line.trim();
if trimmed.is_empty() {
continue;
}
let envelope: PythonEventEnvelope = serde_json::from_str(trimmed).map_err(|error| {
format!("Failed to parse Python event: {error}; line: {trimmed}")
})?;
match envelope.event.as_str() {
"round-progress" => {
window
.emit(ROUND_PROGRESS_EVENT, envelope.payload)
.map_err(|error| error.to_string())?;
}
"result" => {
final_payload = Some(envelope.payload);
}
"error" => {
let message = envelope
.payload
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Python command failed")
.to_string();
return Err(message);
}
other => {
return Err(format!("Unsupported Python event: {other}"));
}
}
}
let stderr_output = {
let mut buffer = String::new();
let mut reader = BufReader::new(stderr);
loop {
let mut line = String::new();
let bytes = reader.read_line(&mut line).map_err(|error| error.to_string())?;
if bytes == 0 {
break;
}
buffer.push_str(&line);
}
buffer.trim().to_string()
};
let status = child.wait().map_err(|error| error.to_string())?;
if !status.success() {
return Err(if stderr_output.is_empty() {
"Python command failed".to_string()
} else {
stderr_output
});
}
final_payload.ok_or_else(|| "Python command completed without result payload".to_string())
}
fn run_python_inline(code: &str) -> Result<String, String> {
let root = workspace_root()?;
let python = python_executable(&root);
let output = Command::new(python)
.current_dir(&root)
.env("PYTHONIOENCODING", "utf-8")
.env("PYTHONUTF8", "1")
.arg("-c")
.arg(code)
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() { stderr } else { stdout };
return Err(if message.is_empty() { "Python command failed".to_string() } else { message });
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[tauri::command]
async fn load_model_config() -> Result<ModelConfig, String> {
spawn_blocking(move || {
let root = workspace_root()?;
let output = run_python_inline(
&format!(
"import json, runpy; module = runpy.run_path(r'{}'); print(json.dumps(module['load_app_config'](), ensure_ascii=False))",
script_path(&root, "scripts/app_config.py")
),
)?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn save_model_config(config: ModelConfig) -> Result<ModelConfig, String> {
spawn_blocking(move || {
let root = workspace_root()?;
let config_json = serde_json::to_string(&config).map_err(|error| error.to_string())?;
let output = run_python_inline(&format!(
"import json, runpy; module = runpy.run_path(r'{}'); print(json.dumps(module['save_app_config'](json.loads(r'''{}''')), ensure_ascii=False))",
script_path(&root, "scripts/app_config.py"),
config_json
))?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn test_model_connection(config: ModelConfig) -> Result<TestConnectionResult, String> {
spawn_blocking(move || {
let config_json = serde_json::to_string(&config).map_err(|error| error.to_string())?;
let output = run_python_json(&[
"test-connection".to_string(),
config_json,
])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn get_document_status(source_path: String, prompt_profile: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["document-status".to_string(), source_path, prompt_profile])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn get_document_history(source_path: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["document-history".to_string(), source_path])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn list_document_histories() -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["document-history-list".to_string()])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn delete_document_history(doc_id: String, from_round: Option<i32>) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let mut args = vec!["delete-document-history".to_string(), doc_id];
if let Some(round) = from_round {
args.push("--from-round".to_string());
args.push(round.to_string());
}
let output = run_python_json(&args)?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn request_stop(source_path: String, prompt_profile: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["request-stop".to_string(), source_path, prompt_profile])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn run_aigc_round(
window: Window,
source_path: String,
model_config: ModelConfig,
execution_options: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let config_json = serde_json::to_string(&model_config).map_err(|error| error.to_string())?;
let mut args = vec![
"run-round".to_string(),
source_path,
config_json,
];
if let Some(options) = execution_options {
args.push("--execution-options-json".to_string());
args.push(serde_json::to_string(&options).map_err(|error| error.to_string())?);
}
run_python_json_streaming(window, &args)
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn read_output_text(output_path: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["read-output".to_string(), output_path])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn read_output_preview(output_path: String, manifest_path: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["read-output-preview".to_string(), output_path, manifest_path])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn read_source_preview(input_path: String, manifest_path: String, prompt_profile: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&["read-source-preview".to_string(), input_path, manifest_path, prompt_profile])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn export_round_output(output_path: String, export_path: String, target_format: String) -> Result<serde_json::Value, String> {
spawn_blocking(move || {
let output = run_python_json(&[
"export-round".to_string(),
output_path,
export_path,
target_format,
])?;
serde_json::from_str(&output).map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
load_model_config,
save_model_config,
test_model_connection,
get_document_status,
get_document_history,
list_document_histories,
delete_document_history,
request_stop,
run_aigc_round,
read_output_text,
read_output_preview,
read_source_preview,
export_round_output,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "baibaiAIGC",
"version": "0.1.0",
"identifier": "com.baibaiaigc.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist",
"devUrl": "http://localhost:1420"
},
"app": {
"windows": [
{
"title": "baibaiAIGC",
"width": 1440,
"height": 980,
"minWidth": 1180,
"minHeight": 820,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' asset: http://asset.localhost https://asset.localhost data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
}
},
"bundle": {
"active": false,
"targets": "msi",
"windows": {
"wix": {
"language": "zh-CN"
}
}
}
}import { useEffect, useRef, useState } from "react";
import { DocumentCard } from "./components/DocumentCard";
import { HistoryCard } from "./components/HistoryCard";
import { ModelConfigCard } from "./components/ModelConfigCard";
import { ResultCard } from "./components/ResultCard";
import { useAppState, type ActivePreview } from "./hooks/useAppState";
import type { AppService } from "./lib/appService";
import type {
ApplyMode,
HistoryDocumentSummary,
HistoryRevision,
HistoryRound,
RoundProgress,
RunExecutionOptions,
} from "./types/app";
type Props = {
service: AppService;
pickerLabel?: string;
};
type PageKey = "workspace" | "history" | "result";
const PAGE_META: Array<{ key: PageKey; title: string; description: string }> = [
{ key: "workspace", title: "文档工作台", description: "模型设置、导入文档和整轮续跑" },
{ key: "history", title: "历史记录", description: "查看轮次结果、修订版与导出记录" },
{ key: "result", title: "预览", description: "按段落选择后生成修订版或下一轮局部处理" },
];
function formatRuntimeStep(progress: RoundProgress | null, fallback: string): string {
if (!progress) {
return fallback;
}
if (progress.phase === "chunk-error") {
return `第 ${progress.round} 轮已暂停,第 ${progress.currentChunk}/${progress.totalChunks} 块处理失败`;
}
if (progress.phase === "processing-chunk" && progress.currentChunk && progress.totalChunks) {
return `正在执行第 ${progress.round} 轮,第 ${progress.currentChunk}/${progress.totalChunks} 块`;
}
if (progress.phase === "chunking-ready" && progress.totalChunks) {
const prefix = progress.resumed ? "已恢复断点" : "已完成切块";
const completed = progress.completedChunks ? `,已完成 ${progress.completedChunks} 块` : "";
return `第 ${progress.round} 轮${prefix},共 ${progress.totalChunks} 块${completed},准备开始处理`;
}
if (progress.phase === "chunk-skipped" && progress.currentChunk && progress.totalChunks) {
return `第 ${progress.round} 轮跳过已完成块,第 ${progress.currentChunk}/${progress.totalChunks} 块已复用`;
}
if (progress.phase === "restoring-output") {
return `第 ${progress.round} 轮分块处理完成,正在恢复完整输出`;
}
if (progress.phase === "chunk-complete" && progress.currentChunk && progress.totalChunks) {
return `第 ${progress.round} 轮已完成第 ${progress.currentChunk}/${progress.totalChunks} 块`;
}
if (progress.phase === "stopped") {
return progress.message || `第 ${progress.round} 轮已停止,可从当前进度继续`;
}
return fallback;
}
function describeDocumentProgress(nextRound: number | null, hasNextRound: boolean): string {
if (hasNextRound && nextRound) {
return `当前可执行第 ${nextRound} 轮。`;
}
return "当前文档已完成全部轮次。";
}
function describeProgressStatus(status: string): string {
if (status === "completed") {
return "已完成";
}
if (status === "in_progress") {
return "处理中";
}
if (status === "paused") {
return "已暂停,等待手动继续";
}
if (status === "stopped") {
return "已停止,可从当前进度继续";
}
return "未开始";
}
function describePromptProfile(promptProfile: "cn" | "en"): string {
return promptProfile === "en" ? "英文单轮提示词" : "中文双轮提示词";
}
function buildActivePreviewFromVersion(
label: string,
item: HistoryRound | HistoryRevision,
preview: Awaited<ReturnType<AppService["readOutputPreview"]>>,
): ActivePreview {
return {
label,
round: item.kind === "revision" ? item.sourceRound ?? item.targetRound ?? 0 : item.round,
revisionNumber: item.kind === "revision" ? item.revisionNumber : item.revisionNumber ?? null,
outputPath: item.outputPath,
manifestPath: item.manifestPath,
kind: item.kind,
sourceRound: item.kind === "revision" ? item.sourceRound ?? item.targetRound ?? 0 : item.round,
preview,
};
}
function buildDisplayNameMap(items: HistoryDocumentSummary[], currentDocId: string | null, currentDisplayName: string | null): Map<string, string> {
const usageCount = new Map<string, number>();
const displayMap = new Map<string, string>();
const orderedItems = items.map((item) => ({
docId: item.docId,
rawName: item.displayName || item.originPath || item.sourcePath || item.docId,
}));
if (currentDocId && currentDisplayName && !orderedItems.some((item) => item.docId === currentDocId)) {
orderedItems.unshift({ docId: currentDocId, rawName: currentDisplayName });
}
orderedItems.forEach((item) => {
const nextIndex = usageCount.get(item.rawName) ?? 0;
usageCount.set(item.rawName, nextIndex + 1);
displayMap.set(item.docId, nextIndex === 0 ? item.rawName : `${item.rawName}(${nextIndex})`);
});
return displayMap;
}
export function App({ service, pickerLabel }: Props) {
const progressUnlistenRef = useRef<null | (() => void)>(null);
const [stopBusy, setStopBusy] = useState(false);
const [currentPage, setCurrentPage] = useState<PageKey>("workspace");
const {
modelConfig,
documentStatus,
history,
historyItems,
historyPanelOpen,
roundResult,
progress,
activePreview,
selectedParagraphIndexes,
runtimeStep,
notice,
busy,
error,
setModelConfig,
setDocumentStatus,
setHistory,
setHistoryItems,
setHistoryPanelOpen,
setRoundResult,
setProgress,
setPreviewText,
setActivePreview,
setSelectedParagraphIndexes,
setRuntimeStep,
setNotice,
setBusy,
setError,
} = useAppState();
const displayNameMap = buildDisplayNameMap(historyItems, documentStatus?.docId ?? null, documentStatus?.displayName ?? null);
const currentDisplayName = documentStatus ? (displayNameMap.get(documentStatus.docId) || documentStatus.displayName || documentStatus.docId) : undefined;
useEffect(() => {
service.loadModelConfig()
.then((config) => setModelConfig(config))
.catch((appError: unknown) => setError(String(appError)));
}, [service, setError, setModelConfig]);
useEffect(() => {
service.listDocumentHistories()
.then((result) => setHistoryItems(result.items))
.catch((appError: unknown) => setError(String(appError)));
}, [service, setError, setHistoryItems]);
useEffect(() => {
return () => {
progressUnlistenRef.current?.();
progressUnlistenRef.current = null;
};
}, []);
async function refreshDocumentState(sourcePath: string, config = modelConfig) {
const [status, nextHistory] = await Promise.all([
service.getDocumentStatus(sourcePath, config),
service.getDocumentHistory(sourcePath),
]);
setDocumentStatus(status);
setHistory(nextHistory);
return status;
}
async function refreshHistoryList() {
const result = await service.listDocumentHistories();
setHistoryItems(result.items);
return result.items;
}
function clearPreviewSelection() {
setSelectedParagraphIndexes([]);
}
async function handleSelectHistory(item: HistoryDocumentSummary) {
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep("正在加载历史文档");
const status = await refreshDocumentState(item.sourcePath);
setCurrentPage("workspace");
setRoundResult(null);
setPreviewText("");
setActivePreview(null);
clearPreviewSelection();
setNotice(`已切换到历史文档,${describeDocumentProgress(status.nextRound, status.hasNextRound)}`);
setRuntimeStep(
status.hasNextRound && status.nextRound
? `已加载历史文档,当前可执行第 ${status.nextRound} 轮`
: "已加载历史文档,全部轮次已完成",
);
} catch (appError) {
setError(String(appError));
setRuntimeStep("加载历史文档失败");
} finally {
setBusy(false);
}
}
async function handleDeleteHistory(docId: string, fromRound?: number) {
const actionLabel = fromRound ? `删除第 ${fromRound} 轮及之后的历史` : "删除整条历史";
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep(`正在${actionLabel}`);
const result = await service.deleteDocumentHistory(docId, fromRound);
const items = await refreshHistoryList();
if (documentStatus?.docId === docId) {
if (result.removedDocument) {
setDocumentStatus(null);
setHistory(null);
setRoundResult(null);
setPreviewText("");
setActivePreview(null);
clearPreviewSelection();
} else {
const matchedItem = items.find((entry) => entry.docId === docId);
if (matchedItem) {
await refreshDocumentState(matchedItem.sourcePath);
setRoundResult(null);
setPreviewText("");
setActivePreview(null);
clearPreviewSelection();
}
}
}
const deletedText = result.deletedRounds.length
? `已删除轮次:${result.deletedRounds.join(", ")}`
: "没有匹配到可删除的轮次";
setNotice(result.removedDocument ? `历史已删除。${deletedText}` : `历史已更新。${deletedText}`);
setRuntimeStep(result.removedDocument ? "历史删除完成" : "历史回滚完成");
} catch (appError) {
setError(String(appError));
setRuntimeStep(`${actionLabel}失败`);
} finally {
setBusy(false);
}
}
async function handleSaveModelConfig() {
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep("正在保存模型设置");
const saved = await service.saveModelConfig(modelConfig);
setModelConfig(saved);
if (documentStatus) {
await refreshDocumentState(documentStatus.sourcePath, saved);
}
setNotice(`模型设置已保存到本地,当前模式为 ${describePromptProfile(saved.promptProfile)}。`);
setRuntimeStep("模型设置已保存");
} catch (appError) {
setError(String(appError));
setRuntimeStep("保存模型设置失败");
} finally {
setBusy(false);
}
}
async function handleTestConnection() {
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep(modelConfig.offlineMode ? "离线模式无需测试接口" : "正在测试接口连通性");
const result = await service.testModelConnection(modelConfig);
setNotice(
result.message
+ (result.apiType ? ` 类型:${result.apiType}` : "")
+ (result.endpoint ? ` 接口:${result.endpoint}` : ""),
);
setRuntimeStep(result.offlineMode ? "离线模式已确认" : "接口连通性测试成功");
} catch (appError) {
setError(String(appError));
setRuntimeStep("接口连通性测试失败");
} finally {
setBusy(false);
}
}
async function handlePickFile() {
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep("正在选择并读取文档");
const picked = await service.pickInputFile();
if (!picked) {
setNotice("已取消选择文档。");
setRuntimeStep("待命");
return;
}
const status = await refreshDocumentState(picked.sourcePath);
await refreshHistoryList();
const preview = await service.readSourcePreview(status.currentInputPath, status.manifestPath, modelConfig.promptProfile);
setCurrentPage("result");
setHistoryPanelOpen(true);
setRoundResult(null);
setPreviewText(preview.text);
setActivePreview({
label: "初始预览",
round: 0,
revisionNumber: null,
outputPath: status.currentInputPath,
manifestPath: status.manifestPath,
kind: "round",
sourceRound: 0,
preview,
});
clearPreviewSelection();
setRuntimeStep(
status.hasNextRound && status.nextRound
? `已载入文档,当前可执行第 ${status.nextRound} 轮`
: "已载入文档,全部轮次已完成",
);
const resumeNotice = status.canResume && status.totalChunkCount && status.completedChunkCount
? `检测到第 ${status.nextRound} 轮已有 ${status.completedChunkCount}/${status.totalChunkCount} 块进度,可直接续跑。`
: "";
const partialNotice = status.targetParagraphIndexes.length
? ` 当前断点仅处理 ${status.targetParagraphIndexes.length} 段。`
: "";
const errorNotice = status.lastError ? ` 当前暂停原因:${status.lastError}` : "";
const stopNotice = status.stopReason ? ` 当前停止说明:${status.stopReason}` : "";
setNotice(
`已导入文档,当前使用 ${describePromptProfile(modelConfig.promptProfile)},${describeDocumentProgress(status.nextRound, status.hasNextRound)}${resumeNotice}${partialNotice}${errorNotice}${stopNotice}`,
);
} catch (appError) {
setError(String(appError));
setRuntimeStep("读取文档失败");
} finally {
setBusy(false);
}
}
async function handleStopRound() {
if (!documentStatus || !busy) {
return;
}
try {
setStopBusy(true);
setError("");
setNotice("已发送停止请求,当前块处理完成后会停下。");
setRuntimeStep("停止请求已发送,等待当前块收尾");
const status = await service.requestStop(documentStatus.sourcePath, modelConfig);
setDocumentStatus(status);
} catch (appError) {
setError(String(appError));
setRuntimeStep("发送停止请求失败");
} finally {
setStopBusy(false);
}
}
async function executeRound(executionOptions?: RunExecutionOptions | null) {
if (!documentStatus) {
setNotice("请先导入一个 txt 或 docx 文档。");
return;
}
if (!documentStatus.hasNextRound && !executionOptions) {
setNotice("当前文档已完成全部轮次,如需重跑请先从历史记录回滚。");
return;
}
try {
setBusy(true);
setStopBusy(false);
setError("");
setNotice("");
setProgress(null);
progressUnlistenRef.current?.();
const runToken = await service.startRunRound(documentStatus.sourcePath, modelConfig, executionOptions);
await refreshHistoryList();
progressUnlistenRef.current = await service.listenRoundProgress((nextProgress) => {
setProgress(nextProgress);
setRuntimeStep(formatRuntimeStep(nextProgress, "处理中"));
if (nextProgress.phase === "chunk-error") {
setNotice(nextProgress.error || "本轮已暂停,请检查网络或模型接口后手动继续。");
}
if (nextProgress.phase === "stopped") {
setNotice(nextProgress.message || "已按你的请求停止,当前进度已保留。");
}
}, runToken);
const runLabel = executionOptions?.applyMode === "current_round_revision"
? `准备生成第 ${executionOptions.targetRound} 轮修订版`
: executionOptions?.applyMode === "next_round_partial"
? `准备执行第 ${executionOptions.targetRound} 轮局部处理`
: `准备执行第 ${documentStatus.nextRound} 轮`;
setRuntimeStep(runLabel);
const result = await service.awaitRunRound(documentStatus.sourcePath, modelConfig, runToken, executionOptions);
progressUnlistenRef.current?.();
progressUnlistenRef.current = null;
setProgress(null);
setRoundResult(result);
setPreviewText(result.paragraphs.map((paragraph) => paragraph.text).join("\n\n"));
setActivePreview({
label: result.revisionNumber ? `第 ${result.round} 轮 / 修订 ${result.revisionNumber}` : "当前最新结果",
round: result.round,
revisionNumber: result.revisionNumber ?? null,
outputPath: result.outputPath,
manifestPath: result.manifestPath,
kind: "current-result",
sourceRound: result.sourceRound ?? result.round,
preview: {
path: result.outputPath,
text: result.paragraphs.map((paragraph) => paragraph.text).join("\n\n"),
paragraphs: result.paragraphs,
},
});
clearPreviewSelection();
const status = await refreshDocumentState(documentStatus.sourcePath);
await refreshHistoryList();
setCurrentPage("result");
setHistoryPanelOpen(true);
setRuntimeStep(
status.hasNextRound && status.nextRound
? `第 ${result.round} 轮完成,下一步可执行第 ${status.nextRound} 轮`
: `第 ${result.round} 轮完成,当前文档全部轮次已结束`,
);
if (executionOptions?.applyMode === "current_round_revision") {
setNotice(`第 ${result.round} 轮修订版已生成,本次处理了 ${result.targetParagraphIndexes.length} 段。`);
} else if (executionOptions?.applyMode === "next_round_partial") {
setNotice(`第 ${result.round} 轮局部处理已完成,本次处理了 ${result.targetParagraphIndexes.length} 段。`);
} else {
setNotice(
status.hasNextRound
? `第 ${result.round} 轮已完成${result.resumed ? ",本次为断点续跑" : ""},可以继续导出或进入下一轮。`
: `第 ${result.round} 轮已完成${result.resumed ? ",本次为断点续跑" : ""},当前文档的全部轮次已结束,可以直接导出。`,
);
}
} catch (appError) {
progressUnlistenRef.current?.();
progressUnlistenRef.current = null;
const latestStatus = await refreshDocumentState(documentStatus.sourcePath).catch(() => null);
await refreshHistoryList().catch(() => null);
const interruptedMessage = latestStatus?.status === "interrupted"
? latestStatus.lastError || latestStatus.stopReason || "网络异常或模型请求失败,当前轮已中断,可继续续跑。"
: "";
const stoppedMessage = latestStatus?.progressStatus === "stopped"
? latestStatus.stopReason || "已按你的请求停止,当前进度已保留。"
: "";
setProgress(null);
setError(interruptedMessage && !stoppedMessage ? interruptedMessage : stoppedMessage ? "" : String(appError));
setNotice(
interruptedMessage
? `已中断在第 ${latestStatus?.targetRound ?? latestStatus?.nextRound ?? documentStatus.nextRound} 轮,保留已完成进度,可随时继续。`
: stoppedMessage
? `已停止在第 ${latestStatus?.targetRound ?? latestStatus?.nextRound ?? documentStatus.nextRound} 轮,保留已完成进度,可随时继续。`
: "",
);
setRuntimeStep(
interruptedMessage
? "执行已中断,等待手动继续"
: stoppedMessage
? "执行已停止,等待手动继续"
: "执行轮次失败",
);
} finally {
setStopBusy(false);
setBusy(false);
}
}
async function handleRunRound() {
await executeRound(null);
}
async function handleHistoryDownload(item: HistoryRound | HistoryRevision, targetFormat: "txt" | "docx") {
if (!item.outputPath) {
setNotice("当前历史记录没有可导出的输出路径。");
return;
}
try {
setBusy(true);
setError("");
setNotice("");
const label = item.kind === "revision"
? `第 ${item.sourceRound ?? item.targetRound} 轮修订 ${item.revisionNumber}`
: `第 ${item.round} 轮`;
setRuntimeStep(`正在导出 ${label} ${targetFormat.toUpperCase()}`);
const result = await service.exportRound(item.outputPath, targetFormat);
setNotice(`${label} 已导出 ${result.format.toUpperCase()}:${result.path}`);
setRuntimeStep(`${label} 导出完成`);
} catch (appError) {
setError(String(appError));
setRuntimeStep("导出失败");
} finally {
setBusy(false);
}
}
async function handlePreviewHistoryVersion(item: HistoryRound | HistoryRevision) {
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep("正在读取历史预览");
const preview = await service.readOutputPreview(item.outputPath, item.manifestPath);
const label = item.kind === "revision"
? `历史预览:第 ${item.sourceRound ?? item.targetRound} 轮 / 修订 ${item.revisionNumber}`
: `历史预览:第 ${item.round} 轮`;
setActivePreview(buildActivePreviewFromVersion(label, item, preview));
setPreviewText(preview.text);
clearPreviewSelection();
setCurrentPage("result");
setNotice("已打开历史版本预览,可以按段落选择并继续处理。");
setRuntimeStep("历史预览已加载");
} catch (appError) {
setError(String(appError));
setRuntimeStep("读取历史预览失败");
} finally {
setBusy(false);
}
}
async function handleExport(targetFormat: "txt" | "docx") {
const exportPath = activePreview?.outputPath ?? roundResult?.outputPath;
if (!exportPath) {
setNotice("请先打开一个可导出的结果预览。");
return;
}
try {
setBusy(true);
setError("");
setNotice("");
setRuntimeStep(`正在导出 ${targetFormat.toUpperCase()}`);
const result = await service.exportRound(exportPath, targetFormat);
setNotice(`已导出 ${result.format.toUpperCase()}:${result.path}`);
setRuntimeStep("导出完成");
} catch (appError) {
setError(String(appError));
setRuntimeStep("导出失败");
} finally {
setBusy(false);
}
}
function toggleParagraph(paragraphIndex: number) {
setSelectedParagraphIndexes(
selectedParagraphIndexes.includes(paragraphIndex)
? selectedParagraphIndexes.filter((value) => value !== paragraphIndex)
: [...selectedParagraphIndexes, paragraphIndex].sort((left, right) => left - right),
);
}
function buildExecutionOptions(applyMode: ApplyMode): RunExecutionOptions | null {
if (!activePreview || !selectedParagraphIndexes.length) {
setNotice("请先选择至少一个段落。");
return null;
}
const sourceRound = activePreview.round;
const targetRound = applyMode === "current_round_revision" ? sourceRound : sourceRound + 1;
return {
applyMode,
targetParagraphIndexes: selectedParagraphIndexes,
sourceRound,
targetRound,
basedOnOutputPath: activePreview.outputPath,
basedOnManifestPath: activePreview.manifestPath,
revisionNumber: activePreview.revisionNumber,
};
}
async function handleCreateRevision() {
if (activePreview?.kind === "round" && activePreview.round === 0) {
setNotice("初始预览还没有当前轮结果,请使用“在下一轮处理所选段落”。");
return;
}
const options = buildExecutionOptions("current_round_revision");
if (!options) {
return;
}
await executeRound(options);
}
async function handleRunNextPartial() {
const options = buildExecutionOptions("next_round_partial");
if (!options) {
return;
}
await executeRound(options);
}
const activePage = PAGE_META.find((item) => item.key === currentPage) ?? PAGE_META[0];
return (
<main className="app-shell">
<div className="hero-panel">
<div className="hero-copy-wrap">
<p className="eyebrow">baibaiAIGC</p>
<h1>段落级 AIGC 文稿处理工作台</h1>
</div>
<div className="hero-status-column">
<span className={`status-tag ${busy ? "" : "idle"}`}>
{busy ? (progress?.round ? `第 ${progress.round} 轮运行中` : "处理中") : "待命"}
</span>
<div className="hero-status-note">
<span>当前页面</span>
<strong>{activePage.title}</strong>
</div>
</div>
</div>
{error ? <div className="error-banner">{error}</div> : null}
{notice ? <div className="notice-banner">{notice}</div> : null}
<div className="runtime-log" aria-live="polite">
<span className="runtime-log-label">运行步骤</span>
<strong>{formatRuntimeStep(progress, runtimeStep)}</strong>
</div>
<nav className="page-switcher" aria-label="页面切换">
{PAGE_META.map((page) => (
<button
key={page.key}
type="button"
className={`page-tab ${page.key === currentPage ? "active" : ""}`}
onClick={() => setCurrentPage(page.key)}
>
<strong>{page.title}</strong>
<span>{page.description}</span>
</button>
))}
</nav>
<section className={`page-frame ${currentPage === "workspace" ? "" : "page-frame-compact"}`}>
{currentPage === "workspace" ? (
<div className="page-frame-head">
<div>
<p className="page-kicker">页面内容</p>
<h2>{activePage.title}</h2>
</div>
<p>{activePage.description}</p>
</div>
) : null}
{currentPage === "workspace" ? (
<div className="content-grid">
<ModelConfigCard
value={modelConfig}
busy={busy}
onChange={setModelConfig}
onSave={handleSaveModelConfig}
onTestConnection={handleTestConnection}
/>
<DocumentCard
value={documentStatus}
displayName={currentDisplayName}
busy={busy}
stopBusy={stopBusy}
onPickFile={handlePickFile}
onRunRound={handleRunRound}
onStop={handleStopRound}
pickerLabel={pickerLabel}
progressStatusLabel={documentStatus ? describeProgressStatus(documentStatus.progressStatus) : "未开始"}
/>
</div>
) : null}
{currentPage === "history" ? (
<HistoryCard
currentDocId={documentStatus?.docId ?? null}
currentHistory={history}
items={historyItems}
open={historyPanelOpen}
busy={busy}
embedded
onToggle={() => setHistoryPanelOpen(!historyPanelOpen)}
onSelect={handleSelectHistory}
onDelete={handleDeleteHistory}
onDownload={handleHistoryDownload}
onPreview={handlePreviewHistoryVersion}
/>
) : null}
{currentPage === "result" ? (
<ResultCard
result={roundResult}
activePreview={activePreview}
selectedParagraphIndexes={selectedParagraphIndexes}
busy={busy}
onToggleParagraph={toggleParagraph}
onSelectAllParagraphs={() => {
setSelectedParagraphIndexes(activePreview?.preview.paragraphs.map((paragraph) => paragraph.paragraphIndex) ?? []);
}}
onClearParagraphs={clearPreviewSelection}
onCreateRevision={handleCreateRevision}
onRunNextPartial={handleRunNextPartial}
onExportTxt={() => handleExport("txt")}
onExportDocx={() => handleExport("docx")}
/>
) : null}
</section>
</main>
);
}
import type { DocumentStatus } from "../types/app";
type Props = {
value: DocumentStatus | null;
displayName?: string;
busy: boolean;
stopBusy: boolean;
onPickFile: () => void;
onRunRound: () => void;
onStop: () => void;
pickerLabel?: string;
progressStatusLabel: string;
};
function renderResumeStatus(status: DocumentStatus): string {
if (!status.hasNextRound || status.isComplete) {
return "当前文档已完成全部轮次。";
}
if (status.canResume && status.totalChunkCount > 0) {
return `检测到断点进度:已完成 ${status.completedChunkCount}/${status.totalChunkCount} 块,可继续执行。`;
}
return "当前轮还没有已保存的分块进度。";
}
export function DocumentCard({
value,
displayName,
busy,
stopBusy,
onPickFile,
onRunRound,
onStop,
pickerLabel = "选择文档",
progressStatusLabel,
}: Props) {
const canRunNextRound = Boolean(value?.hasNextRound) && !busy;
const canStop = Boolean(value?.hasNextRound) && busy && !stopBusy;
return (
<section className="glass-card section-stack">
<div className="section-header">
<div>
<h2>文档工作台</h2>
<p>支持 txt 和 Word。上传 Word 后会先自动提取为中间 txt。</p>
</div>
<button className="secondary-button" onClick={onPickFile} disabled={busy}>
{pickerLabel}
</button>
</div>
{value ? (
<>
<div className="info-grid">
<div className="info-item">
<span>文件名称</span>
<strong>{displayName || value.displayName || value.docId}</strong>
</div>
<div className="info-item">
<span>文件类型</span>
<strong>{value.sourceKind}</strong>
</div>
<div className="info-item">
<span>已完成轮次</span>
<strong>{value.completedRounds.length ? value.completedRounds.join(" / ") : "暂无"}</strong>
</div>
<div className="info-item">
<span>下一轮</span>
<strong>{value.hasNextRound && value.nextRound ? `第 ${value.nextRound} 轮` : "已完成全部轮次"}</strong>
</div>
</div>
<div className="info-grid">
<div className="info-item">
<span>断点进度</span>
<strong>{value.totalChunkCount ? `${value.completedChunkCount}/${value.totalChunkCount}` : "暂无"}</strong>
</div>
<div className="info-item">
<span>进度状态</span>
<strong>{progressStatusLabel}</strong>
</div>
</div>
<div className="path-box">
<span>当前输入</span>
<strong>{value.currentInputPath}</strong>
</div>
<div className="path-box">
<span>续跑说明</span>
<strong>{renderResumeStatus(value)}</strong>
</div>
{value.lastError ? (
<div className="path-box">
<span>暂停原因</span>
<strong>{value.lastError}</strong>
</div>
) : null}
{value.stopReason ? (
<div className="path-box">
<span>停止说明</span>
<strong>{value.stopReason}</strong>
</div>
) : null}
<div className="button-row">
<button className="primary-button" onClick={onRunRound} disabled={!canRunNextRound}>
{value.canResume
? "继续执行当前轮"
: value.hasNextRound
? "执行下一轮"
: "已完成全部轮次"}
</button>
<button className="secondary-button" onClick={onStop} disabled={!canStop}>
{stopBusy || value.stopRequested ? "停止中..." : "停止当前轮"}
</button>
</div>
</>
) : (
<div className="empty-state">
<strong>还没有导入文档</strong>
<p>先选择一个 txt 或 docx 文件,系统会自动读取当前轮次状态。</p>
</div>
)}
</section>
);
}
import type { DocumentHistory, HistoryDocumentSummary, HistoryRevision, HistoryRound } from "../types/app";
type Props = {
currentDocId: string | null;
currentHistory: DocumentHistory | null;
items: HistoryDocumentSummary[];
open: boolean;
busy: boolean;
embedded?: boolean;
onToggle: () => void;
onSelect: (item: HistoryDocumentSummary) => void;
onDelete: (docId: string, fromRound?: number) => void;
onDownload: (item: HistoryRound | HistoryRevision, format: "txt" | "docx") => void;
onPreview: (item: HistoryRound | HistoryRevision) => void;
};
function formatTimestamp(value: string): string {
if (!value) {
return "时间未知";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function buildDisplayNameMap(items: HistoryDocumentSummary[], currentHistory: DocumentHistory | null, currentDocId: string | null): Map<string, string> {
const usageCount = new Map<string, number>();
const displayMap = new Map<string, string>();
const orderedItems = items.map((item) => {
const rawName = item.displayName || item.originPath || item.sourcePath || item.docId;
return { docId: item.docId, rawName };
});
if (currentHistory && currentDocId && !orderedItems.some((item) => item.docId === currentDocId)) {
orderedItems.unshift({
docId: currentDocId,
rawName: currentHistory.displayName || currentHistory.sourcePath || currentHistory.docId,
});
}
orderedItems.forEach((item) => {
const nextIndex = usageCount.get(item.rawName) ?? 0;
usageCount.set(item.rawName, nextIndex + 1);
displayMap.set(item.docId, nextIndex === 0 ? item.rawName : `${item.rawName}(${nextIndex})`);
});
return displayMap;
}
function formatNextRound(completedRounds: number[]): string {
const filtered = completedRounds.filter((round) => round >= 1 && round <= 2);
if (filtered.length >= 2) {
return "已完成";
}
if (!filtered.length) {
return "1";
}
return String(Math.max(...filtered) + 1);
}
function describeLifecycleStatus(status: HistoryRound["status"] | HistoryRevision["status"]): string {
if (status === "completed") {
return "已完成";
}
if (status === "interrupted") {
return "中断";
}
return "处理中";
}
function renderVersionActions(
item: HistoryRound | HistoryRevision,
busy: boolean,
onDownload: (item: HistoryRound | HistoryRevision, format: "txt" | "docx") => void,
onPreview: (item: HistoryRound | HistoryRevision) => void,
) {
return (
<div className="button-row">
<button className="secondary-button" onClick={() => onPreview(item)} disabled={busy || !item.outputPath || !item.manifestPath}>
预览并选择
</button>
<button className="secondary-button" onClick={() => onDownload(item, "txt")} disabled={busy || !item.outputPath}>
下载 TXT
</button>
<button className="primary-button" onClick={() => onDownload(item, "docx")} disabled={busy || !item.outputPath}>
下载 Word
</button>
</div>
);
}
function renderLifecycleMeta(item: HistoryRound | HistoryRevision) {
return (
<>
<div className="history-metrics">
<span>状态 {describeLifecycleStatus(item.status)}</span>
<span>进度 {item.totalChunkCount ? `${item.completedChunkCount}/${item.totalChunkCount}` : "-"}</span>
<span>输入块数 {item.inputSegmentCount ?? "-"}</span>
<span>输出块数 {item.outputSegmentCount ?? "-"}</span>
</div>
{item.lastError ? (
<div className="path-box compact-box">
<span>中断原因</span>
<strong>{item.lastError}</strong>
</div>
) : null}
{item.stopReason ? (
<div className="path-box compact-box">
<span>停止说明</span>
<strong>{item.stopReason}</strong>
</div>
) : null}
{item.canResume ? (
<div className="path-box compact-box">
<span>继续续跑</span>
<strong>切换到此文档后可从当前断点继续执行</strong>
</div>
) : null}
</>
);
}
export function HistoryCard({
currentDocId,
currentHistory,
items,
open,
busy,
embedded = false,
onToggle,
onSelect,
onDelete,
onDownload,
onPreview,
}: Props) {
const displayNameMap = buildDisplayNameMap(items, currentHistory, currentDocId);
return (
<section className={`${embedded ? "section-stack history-card history-card-embedded" : "glass-card section-stack history-card"}`}>
<div className="section-header">
<div>
<h2>历史记录</h2>
<p>显示已处理的文档、轮次结果与修订版,并支持重新打开预览选择段落。</p>
</div>
<button className="secondary-button history-toggle" onClick={onToggle} disabled={busy}>
{open ? "收起历史记录" : `查看历史记录${items.length ? ` (${items.length})` : ""}`}
</button>
</div>
{open ? (
items.length ? (
<div className="history-panel-scroll">
<div className="history-list history-document-list">
{items.map((item) => {
const activeRounds = currentDocId === item.docId && currentHistory?.rounds.length ? currentHistory.rounds : item.rounds;
const isActive = currentDocId === item.docId;
const displayName = displayNameMap.get(item.docId) || item.displayName || item.originPath || item.sourcePath || item.docId;
return (
<article key={item.docId} className={`history-item history-document ${isActive ? "active" : ""}`}>
<div className="history-item-head history-document-head">
<div>
<strong>{displayName}</strong>
<span>{item.lastTimestamp ? `最近更新 ${formatTimestamp(item.lastTimestamp)}` : "暂无时间"}</span>
</div>
<span className="pill">已完成 {item.completedRounds.length} 轮</span>
</div>
<div className="history-metrics">
<span>当前文档 {isActive ? "已加载" : "未加载"}</span>
<span>下一轮 {formatNextRound(item.completedRounds)}</span>
</div>
<div className="path-box compact-box">
<span>文档路径</span>
<strong>{item.originPath || item.sourcePath}</strong>
</div>
<div className="button-row history-document-actions">
<button className="secondary-button" onClick={() => onSelect(item)} disabled={busy}>
{isActive ? "重新加载" : "切换到此文档"}
</button>
<button className="secondary-button danger-button" onClick={() => onDelete(item.docId)} disabled={busy}>
删除整条历史
</button>
</div>
{activeRounds.length ? (
<div className="history-round-list">
{activeRounds.map((roundItem) => (
<article key={`${item.docId}-${roundItem.round}`} className="history-item history-round-item">
<div className="history-item-head">
<strong>第 {roundItem.round} 轮</strong>
<span>{formatTimestamp(roundItem.timestamp)}</span>
</div>
{renderLifecycleMeta(roundItem)}
<div className="history-metrics">
<span>修订版 {roundItem.revisions.length}</span>
</div>
<div className="path-box compact-box">
<span>输出路径</span>
<strong>{roundItem.outputPath || "暂无"}</strong>
</div>
{renderVersionActions(roundItem, busy, onDownload, onPreview)}
<div className="button-row">
<button
className="secondary-button danger-button"
onClick={() => onDelete(item.docId, roundItem.round)}
disabled={busy}
>
从本轮重新跑
</button>
</div>
{roundItem.revisions.length ? (
<div className="history-revision-list">
{roundItem.revisions.map((revision) => (
<article
key={`${item.docId}-${roundItem.round}-rev-${revision.revisionNumber}`}
className="history-item history-revision-item"
>
<div className="history-item-head">
<strong>第 {roundItem.round} 轮 / 修订 {revision.revisionNumber}</strong>
<span>{formatTimestamp(revision.timestamp)}</span>
</div>
{renderLifecycleMeta(revision)}
<div className="history-metrics">
<span>已选段落 {revision.targetParagraphIndexes.length}</span>
</div>
<div className="path-box compact-box">
<span>输出路径</span>
<strong>{revision.outputPath || "暂无"}</strong>
</div>
{renderVersionActions(revision, busy, onDownload, onPreview)}
</article>
))}
</div>
) : null}
</article>
))}
</div>
) : null}
</article>
);
})}
</div>
</div>
) : (
<div className="empty-state history-empty">
<strong>还没有历史记录</strong>
<p>执行过的文档会显示在这里,之后可以直接切换回来继续处理。</p>
</div>
)
) : (
<div className="empty-state history-empty">
<strong>历史记录已收起</strong>
<p>点击右上角按钮查看之前处理过的文档和各轮输出。</p>
</div>
)}
</section>
);
}
import type { ChangeEvent } from "react";
import type { ModelConfig } from "../types/app";
type Props = {
value: ModelConfig;
busy: boolean;
onChange: (value: ModelConfig) => void;
onSave: () => void;
onTestConnection: () => void;
};
export function ModelConfigCard({ value, busy, onChange, onSave, onTestConnection }: Props) {
function handleTextField<K extends keyof ModelConfig>(key: K) {
return (event: ChangeEvent<HTMLInputElement>) => {
const nextValue = key === "temperature" ? Number(event.target.value) : event.target.value;
onChange({ ...value, [key]: nextValue });
};
}
function handleOfflineModeChange(event: ChangeEvent<HTMLInputElement>) {
onChange({ ...value, offlineMode: event.target.checked });
}
return (
<section className="glass-card section-stack">
<div className="section-header">
<div>
<h2>模型设置</h2>
<p>本地保存模型配置,供每一轮处理直接调用。</p>
</div>
</div>
<label className="field">
<span>接口地址</span>
<input
value={value.baseUrl}
onChange={handleTextField("baseUrl")}
placeholder="https://your-endpoint/v1"
/>
</label>
<label className="field">
<span>API Key</span>
<input
type="password"
value={value.apiKey}
onChange={handleTextField("apiKey")}
placeholder="请输入 API Key"
/>
</label>
<label className="field">
<span>模型名称</span>
<input
value={value.model}
onChange={handleTextField("model")}
placeholder="例如 gpt-4.1-mini"
/>
</label>
<label className="field">
<span>接口类型</span>
<select
value={value.apiType}
onChange={(event) => onChange({
...value,
apiType: event.target.value as ModelConfig["apiType"],
})}
>
<option value="chat_completions">chat/completions</option>
<option value="responses">responses</option>
</select>
</label>
<label className="field">
<span>Temperature</span>
<input
type="number"
min="0"
max="2"
step="0.1"
value={value.temperature}
onChange={handleTextField("temperature")}
/>
</label>
<label className="toggle-field">
<span>离线联调模式</span>
<input type="checkbox" checked={value.offlineMode} onChange={handleOfflineModeChange} />
</label>
<div className="button-row">
<button className="secondary-button" onClick={onTestConnection} disabled={busy}>
测试连通性
</button>
<button className="primary-button" onClick={onSave} disabled={busy}>
保存模型设置
</button>
</div>
</section>
);
}
import type { ActivePreview } from "../hooks/useAppState";
import type { RoundResult } from "../types/app";
type Props = {
result: RoundResult | null;
activePreview: ActivePreview | null;
selectedParagraphIndexes: number[];
busy: boolean;
onToggleParagraph: (paragraphIndex: number) => void;
onSelectAllParagraphs: () => void;
onClearParagraphs: () => void;
onCreateRevision: () => void;
onRunNextPartial: () => void;
onExportTxt: () => void;
onExportDocx: () => void;
};
function formatPreviewLabel(preview: ActivePreview): string {
if (preview.kind === "current-result") {
return preview.revisionNumber ? `第 ${preview.round} 轮 / 修订 ${preview.revisionNumber}` : `第 ${preview.round} 轮最新结果`;
}
if (preview.kind === "round" && preview.round === 0) {
return "初始预览";
}
if (preview.revisionNumber) {
return `第 ${preview.round} 轮 / 修订 ${preview.revisionNumber}`;
}
return `第 ${preview.round} 轮`;
}
export function ResultCard({
result,
activePreview,
selectedParagraphIndexes,
busy,
onToggleParagraph,
onSelectAllParagraphs,
onClearParagraphs,
onCreateRevision,
onRunNextPartial,
onExportTxt,
onExportDocx,
}: Props) {
const pausedError = typeof result?.docEntry?.last_error === "string" ? result.docEntry.last_error : "";
const paragraphCount = activePreview?.preview.paragraphs.length ?? 0;
const selectedCount = selectedParagraphIndexes.length;
const canRunSelection = Boolean(activePreview && selectedCount > 0 && !busy);
const canCreateRevision = canRunSelection && !(activePreview?.kind === "round" && activePreview.round === 0);
return (
<section className="glass-card section-stack result-card">
<div className="section-header">
<div>
<h2>预览</h2>
<p>支持按段落选择后生成当前轮修订版,或只在下一轮处理选中段落;新导入文档也可直接从预览开始。</p>
</div>
{activePreview ? <span className="pill">{formatPreviewLabel(activePreview)}</span> : null}
</div>
{activePreview ? (
<>
<div className="info-grid compact">
<div className="info-item">
<span>预览来源</span>
<strong>{activePreview.label}</strong>
</div>
<div className="info-item">
<span>段落数</span>
<strong>{paragraphCount}</strong>
</div>
<div className="info-item">
<span>已选段落</span>
<strong>{selectedCount}</strong>
</div>
{result ? (
<div className="info-item">
<span>块数</span>
<strong>{result.outputSegmentCount}</strong>
</div>
) : null}
{pausedError ? (
<div className="info-item">
<span>最近暂停原因</span>
<strong>{pausedError}</strong>
</div>
) : null}
</div>
<div className="button-row">
<button className="secondary-button" onClick={onSelectAllParagraphs} disabled={busy || !paragraphCount}>
全选段落
</button>
<button className="secondary-button" onClick={onClearParagraphs} disabled={busy || !selectedCount}>
清空选择
</button>
<button className="secondary-button" onClick={onCreateRevision} disabled={!canCreateRevision}>
在当前轮生成修订版
</button>
<button className="primary-button" onClick={onRunNextPartial} disabled={!canRunSelection}>
在下一轮处理所选段落
</button>
</div>
<div className="paragraph-preview-list">
{activePreview.preview.paragraphs.map((paragraph) => {
const checked = selectedParagraphIndexes.includes(paragraph.paragraphIndex);
return (
<label
key={`${activePreview.outputPath}-${paragraph.paragraphIndex}`}
className={`paragraph-preview-item ${checked ? "selected" : ""}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleParagraph(paragraph.paragraphIndex)}
disabled={busy}
/>
<div className="paragraph-preview-content">
<div className="paragraph-preview-head">
<strong>第 {paragraph.paragraphIndex + 1} 段</strong>
<span>{paragraph.chunkCount} 块</span>
</div>
<p>{paragraph.text || "当前段落为空"}</p>
</div>
</label>
);
})}
</div>
<div className="button-row">
<button className="secondary-button" onClick={onExportTxt} disabled={busy}>
导出 TXT
</button>
<button className="primary-button" onClick={onExportDocx} disabled={busy}>
导出 Word
</button>
</div>
</>
) : (
<div className="empty-state">
<strong>预览区等待内容</strong>
<p>导入新文档后会直接生成初始预览,运行后会自动切到最新版本,也可以从历史记录打开任意轮次或修订版继续选择。</p>
</div>
)}
</section>
);
}
import { create } from "zustand";
import { DEFAULT_MODEL_CONFIG } from "../types/app";
import type {
DocumentHistory,
DocumentStatus,
HistoryDocumentSummary,
ModelConfig,
OutputPreview,
RoundProgress,
RoundResult,
} from "../types/app";
export type ActivePreview = {
label: string;
round: number;
revisionNumber: number | null;
outputPath: string;
manifestPath: string;
kind: "round" | "revision" | "current-result";
sourceRound: number;
preview: OutputPreview;
};
type AppState = {
modelConfig: ModelConfig;
documentStatus: DocumentStatus | null;
history: DocumentHistory | null;
historyItems: HistoryDocumentSummary[];
historyPanelOpen: boolean;
roundResult: RoundResult | null;
progress: RoundProgress | null;
previewText: string;
activePreview: ActivePreview | null;
selectedParagraphIndexes: number[];
runtimeStep: string;
notice: string;
busy: boolean;
error: string;
setModelConfig: (config: ModelConfig) => void;
setDocumentStatus: (status: DocumentStatus | null) => void;
setHistory: (history: DocumentHistory | null) => void;
setHistoryItems: (items: HistoryDocumentSummary[]) => void;
setHistoryPanelOpen: (open: boolean) => void;
setRoundResult: (result: RoundResult | null) => void;
setProgress: (progress: RoundProgress | null) => void;
setPreviewText: (text: string) => void;
setActivePreview: (preview: ActivePreview | null) => void;
setSelectedParagraphIndexes: (indexes: number[]) => void;
setRuntimeStep: (text: string) => void;
setNotice: (notice: string) => void;
setBusy: (busy: boolean) => void;
setError: (error: string) => void;
};
export const useAppState = create<AppState>((set) => ({
modelConfig: DEFAULT_MODEL_CONFIG,
documentStatus: null,
history: null,
historyItems: [],
historyPanelOpen: false,
roundResult: null,
progress: null,
previewText: "",
activePreview: null,
selectedParagraphIndexes: [],
runtimeStep: "待命",
notice: "",
busy: false,
error: "",
setModelConfig: (modelConfig) => set({ modelConfig }),
setDocumentStatus: (documentStatus) => set({ documentStatus }),
setHistory: (history) => set({ history }),
setHistoryItems: (historyItems) => set({ historyItems }),
setHistoryPanelOpen: (historyPanelOpen) => set({ historyPanelOpen }),
setRoundResult: (roundResult) => set({ roundResult }),
setProgress: (progress) => set({ progress }),
setPreviewText: (previewText) => set({ previewText }),
setActivePreview: (activePreview) => set({ activePreview }),
setSelectedParagraphIndexes: (selectedParagraphIndexes) => set({ selectedParagraphIndexes }),
setRuntimeStep: (runtimeStep) => set({ runtimeStep }),
setNotice: (notice) => set({ notice }),
setBusy: (busy) => set({ busy }),
setError: (error) => set({ error }),
}));
import type {
DeleteHistoryResult,
DocumentHistory,
DocumentStatus,
ExportResult,
HistoryListResponse,
ModelConfig,
OutputPreview,
RoundProgress,
RoundResult,
RunExecutionOptions,
TestConnectionResult,
} from "../types/app";
export type PickedDocument = {
sourcePath: string;
filename: string;
displayName: string;
};
export interface AppService {
loadModelConfig(): Promise<ModelConfig>;
saveModelConfig(config: ModelConfig): Promise<ModelConfig>;
testModelConnection(config: ModelConfig): Promise<TestConnectionResult>;
pickInputFile(): Promise<PickedDocument | null>;
getDocumentStatus(sourcePath: string, modelConfig: ModelConfig): Promise<DocumentStatus>;
getDocumentHistory(sourcePath: string): Promise<DocumentHistory>;
listDocumentHistories(): Promise<HistoryListResponse>;
deleteDocumentHistory(docId: string, fromRound?: number): Promise<DeleteHistoryResult>;
requestStop(sourcePath: string, modelConfig: ModelConfig, runToken?: string | null): Promise<DocumentStatus>;
startRunRound(sourcePath: string, modelConfig: ModelConfig, executionOptions?: RunExecutionOptions | null): Promise<string | null>;
awaitRunRound(sourcePath: string, modelConfig: ModelConfig, runToken?: string | null, executionOptions?: RunExecutionOptions | null): Promise<RoundResult>;
listenRoundProgress(onProgress: (payload: RoundProgress) => void, runToken?: string | null): Promise<() => void>;
readOutput(outputPath: string): Promise<{ path: string; text: string }>;
readOutputPreview(outputPath: string, manifestPath: string): Promise<OutputPreview>;
readSourcePreview(inputPath: string, manifestPath: string, promptProfile: "cn" | "en"): Promise<OutputPreview>;
exportRound(outputPath: string, targetFormat: "txt" | "docx"): Promise<ExportResult>;
}
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import type { AppService, PickedDocument } from "./appService";
import { normalizeModelConfig } from "../types/app";
import type {
DeleteHistoryResult,
DocumentHistory,
DocumentStatus,
ExportResult,
HistoryListResponse,
ModelConfig,
OutputPreview,
RoundProgress,
RoundResult,
RunExecutionOptions,
TestConnectionResult,
} from "../types/app";
export const desktopService: AppService = {
async loadModelConfig(): Promise<ModelConfig> {
const config = await invoke<Partial<ModelConfig>>("load_model_config");
return normalizeModelConfig(config);
},
async saveModelConfig(config: ModelConfig): Promise<ModelConfig> {
const saved = await invoke<Partial<ModelConfig>>("save_model_config", { config });
return normalizeModelConfig(saved);
},
async testModelConnection(config: ModelConfig): Promise<TestConnectionResult> {
return invoke<TestConnectionResult>("test_model_connection", { config });
},
async pickInputFile(): Promise<PickedDocument | null> {
const selected = await open({
multiple: false,
directory: false,
filters: [{ name: "Documents", extensions: ["txt", "docx"] }],
});
if (typeof selected !== "string") {
return null;
}
return {
sourcePath: selected,
filename: selected.split(/[/\\]/).pop() ?? selected,
displayName: selected.split(/[/\\]/).pop() ?? selected,
};
},
async getDocumentStatus(sourcePath: string, modelConfig: ModelConfig): Promise<DocumentStatus> {
return invoke<DocumentStatus>("get_document_status", { sourcePath, promptProfile: modelConfig.promptProfile });
},
async getDocumentHistory(sourcePath: string): Promise<DocumentHistory> {
return invoke<DocumentHistory>("get_document_history", { sourcePath });
},
async listDocumentHistories(): Promise<HistoryListResponse> {
return invoke<HistoryListResponse>("list_document_histories");
},
async deleteDocumentHistory(docId: string, fromRound?: number): Promise<DeleteHistoryResult> {
return invoke<DeleteHistoryResult>("delete_document_history", { docId, fromRound: fromRound ?? null });
},
async requestStop(sourcePath: string, modelConfig: ModelConfig): Promise<DocumentStatus> {
return invoke<DocumentStatus>("request_stop", { sourcePath, promptProfile: modelConfig.promptProfile });
},
async startRunRound(_sourcePath: string, _modelConfig: ModelConfig, _executionOptions?: RunExecutionOptions | null): Promise<string | null> {
return null;
},
async awaitRunRound(sourcePath: string, modelConfig: ModelConfig, _runToken?: string | null, executionOptions?: RunExecutionOptions | null): Promise<RoundResult> {
return invoke<RoundResult>("run_aigc_round", { sourcePath, modelConfig, executionOptions: executionOptions ?? null });
},
async listenRoundProgress(onProgress: (payload: RoundProgress) => void): Promise<UnlistenFn> {
return listen<RoundProgress>("round-progress", (event) => {
onProgress(event.payload);
});
},
async readOutput(outputPath: string): Promise<{ path: string; text: string }> {
return invoke<{ path: string; text: string }>("read_output_text", { outputPath });
},
async readOutputPreview(outputPath: string, manifestPath: string): Promise<OutputPreview> {
return invoke<OutputPreview>("read_output_preview", { outputPath, manifestPath });
},
async readSourcePreview(inputPath: string, manifestPath: string, promptProfile: "cn" | "en"): Promise<OutputPreview> {
return invoke<OutputPreview>("read_source_preview", { inputPath, manifestPath, promptProfile });
},
async exportRound(outputPath: string, targetFormat: "txt" | "docx"): Promise<ExportResult> {
const exportPath = await save({
defaultPath: targetFormat === "docx" ? "当前轮结果.docx" : "当前轮结果.txt",
filters: [{ name: "Export", extensions: [targetFormat] }],
});
if (!exportPath || Array.isArray(exportPath)) {
throw new Error("Export cancelled");
}
return invoke<ExportResult>("export_round_output", { outputPath, exportPath, targetFormat });
},
};
import type { AppService, PickedDocument } from "./appService";
import { normalizeModelConfig } from "../types/app";
import type {
DeleteHistoryResult,
DocumentHistory,
DocumentStatus,
ExportResult,
HistoryListResponse,
ModelConfig,
OutputPreview,
RoundProgress,
RoundResult,
RunExecutionOptions,
TestConnectionResult,
} from "../types/app";
const WEB_API_BASE = (globalThis as { __BAIBAIAIGC_WEB_API__?: string }).__BAIBAIAIGC_WEB_API__ ?? "";
type ProgressListener = (payload: RoundProgress) => void;
type RunStream = {
progressListeners: Set<ProgressListener>;
resultPromise: Promise<RoundResult>;
close: () => void;
};
type UploadDocumentResponse = PickedDocument & {
conflict?: boolean;
reused?: boolean;
};
const runStreams = new Map<string, RunStream>();
async function requestJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${WEB_API_BASE}${input}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const errorPayload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(errorPayload?.message || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
function readFileWithFallback(file: File): Promise<string> {
if (file.name.toLowerCase().endsWith(".txt")) {
return file.text();
}
throw new Error("Unsupported text read for current file type.");
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== "string") {
reject(new Error("Failed to read file."));
return;
}
const commaIndex = result.indexOf(",");
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
};
reader.onerror = () => reject(new Error("Failed to read file."));
reader.readAsDataURL(file);
});
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function parseMessageEvent<T>(event: Event, fallbackMessage: string): T {
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
throw new Error(fallbackMessage);
}
return JSON.parse(event.data) as T;
}
function createRunStream(runToken: string): RunStream {
let closed = false;
let settled = false;
let resolveResult!: (value: RoundResult) => void;
let rejectResult!: (reason: Error) => void;
const progressListeners = new Set<ProgressListener>();
const eventSource = new EventSource(`${WEB_API_BASE}/api/run-round-events/${runToken}`);
const close = () => {
if (closed) {
return;
}
closed = true;
eventSource.close();
runStreams.delete(runToken);
};
const settleResult = (value: RoundResult) => {
if (settled) {
return;
}
settled = true;
resolveResult(value);
close();
};
const settleError = (message: string) => {
if (settled) {
return;
}
settled = true;
rejectResult(new Error(message));
close();
};
const resultPromise = new Promise<RoundResult>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject as (reason: Error) => void;
});
eventSource.addEventListener("progress", (event) => {
try {
const payload = parseMessageEvent<RoundProgress>(event, "Invalid progress event.");
progressListeners.forEach((listener) => listener(payload));
} catch (error) {
settleError(error instanceof Error ? error.message : "Invalid progress event.");
}
});
eventSource.addEventListener("result", (event) => {
try {
settleResult(parseMessageEvent<RoundResult>(event, "Invalid run result."));
} catch (error) {
settleError(error instanceof Error ? error.message : "Invalid run result.");
}
});
eventSource.addEventListener("error", (event) => {
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
return;
}
try {
const payload = JSON.parse(event.data) as { message?: string };
settleError(payload.message || "Run round failed.");
} catch {
settleError("Run round failed.");
}
});
eventSource.onerror = () => {
settleError("Progress channel disconnected.");
};
return {
progressListeners,
resultPromise,
close,
};
}
function getRunStream(runToken: string): RunStream {
const existing = runStreams.get(runToken);
if (existing) {
return existing;
}
const stream = createRunStream(runToken);
runStreams.set(runToken, stream);
return stream;
}
export const webService: AppService = {
async loadModelConfig(): Promise<ModelConfig> {
const config = await requestJson<Partial<ModelConfig>>("/api/model-config");
return normalizeModelConfig(config);
},
async saveModelConfig(config: ModelConfig): Promise<ModelConfig> {
const saved = await requestJson<Partial<ModelConfig>>("/api/model-config", {
method: "POST",
body: JSON.stringify(normalizeModelConfig(config)),
});
return normalizeModelConfig(saved);
},
async testModelConnection(config: ModelConfig): Promise<TestConnectionResult> {
return requestJson<TestConnectionResult>("/api/test-connection", {
method: "POST",
body: JSON.stringify(normalizeModelConfig(config)),
});
},
async pickInputFile(): Promise<PickedDocument | null> {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt,.docx";
return new Promise((resolve, reject) => {
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const lowerName = file.name.toLowerCase();
const buildRequestBody = async (duplicateAction?: "reuse_existing" | "replace_with_new") => {
if (lowerName.endsWith(".docx")) {
return {
filename: file.name,
encoding: "base64",
contentBase64: await readFileAsBase64(file),
duplicateAction: duplicateAction ?? null,
};
}
return {
filename: file.name,
encoding: "text",
content: await readFileWithFallback(file),
duplicateAction: duplicateAction ?? null,
};
};
const upload = async (duplicateAction?: "reuse_existing" | "replace_with_new") => requestJson<UploadDocumentResponse>("/api/upload-document", {
method: "POST",
body: JSON.stringify(await buildRequestBody(duplicateAction)),
});
let payload = await upload();
if (payload.conflict) {
const reuseExisting = globalThis.confirm("检测到同名文件。选择“确定”使用以前的文件,选择“取消”重新上传新文件。");
payload = await upload(reuseExisting ? "reuse_existing" : "replace_with_new");
}
resolve(payload);
} catch (error) {
reject(error);
}
}, { once: true });
input.click();
});
},
async getDocumentStatus(sourcePath: string, modelConfig: ModelConfig): Promise<DocumentStatus> {
return requestJson<DocumentStatus>(
`/api/document-status?sourcePath=${encodeURIComponent(sourcePath)}&promptProfile=${encodeURIComponent(modelConfig.promptProfile)}`,
);
},
async getDocumentHistory(sourcePath: string): Promise<DocumentHistory> {
return requestJson<DocumentHistory>(`/api/document-history?sourcePath=${encodeURIComponent(sourcePath)}`);
},
async listDocumentHistories(): Promise<HistoryListResponse> {
return requestJson<HistoryListResponse>("/api/history-documents");
},
async deleteDocumentHistory(docId: string, fromRound?: number): Promise<DeleteHistoryResult> {
return requestJson<DeleteHistoryResult>("/api/document-history", {
method: "DELETE",
body: JSON.stringify({ docId, fromRound: fromRound ?? null }),
});
},
async requestStop(sourcePath: string, modelConfig: ModelConfig): Promise<DocumentStatus> {
return requestJson<DocumentStatus>("/api/request-stop", {
method: "POST",
body: JSON.stringify({ sourcePath, promptProfile: modelConfig.promptProfile }),
});
},
async startRunRound(sourcePath: string, modelConfig: ModelConfig, executionOptions?: RunExecutionOptions | null): Promise<string | null> {
const { runId } = await requestJson<{ runId: string }>("/api/run-round", {
method: "POST",
body: JSON.stringify({
sourcePath,
modelConfig: normalizeModelConfig(modelConfig),
executionOptions: executionOptions ?? null,
}),
});
return runId;
},
async awaitRunRound(_sourcePath: string, _modelConfig: ModelConfig, runToken?: string | null, _executionOptions?: RunExecutionOptions | null): Promise<RoundResult> {
if (!runToken) {
throw new Error("runToken is required in web mode.");
}
return getRunStream(runToken).resultPromise;
},
async listenRoundProgress(onProgress: (payload: RoundProgress) => void, runToken?: string | null): Promise<() => void> {
if (!runToken) {
return () => undefined;
}
const stream = getRunStream(runToken);
stream.progressListeners.add(onProgress);
return () => {
stream.progressListeners.delete(onProgress);
};
},
async readOutput(outputPath: string): Promise<{ path: string; text: string }> {
return requestJson<{ path: string; text: string }>(`/api/read-output?outputPath=${encodeURIComponent(outputPath)}`);
},
async readOutputPreview(outputPath: string, manifestPath: string): Promise<OutputPreview> {
return requestJson<OutputPreview>(
`/api/read-output-preview?outputPath=${encodeURIComponent(outputPath)}&manifestPath=${encodeURIComponent(manifestPath)}`,
);
},
async readSourcePreview(inputPath: string, manifestPath: string, promptProfile: "cn" | "en"): Promise<OutputPreview> {
return requestJson<OutputPreview>(
`/api/read-source-preview?inputPath=${encodeURIComponent(inputPath)}&manifestPath=${encodeURIComponent(manifestPath)}&promptProfile=${encodeURIComponent(promptProfile)}`,
);
},
async exportRound(outputPath: string, targetFormat: "txt" | "docx"): Promise<ExportResult> {
const response = await fetch(
`${WEB_API_BASE}/api/export-round?outputPath=${encodeURIComponent(outputPath)}&targetFormat=${targetFormat}`,
);
if (!response.ok) {
throw new Error(`Export failed: ${response.status}`);
}
const blob = await response.blob();
const filename = decodeURIComponent(
response.headers.get("Content-Disposition")?.match(/filename="?([^\"]+)"?/)?.[1] ?? `当前轮结果.${targetFormat}`,
);
downloadBlob(blob, filename);
return {
format: targetFormat,
path: filename,
};
},
};
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { desktopService } from "./lib/desktopService";
import { webService } from "./lib/webService";
import "./styles/global.css";
const runtimeMode = (import.meta.env.VITE_APP_RUNTIME ?? "desktop").toLowerCase();
const service = runtimeMode === "web" ? webService : desktopService;
const pickerLabel = runtimeMode === "web" ? "上传文档" : "选择文档";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App service={service} pickerLabel={pickerLabel} />
</React.StrictMode>,
);
:root {
color-scheme: light;
font-family: "PingFang SC", "SF Pro Display", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(36, 123, 255, 0.16), transparent 24%),
radial-gradient(circle at top right, rgba(47, 176, 116, 0.14), transparent 20%),
linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%);
color: #1d1d1f;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
min-width: 320px;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
border: none;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease, border-color 0.18s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
input,
select {
width: 100%;
border: 1px solid rgba(60, 60, 67, 0.12);
background: rgba(255, 255, 255, 0.9);
padding: 0 14px;
height: 46px;
border-radius: 14px;
outline: none;
}
input:focus,
select:focus {
border-color: rgba(0, 113, 227, 0.45);
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.12);
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 42px;
color: #1d1d1f;
background-image:
linear-gradient(45deg, transparent 50%, #6e6e73 50%),
linear-gradient(135deg, #6e6e73 50%, transparent 50%),
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.88));
background-position:
calc(100% - 20px) 18px,
calc(100% - 14px) 18px,
0 0;
background-size: 6px 6px, 6px 6px, 100% 100%;
background-repeat: no-repeat;
cursor: pointer;
}
.app-shell {
max-width: 1280px;
margin: 0 auto;
padding: 10px 18px 20px;
}
.hero-panel {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 10px;
padding: 18px 22px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(22px);
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
}
.hero-copy-wrap {
max-width: 900px;
}
.eyebrow {
margin: 0 0 4px;
color: #0f6bdc;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.hero-panel h1 {
margin: 0;
font-size: clamp(22px, 3.3vw, 34px);
line-height: 1.04;
letter-spacing: -0.04em;
}
.hero-copy {
max-width: 760px;
margin: 12px 0 0;
color: #667085;
font-size: 14px;
}
.hero-status-column {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-width: 112px;
}
.hero-status-note {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
color: #7b7f87;
font-size: 12px;
}
.hero-status-note strong {
color: #1d1d1f;
font-size: 13px;
}
.status-tag,
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.12);
color: #0f6bdc;
font-size: 11px;
font-weight: 700;
}
.status-tag.idle {
background: rgba(52, 199, 89, 0.12);
color: #248a3d;
}
.error-banner,
.notice-banner {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
font-size: 12px;
}
.error-banner {
background: rgba(255, 159, 10, 0.12);
color: #9a5c00;
border-color: rgba(255, 159, 10, 0.18);
}
.notice-banner {
background: rgba(52, 199, 89, 0.12);
color: #227943;
border-color: rgba(52, 199, 89, 0.18);
}
.runtime-log {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(60, 60, 67, 0.08);
color: #3a3a3c;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.runtime-log-label {
font-size: 11px;
color: #6e6e73;
letter-spacing: 0.08em;
}
.page-switcher {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.page-tab {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
min-height: 86px;
padding: 14px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(60, 60, 67, 0.1);
color: #475467;
text-align: left;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.04);
}
.page-tab strong {
color: #101828;
font-size: 14px;
}
.page-tab span {
font-size: 11px;
}
.page-tab.active {
background: linear-gradient(180deg, rgba(18, 120, 255, 0.96), rgba(0, 113, 227, 0.92));
border-color: rgba(0, 113, 227, 0.2);
box-shadow: 0 18px 40px rgba(0, 113, 227, 0.22);
color: rgba(255, 255, 255, 0.86);
}
.page-tab.active strong {
color: #ffffff;
}
.page-frame {
padding: 18px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.66);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.page-frame-compact {
padding-top: 8px;
}
.page-frame-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.page-frame-head h2 {
margin: 2px 0 0;
font-size: 22px;
}
.page-frame-head p {
margin: 0;
color: #667085;
font-size: 13px;
}
.page-kicker {
color: #0f6bdc;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.content-grid {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 16px;
}
.glass-card {
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.section-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.section-header h2 {
margin: 0;
font-size: 18px;
}
.section-header p {
margin: 4px 0 0;
color: #6e6e73;
font-size: 13px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field span,
.info-item span,
.path-box span {
color: #6e6e73;
font-size: 12px;
}
.toggle-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(60, 60, 67, 0.08);
}
.toggle-field input {
width: 18px;
height: 18px;
}
.primary-button,
.secondary-button {
min-height: 40px;
border-radius: 12px;
padding: 0 14px;
font-weight: 700;
font-size: 14px;
}
.primary-button {
color: #ffffff;
background: linear-gradient(180deg, #1185ff 0%, #0071e3 100%);
box-shadow: 0 14px 28px rgba(0, 113, 227, 0.22);
}
.secondary-button {
color: #1d1d1f;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(60, 60, 67, 0.12);
}
.danger-button {
color: #b42318;
border-color: rgba(180, 35, 24, 0.18);
background: rgba(255, 245, 245, 0.95);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button-row .primary-button,
.button-row .secondary-button {
flex: 1 1 160px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.info-grid.compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.info-item {
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(60, 60, 67, 0.08);
}
.info-item strong,
.path-box strong {
display: block;
margin-top: 4px;
word-break: break-all;
font-size: 13px;
color: #1d1d1f;
}
.path-box {
padding: 12px;
border-radius: 14px;
background: rgba(250, 250, 252, 0.92);
border: 1px solid rgba(60, 60, 67, 0.08);
}
.compact-box {
padding: 10px 12px;
}
.empty-state {
display: flex;
min-height: 140px;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
border-radius: 16px;
border: 1px dashed rgba(60, 60, 67, 0.18);
background: rgba(255, 255, 255, 0.42);
color: #6e6e73;
padding: 16px;
font-size: 13px;
}
.empty-state strong {
color: #1d1d1f;
margin-bottom: 6px;
}
.result-card {
min-height: 360px;
}
.history-card {
margin: 0;
}
.history-card-embedded {
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none;
}
.history-toggle {
min-width: 136px;
}
.history-panel-scroll {
max-height: 520px;
overflow-y: auto;
padding-right: 6px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.history-document-list {
padding-right: 4px;
}
.history-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(60, 60, 67, 0.08);
}
.history-document {
gap: 10px;
}
.history-document.active {
border-color: rgba(0, 113, 227, 0.22);
box-shadow: inset 0 0 0 1px rgba(0, 113, 227, 0.08);
}
.history-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.history-item-head strong {
font-size: 14px;
}
.history-item-head span,
.history-metrics span {
color: #6e6e73;
font-size: 12px;
}
.history-document-head > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-document-actions {
margin-top: 2px;
}
.history-metrics {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.history-round-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.history-round-item {
background: rgba(250, 250, 252, 0.92);
}
.history-revision-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 6px;
}
.history-revision-item {
background: rgba(245, 248, 255, 0.92);
border-style: dashed;
}
.history-empty {
min-height: 100px;
}
.paragraph-preview-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.paragraph-preview-item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(60, 60, 67, 0.08);
background: rgba(250, 250, 252, 0.92);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.paragraph-preview-item:hover {
border-color: rgba(17, 133, 255, 0.18);
}
.paragraph-preview-item.selected {
border-color: rgba(17, 133, 255, 0.32);
box-shadow: inset 0 0 0 1px rgba(17, 133, 255, 0.12);
background: rgba(240, 247, 255, 0.96);
}
.paragraph-preview-item input {
width: 18px;
height: 18px;
margin-top: 2px;
}
.paragraph-preview-content {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.paragraph-preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
color: #6e6e73;
font-size: 12px;
}
.paragraph-preview-content p {
margin: 0;
color: #1d1d1f;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.65;
}
.preview-box {
min-height: 220px;
max-height: 360px;
overflow: auto;
border-radius: 16px;
padding: 14px;
background: rgba(250, 250, 252, 0.92);
border: 1px solid rgba(60, 60, 67, 0.08);
}
.preview-box pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace;
font-size: 12px;
line-height: 1.55;
}
@media (max-width: 1100px) {
.hero-panel,
.page-frame-head,
.section-header {
flex-direction: column;
}
.hero-status-column {
align-items: flex-start;
}
.hero-status-note {
align-items: flex-start;
}
.page-switcher,
.content-grid,
.info-grid.compact {
grid-template-columns: 1fr;
}
.info-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.app-shell {
padding: 12px 12px 22px;
}
.hero-panel,
.page-frame,
.glass-card {
padding: 18px;
border-radius: 22px;
}
.runtime-log {
flex-direction: column;
align-items: flex-start;
}
.page-tab strong {
font-size: 16px;
}
.info-grid,
.button-row {
grid-template-columns: 1fr;
}
.button-row .primary-button,
.button-row .secondary-button {
flex-basis: 100%;
}
}
export type ApiType = "chat_completions" | "responses";
export type PromptProfile = "cn" | "en";
export type LifecycleStatus = "in_progress" | "interrupted" | "completed";
export type RoundProgressPhase =
| "chunking-ready"
| "chunk-skipped"
| "processing-chunk"
| "chunk-error"
| "chunk-complete"
| "restoring-output"
| "stopped";
export type ModelConfig = {
baseUrl: string;
apiKey: string;
model: string;
apiType: ApiType;
temperature: number;
offlineMode: boolean;
promptProfile: PromptProfile;
};
export const DEFAULT_MODEL_CONFIG: ModelConfig = {
baseUrl: "",
apiKey: "",
model: "",
apiType: "chat_completions",
temperature: 0.7,
offlineMode: false,
promptProfile: "cn",
};
export function normalizeModelConfig(config?: Partial<ModelConfig> | null): ModelConfig {
return {
baseUrl: String(config?.baseUrl ?? DEFAULT_MODEL_CONFIG.baseUrl),
apiKey: String(config?.apiKey ?? DEFAULT_MODEL_CONFIG.apiKey),
model: String(config?.model ?? DEFAULT_MODEL_CONFIG.model),
apiType: config?.apiType === "responses" ? "responses" : "chat_completions",
temperature: typeof config?.temperature === "number" && Number.isFinite(config.temperature)
? config.temperature
: DEFAULT_MODEL_CONFIG.temperature,
offlineMode: Boolean(config?.offlineMode),
promptProfile: config?.promptProfile === "en" ? "en" : "cn",
};
}
export type RoundProgress = {
phase: RoundProgressPhase;
round: number;
currentChunk?: number;
totalChunks?: number;
completedChunks?: number;
remainingChunks?: number;
chunkId?: string;
paragraphIndex?: number;
chunkIndex?: number;
paragraphCount?: number;
inputPath?: string;
outputPath?: string;
manifestPath?: string;
progressPath?: string;
resumed?: boolean;
error?: string;
message?: string;
applyMode?: ApplyMode | "";
targetParagraphIndexes?: number[];
revisionNumber?: number;
};
export type ApplyMode = "current_round_revision" | "next_round_partial";
export type ParagraphPreview = {
paragraphIndex: number;
text: string;
chunkIds: string[];
chunkCount: number;
};
export type RunExecutionOptions = {
applyMode: ApplyMode;
targetParagraphIndexes: number[];
sourceRound: number;
targetRound: number;
basedOnOutputPath: string;
basedOnManifestPath: string;
revisionNumber?: number | null;
};
export type TestConnectionResult = {
ok: boolean;
offlineMode: boolean;
message: string;
endpoint: string;
model: string;
apiType?: ApiType;
status?: number;
};
export type DocumentStatus = {
docId: string;
sourcePath: string;
displayName: string;
sourceKind: string;
completedRounds: number[];
nextRound: number | null;
maxRounds: number;
hasNextRound: boolean;
isComplete: boolean;
currentInputPath: string;
currentOutputPath: string;
manifestPath: string;
progressPath: string;
progressStatus: string;
status: LifecycleStatus;
canResume: boolean;
completedChunkCount: number;
totalChunkCount: number;
lastError: string;
lastErrorChunkId: string;
stopRequested: boolean;
stopReason: string;
latestOutputPath: string;
extractedFromDocx: boolean;
applyMode: ApplyMode | "";
targetParagraphIndexes: number[];
sourceRound: number | null;
targetRound: number | null;
revisionNumber: number | null;
basedOnOutputPath: string;
basedOnManifestPath: string;
};
export type RoundResult = {
round: number;
outputPath: string;
manifestPath: string;
progressPath: string;
chunkLimit: number;
inputSegmentCount: number;
outputSegmentCount: number;
completedChunkCount: number;
paragraphCount: number;
resumed: boolean;
offlineMode: boolean;
paragraphs: ParagraphPreview[];
isPartial: boolean;
targetParagraphIndexes: number[];
applyMode: ApplyMode | "";
sourceRound?: number | null;
targetRound?: number | null;
revisionNumber?: number | null;
docEntry: Record<string, unknown>;
skillContext: Record<string, unknown>;
};
export type HistoryRound = {
round: number;
prompt: string;
inputPath: string;
outputPath: string;
manifestPath: string;
progressPath: string;
progressStatus: string;
status: LifecycleStatus;
canResume: boolean;
completedChunkCount: number;
totalChunkCount: number;
lastError: string;
lastErrorChunkId: string;
stopRequested: boolean;
stopReason: string;
scoreTotal: number | null;
chunkLimit: number | null;
inputSegmentCount: number | null;
outputSegmentCount: number | null;
timestamp: string;
kind: "round";
isPartial: boolean;
targetParagraphIndexes: number[];
basedOnOutputPath: string;
basedOnManifestPath: string;
sourceRound: number | null;
targetRound: number | null;
revisionNumber: number | null;
revisions: HistoryRevision[];
};
export type HistoryRevision = {
revisionNumber: number;
prompt: string;
inputPath: string;
outputPath: string;
manifestPath: string;
progressPath: string;
progressStatus: string;
status: LifecycleStatus;
canResume: boolean;
completedChunkCount: number;
totalChunkCount: number;
lastError: string;
lastErrorChunkId: string;
stopRequested: boolean;
stopReason: string;
scoreTotal: number | null;
chunkLimit: number | null;
inputSegmentCount: number | null;
outputSegmentCount: number | null;
timestamp: string;
kind: "revision";
isPartial: boolean;
targetParagraphIndexes: number[];
basedOnOutputPath: string;
basedOnManifestPath: string;
sourceRound: number | null;
targetRound: number | null;
};
export type DocumentHistory = {
docId: string;
sourcePath: string;
displayName: string;
rounds: HistoryRound[];
};
export type HistoryDocumentSummary = {
docId: string;
sourcePath: string;
originPath: string;
displayName: string;
completedRounds: number[];
latestOutputPath: string;
lastTimestamp: string;
rounds: HistoryRound[];
};
export type HistoryListResponse = {
items: HistoryDocumentSummary[];
total: number;
};
export type DeleteHistoryResult = {
docId: string;
deletedRounds: number[];
remainingRounds: number[];
removedDocument: boolean;
deletedFiles: string[];
};
export type ExportResult = {
format: "txt" | "docx";
path: string;
};
export type OutputPreview = {
path: string;
text: string;
paragraphs: ParagraphPreview[];
};
/// <reference types="vite/client" />{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["react", "react-dom"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => ({
plugins: [react()],
clearScreen: false,
define: {
"import.meta.env.VITE_APP_RUNTIME": JSON.stringify(mode === "web" ? "web" : "desktop"),
},
server: {
port: 1420,
strictPort: true,
proxy: {
"/api": {
target: "http://127.0.0.1:8765",
changeOrigin: true,
},
},
},
}));
论文(技术文档)修改助手角色与规则
一、角色与目标
你现在扮演一个专业的“论文(或技术文档)修改助手”。你的核心任务是接收一段中文原文(通常是技术性或学术性的描述),并将其改写成一种特定的风格。这种风格的特点是:比原文稍微啰嗦、更具解释性、措辞上更偏向通俗或口语化(但保持专业底线),并且系统性地使用特定的替代词汇和句式结构。你的目标是精确地模仿分析得出的修改模式,生成“修改后”风格的文本,同时务必保持原文的核心技术信息、逻辑关系和事实准确性,也不要添加过多的字数。
- 注意不要过于口语化(通常情况下不会过于口语化,有一些比如至于xxx呢,这种的不要有)
- 注意!你输出的内容不应原多于原文!应时刻记得字数和原文相符!
- 注意!不要有‘’xxx呢‘’这种形式,如‘至于vue呢’
- 不要第一人称
二、输入与输出
- 输入:一段中文原文(标记为“原文”)。
- 输出:仅输出一段严格按照以下规则改写后的中文正文。
二点一、硬性输出约束
- 只能输出改写后的正文,不得输出“修改后”“改写后”“说明”“可以改成”“如果你愿意”等任何说明性或对话式文字。
- 不得提供多个候选标题、备选版本、项目符号建议、评价或邀请式话语。
- 不得改变原文的核心意思、事实、论点、结论和逻辑关系,不得新增、删减或偷换观点。
- 必须保持原有段落角色和编号结构;标题仍是标题,正文仍是正文,不能把正文改成答疑、讲解或润色建议。
- 除非原文本来如此,否则不要输出 Markdown 粗体、标题符号、引用块或列表格式。
三、核心修改手法与规则(请严格遵守)
1. 增加冗余与解释性(Verbose Elaboration)
动词短语扩展
将简洁的动词或动词短语替换为更长的、带有动作过程描述的短语。
- 示例:“管理” -> “开展...的管理工作” 或 “进行管理”
- 示例:“交互” -> “进行交互” 或 “开展交互”
- 示例:“配置” -> “进行配置”
- 示例:“处理” -> “去处理...工作”
- 示例:“恢复” -> “进行恢复”
- 示例:“实现” -> “得以实现” 或 “来实现”
增加辅助词/结构
在句子中添加语法上允许但非必需的词语,使句子更饱满。
- 示例:适当增加 “了”、“的”、“地”、“所”、“会”、“可以”、“这个”、“方面”、“当中” 等。
- 示例:“提供功能” -> “有...功能” 或 “拥有...功能”
2. 系统性词汇替换(Systematic Synonym/Phrasing Substitution)
特定动词/介词/连词替换
将原文中常用的某些词汇固定地替换为特定的替代词。这是模仿目标风格的关键。
- 采用 / 使用 -> 运用 / 选用 / 把...当作...来使用
- 基于 -> 鉴于 / 基于...来开展
- 利用 -> 借助 / 运用 / 凭借
- 通过 -> 借助 / 依靠 / 凭借
- 和 / 及 / 与 -> 以及 (尤其是在列举多项时)
- 并 -> 并且 / 还 / 同时
- 其 -> 它 / 其 (可根据语境选择,有时用“它”更口语化)
特定名词/形容词替换
- 原因 -> 缘由 / 主要原因囊括...
- 符合 -> 契合
- 适合 -> 适宜
- 特点 -> 特性
- 提升 / 提高 -> 提高 / 提升 (可互换使用,保持多样性)
- 极大(地) -> 极大程度(上)
- 立即 -> 马上
3. 括号内容处理(Bracket Content Integration/Removal)
解释性括号
对于原文中用于解释、举例或说明缩写的括号 (...) 或 (...):
- 优先整合:尝试将括号内的信息自然地融入句子,使用 “也就是”、“即”、“比如”、“像” 等引导词。
- 示例:ORM(对象关系映射) -> 对象关系映射即ORM 或 ORM也就是对象关系映射
- 示例:功能(如ORM、Admin) -> 功能,比如ORM、Admin 或 功能,像ORM、Admin等
- 谨慎省略:如果整合后语句极其冗长或别扭,并且括号内容并非核心关键信息(例如,非常基础的缩写全称),可以考虑省略。但要极其小心,避免丢失重要上下文或示例。在提供的范例中,有时示例信息被省略了,你可以模仿这一点,但要判断是否会损失过多信息。
代码/标识符旁括号
对于紧跟在代码、文件名、类名旁的括号,通常直接移除括号。
- 示例:视图 (views.py) 中 -> 视图也就是views.py中
- 示例:权限类 (admin_panel.permissions) -> 权限类 admin_panel.permissions
4. 句式微调与口语化倾向(Sentence Structure & Colloquial Touch)
使用“把”字句
在合适的场景下,倾向于使用“把”字句。
- 示例:“会将对象移动” -> “会把对象移动”
条件句式转换
将较书面的条件句式改为稍口语化的形式。
- 示例:“若...,则...” -> “要是...,那就...” 或 “如果...,就...”
名词化与动词化转换
根据需要进行调整,有时将名词性结构展开为动词性结构,反之亦然,以符合更自然的口语表达。
- 示例:“为了将...解耦” -> “为了实现...的解耦”
增加语气词/连接词
如在句首或句中添加“那么”、“这样”、“同时”等。
5. 保持技术准确性(Maintain Technical Accuracy)
- 绝对禁止修改:所有的技术术语(如 Django, RESTful API, Ceph, RGW, S3, JWT, ORM, MySQL)、代码片段 (views.py, settings.py, accounts.CustomUser, .folder_marker)、库名 (Boto3, djangorestframework-simplejwt)、配置项 (CEPH_STORAGE, DATABASES)、API 路径 (/accounts/api/token/refresh/) 等必须保持原样,不得修改或错误转写。
- 核心逻辑不变:修改后的句子必须表达与原文完全相同的技术逻辑、因果关系和功能描述。
四、执行指令
请根据以上所有规则,对接下来提供的“原文”进行修改,生成符合上述特定风格的“修改后”文本。务必仔细揣摩每个规则的细节和示例,力求在风格上高度一致。
- 注意不要过于口语化(通常情况下不会过于口语化,有一些比如至于xxx呢,这种的不要有)
- 注意!你输出的内容不应原多于原文!应时刻记得字数和原文相符!
- 注意!不要有‘’xxx呢‘’这种形式,如‘至于vue呢’
- 不要第一人称