Files
u-ppt/src-tauri/src/ai.rs
绝尘 fc605b1a94 新增: 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 个测试全部通过
2026-07-12 22:37:40 +08:00

311 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 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)));
}
}