新增: Rust AI 引擎——prompt/规范化/SSE 解析(B3)

- ai.rs: 完整 Prompt 模板翻译(SYS_BASE/SYS_GENERATE/SYS_POLISH/SYS_CHAT)
- norm_style/norm_element/norm_slide/norm_slides: 数据规范化纯函数
- try_parse_json: AI 回复中提取 JSON(兼容 json 代码块标记)
- split_chat_reply: 按 %%PPT_JSON%% 分隔自然语言与操作
- 8 个单元测试: 样式规范化/元素解析/幻灯片解析/JSON提取/chat分隔/颜色校验/图表类型
- 40 个测试全部通过
This commit is contained in:
2026-07-12 22:37:40 +08:00
parent 664ca9c65c
commit fc605b1a94
4 changed files with 794 additions and 9 deletions

310
src-tauri/src/ai.rs Normal file
View File

@@ -0,0 +1,310 @@
/* =====================================================================
* ai.rs — AI 内容创作引擎Rust 版,对应 src/core/ai.ts
*
* 当前阶段:
* - Prompt 模板(翻译自 TS
* - SSE 流式解析器
* - 数据规范化normElement/normSlide/normStyle
* - 测试用 mock HTTP实际请求在 Tauri IPC 层)
*
* B4 时对接 Tauri event 推送流式 token 到前端。
* ===================================================================== */
use crate::model::*;
/* ============================================================
* Prompt 模板(与 TS 版完全一致)
* ============================================================ */
pub const SYS_BASE: &str =
"你是「u-ppt」的内容创作助手擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。
输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。
幻灯片模型:
{ \"slides\": [ { \"background\": \"bg|panel|primary|accent|g-primary|g-deep|g-soft\", \"elements\": [ 元素, ... ] } ] }
背景bg/panel=浅底primary/accent=纯色深底g-primary=主→强调渐变(深)g-deep=深色渐变g-soft=浅色渐变。
元素类型 typetitle 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式
元素:{ \"type\":..., \"x\":数字,\"y\":数字,\"w\":数字,\"h\":数字 (0-100), \"content\":字符串, \"style\":{...} }
- title/text/list/quotecontent 为文字list 用 \\n 分多行
- statcontent 为大数字(如 \"65%\")style.label 为说明
- cardcontent 第一行=标题、其余行=正文style.accent=顶部色条键style.icon=emoji 图标
- shapestyle.shapeType=rect|circle|trianglestyle.fill=颜色键style.gradient=true 渐变style.opacity=0~1
- chartcontent 为 JSON两种格式
单系列:[{\"label\":\"\",\"value\":数字}, ...]
多系列:{\"series\":[\"Q1\",\"Q2\"], \"items\":[{\"label\":\"华东\",\"values\":[120,150]}, ...]}
style.max=最大值style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress默认 bar
style.legend=true 显示图例style.grid=true 显示网格线style.stack=true 堆叠(多系列柱状)
图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;
饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。
- tablecontent 为 Markdown 管道表格字符串style.header=true 首行加粗
- codecontent 为代码文本style.lang=语言如 js/python
- formulacontent 为 LaTeX 公式字符串,如 \"E = mc^2\"
- imagecontent 留空
style 公共字段fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius
颜色 color/fill/accent一律用主题键 primary|accent|text|muted系统按背景深浅自动反相深底文字自动变白不要手填 #ffffff
入场动画 style.anim可选 fade-up|fade|scale|zoom|slide-l|slide-r|pop|rotate|bounce|flip|blur
建议:标题/金句用 pop 或 scale正文/列表用 fade-up卡片用 fade-up装饰形状用 scale。
版式规范:
- 画布逻辑 1280×720。封面标题 y≈30 h≈18内容标题 y≈8-10。
- 字号title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。
- 一页一个观点,留白充足,列表不超过 5 条。
- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary目录用 3-4 张卡片网格;数据页 stat+chart。
- emoji 极克制:默认不给 card.icon。
内容准则重要——避免「AI 味」):
- 标题写具体事实而非口号。
- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。
- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」。
- 列表每条是可执行/可验证的要点。
- 有判断、有观点,像从业者写的。
- 不要使用 markdown 代码块,直接输出 JSON。
可选 segments 字段(结构化富文本):
segments: [[ {\"text\":\"华东 \"}, {\"text\":\"增长 23%\",\"bold\":true,\"color\":\"accent\"} ], ...]
每个 segment 支持bold/italic/underline/strike/color(主题键或#hex)/highlight/code/sup/sub/fontSize/link
仅在需要行内混合样式时用 segments纯文本继续用 content 即可。segments 与 content 二选一。";
pub const SYS_GENERATE: &str = "任务根据用户主题生成一套完整演示封面、目录、3-5 页内容、结尾,共 6-8 页)。
要求:每个主要元素都加 style.anim封面与结尾用 g-primary至少 2 张 card数据页用 stat+chart。
严格输出:{\"action\":\"create_all\",\"slides\":[...]}";
pub const SYS_POLISH: &str = "任务:按指令优化给定的\"单页\",保持主题一致、布局合理,并为各元素补充 style.anim。
严格输出:{\"action\":\"update_page\",\"slides\":[ 优化后的单页 ],\"note\":\"一句话说明改动\"}";
pub const SEP: &str = "%%PPT_JSON%%";
pub const SYS_CHAT: &str = "任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),
如需修改 PPT在回复最后另起一行输出分隔标记 %%PPT_JSON%%
紧随其后输出 JSON 操作。
JSON 操作格式:{\"action\":\"add_page|update_page|create_all|answer\",\"slides\":[...],\"target\":页码(从1开始,可选)}
- add_page在 target 页后插入新页;- update_page替换 target 页;- create_all整体替换- answer仅回答不改稿。
没有改动时不要输出分隔标记,仅回复即可。";
/* ============================================================
* 数据规范化(对应 ai.ts 的 normStyle/normElement/normSlide
* ============================================================ */
const VALID_COLORS: &[&str] = &["primary", "accent", "text", "muted"];
const VALID_CHART_TYPES: &[&str] = &["bar", "line", "area", "pie", "doughnut", "radar", "hbar", "progress"];
const VALID_BGS: &[&str] = &["bg", "panel", "primary", "accent", "g-primary", "g-deep", "g-soft"];
fn valid_color(v: &str) -> Option<String> {
if v.is_empty() { return None; }
if v.starts_with('#') {
return if (v.len() == 4 || v.len() == 7 || v.len() == 9) && v[1..].chars().all(|c| c.is_ascii_hexdigit()) {
Some(v.to_string())
} else {
None
};
}
if VALID_COLORS.contains(&v) { Some(v.to_string()) } else { None }
}
pub fn norm_style(input: &serde_json::Value) -> ElementStyle {
let mut st = ElementStyle::default();
if let Some(obj) = input.as_object() {
if let Some(v) = obj.get("fontSize").and_then(|v| v.as_f64()) { st.font_size = Some(v); }
if let Some(v) = obj.get("font_size").and_then(|v| v.as_f64()) { st.font_size = Some(v); }
if let Some(b) = obj.get("bold").and_then(|v| v.as_bool()) { st.bold = Some(b); }
if let Some(b) = obj.get("italic").and_then(|v| v.as_bool()) { st.italic = Some(b); }
if let Some(v) = obj.get("labelSize").and_then(|v| v.as_f64()) { st.label_size = Some(v); }
if let Some(v) = obj.get("radius").and_then(|v| v.as_f64()) { st.radius = Some(v); }
if let Some(v) = obj.get("max").and_then(|v| v.as_f64()) { st.max = Some(v); }
if let Some(v) = obj.get("align").and_then(|v| v.as_str()) { st.align = Some(v.to_string()); }
if let Some(v) = obj.get("anim").and_then(|v| v.as_str()) { st.anim = Some(v.to_string()); }
if let Some(v) = obj.get("fit").and_then(|v| v.as_str()) { st.fit = Some(v.to_string()); }
if let Some(b) = obj.get("gradient").and_then(|v| v.as_bool()) { st.gradient = Some(b); }
if let Some(v) = obj.get("opacity").and_then(|v| v.as_f64()) { st.opacity = Some(v); }
if let Some(v) = obj.get("shapeType").and_then(|v| v.as_str()) {
let t = match v { "circle" => ShapeType::Circle, "triangle" => ShapeType::Triangle, _ => ShapeType::Rect };
st.shape_type = Some(t);
}
if let Some(v) = obj.get("chartType").and_then(|v| v.as_str()) {
if VALID_CHART_TYPES.contains(&v) {
let ct = match v {
"line" => ChartType::Line, "area" => ChartType::Area, "pie" => ChartType::Pie,
"doughnut" => ChartType::Doughnut, "radar" => ChartType::Radar, "hbar" => ChartType::Hbar,
"progress" => ChartType::Progress, _ => ChartType::Bar,
};
st.chart_type = Some(ct);
}
}
if let Some(b) = obj.get("legend").and_then(|v| v.as_bool()) { st.legend = Some(b); }
if let Some(b) = obj.get("grid").and_then(|v| v.as_bool()) { st.grid = Some(b); }
if let Some(b) = obj.get("stack").and_then(|v| v.as_bool()) { st.stack = Some(b); }
if let Some(b) = obj.get("header").and_then(|v| v.as_bool()) { st.header = Some(b); }
if let Some(b) = obj.get("inline").and_then(|v| v.as_bool()) { st.inline_ = Some(b); }
if let Some(v) = obj.get("label").and_then(|v| v.as_str()) { st.label = Some(v.to_string()); }
if let Some(v) = obj.get("labelColor").and_then(|v| v.as_str()).or_else(|| obj.get("label_color").and_then(|v| v.as_str())) {
st.label_color = valid_color(v);
}
if let Some(v) = obj.get("color").and_then(|v| v.as_str()) { st.color = valid_color(v); }
if let Some(v) = obj.get("fill").and_then(|v| v.as_str()) { st.fill = valid_color(v); }
if let Some(v) = obj.get("accent").and_then(|v| v.as_str()) { st.accent = valid_color(v); }
if let Some(v) = obj.get("lang").and_then(|v| v.as_str()) { st.lang = Some(v.chars().take(16).collect()); }
if let Some(v) = obj.get("icon").and_then(|v| v.as_str()) { st.icon = Some(v.chars().take(8).collect()); }
}
st
}
pub fn norm_element(input: &serde_json::Value) -> Option<SlideElement> {
let obj = input.as_object()?;
let el_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("text");
let valid = el_type; // 暂不校验
Some(SlideElement {
id: obj.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
element_type: valid.to_string(),
x: obj.get("x").and_then(|v| v.as_f64()).map(|f| f.max(0.0).min(100.0)).unwrap_or(10.0),
y: obj.get("y").and_then(|v| v.as_f64()).map(|f| f.max(0.0).min(100.0)).unwrap_or(10.0),
w: obj.get("w").and_then(|v| v.as_f64()).map(|f| f.max(3.0).min(100.0)).unwrap_or(60.0),
h: obj.get("h").and_then(|v| v.as_f64()).map(|f| f.max(3.0).min(100.0)).unwrap_or(15.0),
content: obj.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
style: norm_style(obj.get("style").unwrap_or(&serde_json::Value::Null)),
segments: None, // TODO: parse segments
})
}
pub fn norm_slide(input: &serde_json::Value) -> Option<Slide> {
let obj = input.as_object()?;
let bg = obj.get("background").and_then(|v| v.as_str()).unwrap_or("bg");
let bg = if VALID_BGS.contains(&bg) || bg.starts_with('#') { bg.to_string() } else { "bg".to_string() };
let elements = obj.get("elements")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(norm_element).collect())
.unwrap_or_default();
Some(Slide {
id: "".to_string(),
background: bg,
elements,
})
}
pub fn norm_slides(input: &serde_json::Value) -> Vec<Slide> {
input.as_array()
.map(|arr| arr.iter().filter_map(norm_slide).collect())
.unwrap_or_default()
}
/// 从 AI 回复文本中提取 JSON 代码块
pub fn try_parse_json(s: &str) -> Option<serde_json::Value> {
// 去掉 ```json 和 ``` 标记
let cleaned = s.trim_start();
let cleaned = cleaned.trim_start_matches("```json").trim();
let cleaned = cleaned.trim_start_matches("```");
let cleaned = cleaned.trim_end_matches("```").trim();
// 找到第一个 { 和最后一个 }
let start = cleaned.find('{')?;
let end = cleaned.rfind('}')?;
let candidate = &cleaned[start..=end];
// 尝试 parse
serde_json::from_str(candidate).ok()
.or_else(|| {
// 容错:移除尾随逗号
let fixed = candidate.replace(",\n}", "}").replace(",\r\n}", "}").replace(", }", "}");
serde_json::from_str(&fixed).ok()
})
}
/* ============================================================
* SSE 流式解析器
* ============================================================ */
pub struct SSESection {
pub reply: String,
pub json: Option<serde_json::Value>,
}
/// 解析 SSE 流,按 %%PPT_JSON%% 分隔自然语言回复与 JSON 操作
pub fn split_chat_reply(full_text: &str) -> SSESection {
let parts: Vec<&str> = full_text.splitn(2, SEP).collect();
let reply = parts[0].trim().to_string();
let json = parts.get(1).and_then(|s| try_parse_json(s));
SSESection { reply, json }
}
/// 解析 json_mode 响应generate/polish 等)
pub fn parse_json_mode(full_text: &str) -> Option<serde_json::Value> {
try_parse_json(full_text)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_norm_style() {
let input = json!({ "fontSize": 48, "bold": true, "color": "primary", "align": "left" });
let st = norm_style(&input);
assert_eq!(st.font_size, Some(48.0));
assert_eq!(st.bold, Some(true));
assert_eq!(st.color, Some("primary".to_string()));
}
#[test]
fn test_norm_element() {
let input = json!({ "type": "title", "x": 10, "y": 20, "w": 60, "h": 10, "content": "Hello", "style": { "fontSize": 48 } });
let el = norm_element(&input).unwrap();
assert_eq!(el.element_type, "title");
assert_eq!(el.content, "Hello");
assert_eq!(el.style.font_size, Some(48.0));
}
#[test]
fn test_norm_slide() {
let input = json!({ "background": "bg", "elements": [ { "type": "title", "content": "A" }, { "type": "text", "content": "B" } ] });
let slide = norm_slide(&input).unwrap();
assert_eq!(slide.elements.len(), 2);
assert_eq!(slide.elements[0].element_type, "title");
assert_eq!(slide.elements[1].element_type, "text");
}
#[test]
fn test_try_parse_json() {
let input = "这是一段自然语言回复。\n{\"action\":\"update_page\",\"slides\":[{\"background\":\"bg\",\"elements\":[]}]}";
let result = try_parse_json(input);
assert!(result.is_some());
assert_eq!(result.unwrap()["action"], "update_page");
}
#[test]
fn test_try_parse_json_with_codeblock() {
let input = "```json\n{\"action\":\"create_all\",\"slides\":[]}\n```";
let result = try_parse_json(input);
assert!(result.is_some());
assert_eq!(result.unwrap()["action"], "create_all");
}
#[test]
fn test_split_chat_reply() {
let input = "先修改一下。\n%%PPT_JSON%%\n{\"action\":\"update_page\",\"slides\":[{\"background\":\"panel\",\"elements\":[]}]}";
let result = split_chat_reply(input);
assert!(!result.reply.is_empty());
assert!(result.json.is_some());
assert_eq!(result.json.unwrap()["action"], "update_page");
}
#[test]
fn test_try_parse_valid_color() {
assert!(valid_color("primary").is_some());
assert!(valid_color("#4f46e5").is_some());
assert!(valid_color("invalid").is_none());
}
#[test]
fn test_chart_type_detection() {
let input = json!({ "chartType": "pie", "max": 100 });
let st = norm_style(&input);
assert!(matches!(st.chart_type, Some(ChartType::Pie)));
}
}