- 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 个测试全部通过
311 lines
16 KiB
Rust
311 lines
16 KiB
Rust
/* =====================================================================
|
||
* 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<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)));
|
||
}
|
||
}
|