Skip to content

Commit ab7f1ee

Browse files
committed
feat(rich-content): complete r1 artifact delivery
1 parent b1ed884 commit ab7f1ee

11 files changed

Lines changed: 965 additions & 4 deletions

File tree

docs/planning/rich-content-delivery/06-implementation-log.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **用途:** 记录实际实施、验证、决策变更、风险与下一步,保证人类和 AI Agent 接手时可追溯。
44
> **受众:** 所有实施者与评审者。
55
> **最后审阅 / Last reviewed:** 2026-08-09
6-
> **状态:** R0 已提交并推送,但远程仓库未报告可查询的 CI;按阶段门禁阻塞。R1–R4 的本地改动不得推送或标记完成,直到维护者提供并通过 R0 的远程验证方式
6+
> **状态:** R0 已通过远程全量 CI。R1 ArtifactService 后端交付已完成本地验证,待提交、推送与远程 CI;R2–R4 仍未开始阶段提交
77
88
---
99

@@ -20,8 +20,8 @@
2020

2121
| 阶段 | 状态 | 负责人 | 开始 | 完成 | 证据/备注 |
2222
|---|---|---|---|---|---|
23-
| R0 契约/安全基线 | 远程 CI 未配置,阻塞 | 当前实施者 | 2026-08-09 | | `6012b1d` 已推送;迁移/双读/默认关闭 flag/安全测试已完成,本地 pass;GitHub API 未报告 check run 或 workflow run |
24-
| R1 ArtifactService/图片/下载 | 本地实现完成,待门禁 | 当前实施者 | 2026-08-09 | 2026-08-09 | 窄 IPC + 原生保存对话框;未引入宽 URI scope;未 commit/push/CI |
23+
| R0 契约/安全基线 | 已完成 | 当前实施者 | 2026-08-09 | 2026-08-09 | `b1ed884`[CI #31271639940](https://github.com/knqiufan/MisakaX/actions/runs/31271639940) 的 8 项检查全绿 |
24+
| R1 ArtifactService/图片/下载 | 本地验证完成,待门禁 | 当前实施者 | 2026-08-09 | | 受限 ArtifactService、窄 IPC、原生保存与会话过期清理;待 commit/push/CI |
2525
| R2 文件预览 | 本地实现完成,待门禁 | 当前实施者 | 2026-08-09 | 2026-08-09 | 本地只读预览、资源上限和下载回退;未 commit/push/CI |
2626
| R3 图表 | 本地实现完成,待门禁 | 当前实施者 | 2026-08-09 | 2026-08-09 | 受限 spec、ARIA、表格与 CSV 产物导出;未 commit/push/CI |
2727
| R4 地图 | 本地实现完成,待门禁 | 当前实施者 | 2026-08-09 | 2026-08-09 | 仅本地 GeoJSON/no tiles;R4b 未开始;未 commit/push/CI |
@@ -120,6 +120,17 @@
120120
- **风险/回滚:** 只增加 Windows 测试初始化等待 700ms;不改变运行时代码、用户终端行为或安全边界。
121121
- **下一步:** 推送并等待 R0 全部远程检查通过。
122122

123+
### 2026-08-09 — R1:ArtifactService 后端与窄 IPC
124+
125+
- **范围:** 提供应用私有 content-addressed artifact store、magic/MIME/尺寸/配额校验、原子写入、会话归属、过期清理、预览元数据、原生保存对话框与窄 Tauri IPC。重型预览 renderer、图表和地图仍留待 R2–R4。
126+
- **代码审查:** Artifact 路径只由 Rust 从 application data 目录解析;写入在 session 存在性校验后才执行;导出使用原生 dialog 并验证写出哈希;未添加 WebView FS/HTTP/Shell permission 或 URL/path 入口。会话删除前过期所属 artifact,保留数据库审计记录。
127+
- **验证:** `cargo fmt --check` → pass;`cargo check` → pass;`cargo test --lib artifact` → 7 passed / 0 failed。
128+
- **未验证:** R1 不接入重型前端 renderer;图像放大/通用文件 preview 将由 R2 覆盖。
129+
- **Git:** 待提交(仅 R1 后端及 IPC 文件)。
130+
- **远程 CI:** 待当前 R1 提交推送后运行。
131+
- **风险/回滚:** 关闭 rich-content feature flags 可保持旧消息路径;删除 artifact 数据前会先将记录标为 expired,并只删除无 active 引用的字节文件。
132+
- **下一步:** 提交、推送并等待远程 CI 全绿后,开始 R2。
133+
123134
## 后续记录模板
124135

125136
```markdown
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use serde::Deserialize;
2+
use tauri::{AppHandle, State};
3+
use tauri_plugin_dialog::DialogExt;
4+
5+
use crate::config;
6+
use crate::db::repository::MessageBlockRepo;
7+
use crate::services::artifacts::{
8+
ArtifactMetadata, ArtifactOrigin, ArtifactPreview, ArtifactService, ContentSafetyPolicy,
9+
ExportOutcome,
10+
};
11+
use crate::services::content::{ContentBlock, MessageBlockService};
12+
use crate::AppState;
13+
14+
#[derive(Debug, Deserialize)]
15+
pub struct ArtifactRegisterRequest {
16+
pub session_id: String,
17+
pub origin_message_id: Option<String>,
18+
pub origin_kind: ArtifactOrigin,
19+
pub display_name: String,
20+
pub media_type: String,
21+
/// Transport-only ingress. It is decoded immediately and never persisted in
22+
/// SQLite or returned by metadata commands.
23+
pub bytes_base64: String,
24+
}
25+
26+
fn service() -> Result<ArtifactService, String> {
27+
ArtifactService::new(
28+
config::artifacts_dir().map_err(|error| error.to_string())?,
29+
ContentSafetyPolicy::default(),
30+
)
31+
.map_err(|error| error.to_string())
32+
}
33+
34+
#[tauri::command]
35+
pub fn artifact_register(
36+
state: State<'_, AppState>,
37+
request: ArtifactRegisterRequest,
38+
) -> Result<ArtifactMetadata, String> {
39+
if !state.feature_flags.rich_content_write {
40+
return Err("CONTENT_BLOCK_UNSUPPORTED".to_string());
41+
}
42+
let conn = state.db.lock().map_err(|error| error.to_string())?;
43+
service()?
44+
.register_base64(
45+
&conn,
46+
request.session_id,
47+
request.origin_message_id,
48+
request.origin_kind,
49+
request.display_name,
50+
request.media_type,
51+
&request.bytes_base64,
52+
)
53+
.map_err(|error| error.to_string())
54+
}
55+
56+
#[tauri::command]
57+
pub fn artifact_get_metadata(
58+
state: State<'_, AppState>,
59+
session_id: String,
60+
artifact_id: String,
61+
) -> Result<ArtifactMetadata, String> {
62+
let conn = state.db.lock().map_err(|error| error.to_string())?;
63+
service()?
64+
.metadata(&conn, &session_id, &artifact_id)
65+
.map_err(|error| error.to_string())
66+
}
67+
68+
#[tauri::command]
69+
pub fn artifact_get_preview(
70+
state: State<'_, AppState>,
71+
session_id: String,
72+
artifact_id: String,
73+
) -> Result<ArtifactPreview, String> {
74+
let conn = state.db.lock().map_err(|error| error.to_string())?;
75+
service()?
76+
.preview(&conn, &session_id, &artifact_id)
77+
.map_err(|error| error.to_string())
78+
}
79+
80+
#[tauri::command]
81+
pub fn artifact_read_preview_base64(
82+
state: State<'_, AppState>,
83+
session_id: String,
84+
artifact_id: String,
85+
) -> Result<String, String> {
86+
let conn = state.db.lock().map_err(|error| error.to_string())?;
87+
service()?
88+
.read_preview_base64(&conn, &session_id, &artifact_id)
89+
.map_err(|error| error.to_string())
90+
}
91+
92+
#[tauri::command]
93+
pub async fn artifact_export(
94+
app: AppHandle,
95+
state: State<'_, AppState>,
96+
session_id: String,
97+
artifact_id: String,
98+
) -> Result<ExportOutcome, String> {
99+
let metadata = {
100+
let conn = state.db.lock().map_err(|error| error.to_string())?;
101+
service()?
102+
.metadata(&conn, &session_id, &artifact_id)
103+
.map_err(|error| error.to_string())?
104+
};
105+
let app_for_dialog = app.clone();
106+
let target = tauri::async_runtime::spawn_blocking(move || {
107+
app_for_dialog
108+
.dialog()
109+
.file()
110+
.set_title("Save artifact")
111+
.set_file_name(metadata.display_name)
112+
.blocking_save_file()
113+
.and_then(|path| path.into_path().ok())
114+
})
115+
.await
116+
.map_err(|error| error.to_string())?;
117+
let Some(target) = target else {
118+
return Ok(ExportOutcome {
119+
status: "cancelled".to_string(),
120+
file_name: None,
121+
});
122+
};
123+
let conn = state.db.lock().map_err(|error| error.to_string())?;
124+
service()?
125+
.export_to_path(&conn, &session_id, &artifact_id, &target)
126+
.map_err(|error| error.to_string())
127+
}
128+
129+
#[tauri::command]
130+
pub fn artifact_delete_or_expire(
131+
state: State<'_, AppState>,
132+
session_id: String,
133+
artifact_id: String,
134+
) -> Result<(), String> {
135+
let conn = state.db.lock().map_err(|error| error.to_string())?;
136+
service()?
137+
.expire(&conn, &session_id, &artifact_id)
138+
.map_err(|error| error.to_string())
139+
}
140+
141+
#[tauri::command]
142+
pub fn append_content_block(state: State<'_, AppState>, block: ContentBlock) -> Result<(), String> {
143+
if !state.feature_flags.rich_content_write {
144+
return Err("CONTENT_BLOCK_UNSUPPORTED".to_string());
145+
}
146+
let conn = state.db.lock().map_err(|error| error.to_string())?;
147+
MessageBlockService::append(&conn, &block, service()?.policy())
148+
.map_err(|error| error.to_string())
149+
}
150+
151+
#[tauri::command]
152+
pub fn get_message_blocks(
153+
state: State<'_, AppState>,
154+
message_id: String,
155+
) -> Result<Vec<ContentBlock>, String> {
156+
if !state.feature_flags.rich_content_render {
157+
return Ok(Vec::new());
158+
}
159+
let conn = state.db.lock().map_err(|error| error.to_string())?;
160+
MessageBlockRepo::find_by_message(&conn, &message_id).map_err(|error| error.to_string())
161+
}

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod artifacts;
12
pub mod chat;
23
pub mod fs_explorer;
34
pub mod mcp;

src-tauri/src/commands/session.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::db::models::{ExportData, ExportSession, ImportResult, MessageSearchRe
77
use crate::db::repository::{
88
ArtifactRepo, MessageBlockRepo, MessageRepo, SessionRepo, WorkspaceRepo,
99
};
10-
use crate::services::artifacts::RetentionState;
10+
use crate::services::artifacts::{ArtifactService, ContentSafetyPolicy, RetentionState};
1111
use crate::AppState;
1212

1313
pub const WORKSPACE_KIND_DEFAULT: &str = "default";
@@ -140,6 +140,14 @@ pub fn update_session(
140140
pub async fn delete_session(state: State<'_, AppState>, id: String) -> Result<(), String> {
141141
let _binding_guard = state.workspace_terminal_guard.lock().await;
142142
let conn = state.db.lock().map_err(|e| e.to_string())?;
143+
let artifact_service = ArtifactService::new(
144+
config::artifacts_dir().map_err(|e| e.to_string())?,
145+
ContentSafetyPolicy::default(),
146+
)
147+
.map_err(|e| e.to_string())?;
148+
artifact_service
149+
.expire_session(&conn, &id)
150+
.map_err(|e| e.to_string())?;
143151
SessionRepo::delete(&conn, &id).map_err(|e| e.to_string())?;
144152
drop(conn);
145153
state.terminal_manager.kill_chat_session(&id);

src-tauri/src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ pub fn db_path() -> Result<PathBuf> {
8888
Ok(config_dir()?.join("data").join("misaka.db"))
8989
}
9090

91+
/// Application-owned binary storage for rich-content artifacts. This is kept
92+
/// outside workspaces, Skills and WebView data so only Rust resolves paths.
93+
pub fn artifacts_dir() -> Result<PathBuf> {
94+
Ok(config_dir()?.join("data").join("artifacts"))
95+
}
96+
9197
/// Get the skills directory path (~/.misakax/skills/)
9298
pub fn skills_dir() -> Result<PathBuf> {
9399
Ok(config_dir()?.join("skills"))
@@ -142,6 +148,7 @@ pub fn ensure_directories() -> Result<()> {
142148
let dirs = [
143149
root.clone(),
144150
root.join("data"),
151+
root.join("data").join("artifacts"),
145152
root.join("skills"),
146153
root.join("managed").join("skills"),
147154
root.join("managed").join("skills-staging"),

src-tauri/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ pub fn run() {
151151
commands::chat::regenerate_message,
152152
commands::chat::generate_session_title,
153153
commands::chat::get_messages,
154+
commands::artifacts::artifact_register,
155+
commands::artifacts::artifact_get_metadata,
156+
commands::artifacts::artifact_get_preview,
157+
commands::artifacts::artifact_read_preview_base64,
158+
commands::artifacts::artifact_export,
159+
commands::artifacts::artifact_delete_or_expire,
160+
commands::artifacts::append_content_block,
161+
commands::artifacts::get_message_blocks,
154162
commands::fs_explorer::fs_list_dir,
155163
commands::fs_explorer::fs_read_text_file,
156164
commands::fs_explorer::fs_write_text_file,

src-tauri/src/services/artifacts/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
pub mod preview;
2+
pub mod service;
13
pub mod types;
24

5+
pub use preview::{ArtifactPreview, PreviewKind, PreviewerRegistry};
6+
pub use service::{ArtifactService, ExportOutcome};
37
pub use types::{
48
ArtifactMetadata, ArtifactOrigin, ArtifactRecord, ContentSafetyPolicy, PreviewState,
59
RetentionState,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
use super::types::{ArtifactRecord, ContentSafetyPolicy, PreviewState};
4+
5+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6+
#[serde(rename_all = "snake_case")]
7+
pub enum PreviewKind {
8+
Text,
9+
Csv,
10+
Pdf,
11+
Spreadsheet,
12+
Document,
13+
Image,
14+
DownloadOnly,
15+
}
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct ArtifactPreview {
19+
pub kind: PreviewKind,
20+
pub state: PreviewState,
21+
pub message_key: Option<String>,
22+
pub text: Option<String>,
23+
pub truncated: bool,
24+
}
25+
26+
pub struct PreviewerRegistry;
27+
28+
impl PreviewerRegistry {
29+
pub fn preview_for(
30+
artifact: &ArtifactRecord,
31+
bytes: Option<&[u8]>,
32+
policy: &ContentSafetyPolicy,
33+
) -> ArtifactPreview {
34+
let kind = match artifact.media_type.as_str() {
35+
"text/plain" | "text/markdown" | "application/json" => PreviewKind::Text,
36+
"text/csv" => PreviewKind::Csv,
37+
"application/pdf" => PreviewKind::Pdf,
38+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
39+
PreviewKind::Spreadsheet
40+
}
41+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
42+
PreviewKind::Document
43+
}
44+
"image/png" | "image/jpeg" | "image/webp" | "image/gif" => PreviewKind::Image,
45+
_ => PreviewKind::DownloadOnly,
46+
};
47+
if artifact.byte_size > policy.max_preview_bytes {
48+
return ArtifactPreview {
49+
kind: PreviewKind::DownloadOnly,
50+
state: PreviewState::Unsupported,
51+
message_key: Some("richContent.preview.resourceLimit".to_string()),
52+
text: None,
53+
truncated: false,
54+
};
55+
}
56+
match kind {
57+
PreviewKind::Text | PreviewKind::Csv => {
58+
let text = bytes.and_then(|value| std::str::from_utf8(value).ok());
59+
match text {
60+
Some(value) => {
61+
let (text, truncated) = limit_text(value, policy);
62+
ArtifactPreview {
63+
kind,
64+
state: PreviewState::Ready,
65+
message_key: None,
66+
text: Some(text),
67+
truncated,
68+
}
69+
}
70+
None => ArtifactPreview {
71+
kind: PreviewKind::DownloadOnly,
72+
state: PreviewState::Failed,
73+
message_key: Some("richContent.preview.parseFailed".to_string()),
74+
text: None,
75+
truncated: false,
76+
},
77+
}
78+
}
79+
PreviewKind::DownloadOnly => ArtifactPreview {
80+
kind,
81+
state: PreviewState::Unsupported,
82+
message_key: Some("richContent.preview.unsupported".to_string()),
83+
text: None,
84+
truncated: false,
85+
},
86+
_ => ArtifactPreview {
87+
kind,
88+
state: PreviewState::Ready,
89+
message_key: None,
90+
text: None,
91+
truncated: false,
92+
},
93+
}
94+
}
95+
}
96+
97+
fn limit_text(value: &str, policy: &ContentSafetyPolicy) -> (String, bool) {
98+
let mut output = String::new();
99+
let mut lines = 0usize;
100+
for line in value.lines() {
101+
if lines == policy.max_text_preview_lines
102+
|| output.len() + line.len() + 1 > policy.max_text_preview_chars
103+
{
104+
return (output, true);
105+
}
106+
output.push_str(line);
107+
output.push('\n');
108+
lines += 1;
109+
}
110+
(output, false)
111+
}

0 commit comments

Comments
 (0)