/* ===================================================================== * 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=浅色渐变。 元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式 元素:{ \"type\":..., \"x\":数字,\"y\":数字,\"w\":数字,\"h\":数字 (0-100), \"content\":字符串, \"style\":{...} } - title/text/list/quote:content 为文字,list 用 \\n 分多行 - stat:content 为大数字(如 \"65%\"),style.label 为说明 - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标 - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1 - chart:content 为 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)完成率。 - table:content 为 Markdown 管道表格字符串,style.header=true 首行加粗 - code:content 为代码文本,style.lang=语言如 js/python - formula:content 为 LaTeX 公式字符串,如 \"E = mc^2\" - image:content 留空 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 { 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 { 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 { 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 { 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 { // 去掉 ```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, } /// 解析 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 { 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))); } }