wip: checkpoint 2026-03-29 runtime work

This commit is contained in:
zyl
2026-03-29 22:44:30 +08:00
parent 7d9036b2d4
commit e294fbb9b1
30 changed files with 6759 additions and 161 deletions

View File

@@ -0,0 +1,382 @@
use async_trait::async_trait;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use zeroclaw::tools::{Tool, ToolResult};
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
const TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../skill_lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html"
));
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";
pub struct ScreenHtmlExportTool {
workspace_root: PathBuf,
}
impl ScreenHtmlExportTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[derive(Debug, Deserialize)]
struct ScreenHtmlExportArgs {
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
generated_at_ms: Option<u64>,
#[serde(default)]
rows: Option<Vec<Vec<Value>>>,
#[serde(default)]
table: Option<Vec<ScreenTableRow>>,
#[serde(default)]
categories: Option<Vec<ScreenCategory>>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ScreenCategory {
category_code: String,
category_label: String,
item_count: u64,
total_heat: u64,
avg_heat: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ScreenTableRow {
rank: u64,
title: String,
url: String,
category_code: String,
category_label: String,
heat_text: String,
heat_value: u64,
reply_count: u64,
upvote_count: u64,
favorite_count: u64,
heart_count: u64,
}
#[derive(Debug, Serialize)]
struct ScreenPayload {
snapshot_id: String,
generated_at_ms: u64,
categories: Vec<ScreenCategory>,
table: Vec<ScreenTableRow>,
}
#[async_trait]
impl Tool for ScreenHtmlExportTool {
fn name(&self) -> &str {
SCREEN_HTML_EXPORT_TOOL_NAME
}
fn description(&self) -> &str {
"Render a local Zhihu hotlist ECharts dashboard HTML for leadership demos and new-tab presentation."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"snapshot_id": { "type": "string" },
"generated_at_ms": { "type": "integer" },
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {}
}
},
"table": {
"type": "array",
"items": { "type": "object" }
},
"categories": {
"type": "array",
"items": { "type": "object" }
},
"output_path": { "type": "string" }
}
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let parsed = match serde_json::from_value::<ScreenHtmlExportArgs>(args) {
Ok(value) => value,
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
};
let table = match parsed.table {
Some(table) if !table.is_empty() => table,
Some(_) => return Ok(failed_tool_result("table must not be empty".to_string())),
None => match parsed.rows {
Some(rows) => build_table_from_rows(&rows)?,
None => {
return Ok(failed_tool_result(
"rows or table is required for screen_html_export".to_string(),
))
}
},
};
if table.is_empty() {
return Ok(failed_tool_result("table must not be empty".to_string()));
}
let categories = parsed
.categories
.filter(|items| !items.is_empty())
.unwrap_or_else(|| derive_categories(&table));
let payload = ScreenPayload {
snapshot_id: parsed
.snapshot_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(default_snapshot_id),
generated_at_ms: parsed.generated_at_ms.unwrap_or_else(now_ms),
categories,
table,
};
let rendered = render_template(&payload)?;
let output_path = parsed
.output_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| default_output_path(&self.workspace_root));
write_output_html(&output_path, &rendered)?;
let presentation_url = file_url_for_path(&output_path);
Ok(ToolResult {
success: true,
output: json!({
"title": DEFAULT_SCREEN_TITLE,
"output_path": output_path,
"renderer": SCREEN_HTML_EXPORT_TOOL_NAME,
"row_count": payload.table.len(),
"snapshot_id": payload.snapshot_id,
"presentation": {
"mode": "new_tab",
"title": DEFAULT_SCREEN_TITLE,
"url": presentation_url,
"open_in_new_tab": true
}
})
.to_string(),
error: None,
})
}
}
fn failed_tool_result(error: String) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
}
fn build_table_from_rows(rows: &[Vec<Value>]) -> anyhow::Result<Vec<ScreenTableRow>> {
if rows.is_empty() {
return Err(anyhow::anyhow!("rows must not be empty"));
}
rows.iter()
.enumerate()
.map(|(index, row)| {
if row.len() != 3 {
return Err(anyhow::anyhow!(
"each row must contain exactly 3 values: rank, title, heat"
));
}
let rank = value_to_rank(&row[0]).unwrap_or((index + 1) as u64);
let title = value_to_string(&row[1]);
if title.trim().is_empty() {
return Err(anyhow::anyhow!("title must not be empty"));
}
let heat_text = value_to_string(&row[2]);
let heat_value = parse_heat_value(&heat_text);
let (category_code, category_label) = classify_title(&title);
Ok(ScreenTableRow {
rank,
title,
url: format!("https://www.zhihu.com/question/hotlist-{rank}"),
category_code: category_code.to_string(),
category_label: category_label.to_string(),
heat_text,
heat_value,
reply_count: 0,
upvote_count: 0,
favorite_count: 0,
heart_count: 0,
})
})
.collect()
}
fn derive_categories(table: &[ScreenTableRow]) -> Vec<ScreenCategory> {
let mut grouped: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
for row in table {
let key = (row.category_code.clone(), row.category_label.clone());
let entry = grouped.entry(key).or_insert((0, 0));
entry.0 += 1;
entry.1 += row.heat_value;
}
grouped
.into_iter()
.map(|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
category_code,
category_label,
item_count,
total_heat,
avg_heat: if item_count == 0 {
0
} else {
total_heat / item_count
},
})
.collect()
}
fn classify_title(title: &str) -> (&'static str, &'static str) {
let normalized = title.to_ascii_lowercase();
if contains_any(&normalized, &["ai", "芯片", "科技", "算法", "机器人", "无人机"]) {
return ("technology", "科技");
}
if contains_any(&normalized, &["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"]) {
return ("entertainment", "娱乐");
}
if contains_any(&normalized, &["足球", "比赛", "联赛", "国足", "体育", "冠军"]) {
return ("sports", "体育");
}
if contains_any(&normalized, &["航母", "作战", "", "军事", "演训"]) {
return ("military", "军事");
}
if contains_any(&normalized, &["出口", "经济", "市场", "财经", "消费", ""]) {
return ("finance", "财经");
}
("society", "社会")
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn parse_heat_value(heat_text: &str) -> u64 {
let compact = heat_text.trim().replace(',', "");
if compact.is_empty() {
return 0;
}
let number_part = compact
.chars()
.filter(|ch| ch.is_ascii_digit() || *ch == '.')
.collect::<String>();
let base = number_part.parse::<f64>().unwrap_or(0.0);
let multiplier = if compact.contains('亿') {
100_000_000.0
} else if compact.contains('万') {
10_000.0
} else {
1.0
};
(base * multiplier).round() as u64
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn value_to_rank(value: &Value) -> Option<u64> {
match value {
Value::Number(number) => number.as_u64(),
Value::String(text) => text.trim().parse::<u64>().ok(),
_ => None,
}
}
fn render_template(payload: &ScreenPayload) -> anyhow::Result<String> {
let payload_json = serde_json::to_string_pretty(payload)?;
let payload_start = TEMPLATE
.find(PAYLOAD_START_MARKER)
.ok_or_else(|| anyhow::anyhow!("default payload start marker missing"))?;
let payload_end = TEMPLATE
.find(PAYLOAD_END_MARKER)
.ok_or_else(|| anyhow::anyhow!("default payload end marker missing"))?;
let replacement = format!(
"{PAYLOAD_START_MARKER}{}\n",
indent_block(&payload_json, " ")
);
Ok(format!(
"{}{}{}",
&TEMPLATE[..payload_start],
replacement,
&TEMPLATE[payload_end..],
))
}
fn indent_block(value: &str, indent: &str) -> String {
value
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn write_output_html(path: &Path, rendered: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, rendered)?;
Ok(())
}
fn default_output_path(workspace_root: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or_default();
workspace_root
.join("out")
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
}
fn default_snapshot_id() -> String {
format!("zhihu-hotlist-screen-{}", now_ms())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or_default()
}
fn file_url_for_path(path: &Path) -> String {
Url::from_file_path(path)
.map(|url| url.to_string())
.unwrap_or_else(|_| format!("file://{}", path.display()))
}