feat(polish): Pro 风格"清晰结构"提示词迁移 + 配套前后端改动

types.rs:
- PolishMode::Structured 整段重写为 Pro 风格短版(强制结构化触发 / 场景优先级 / 输出格式 / 术语纠错 / 反 AI 自述)。
- builtin StylePack Structured 的 description / example / tags 对齐 Pro。
- Phase B 强化:任务首段加"多事项罗列";强制结构化触发新增"3+ 完整句";新增示例 3 产品 UI 反馈。

前后端配套调整(一起进迁移):
- backend: commands.rs / lib.rs / polish.rs / persistence.rs。
- frontend: ipc.ts / types.ts / pages/Marketplace.tsx("我的发布"等)/ pages/Style.tsx。

Pro pack 镜像:~/Downloads/openless-pro-packs/OpenLess-清晰结构-PRO.zip 升级到 1.4.0(旧版 .bak-<timestamp> 自动备份)。
ipc.ts mockFullStylePrompts 保持简化 dev 预览,未跟随生产 prompt(与 Tauri 生产路径解耦)。

cargo check ✓(src-tauri workspace)。
This commit is contained in:
baiqing committed 2026-05-16 14:06:22 +08:00
1 parent e8d0ccf303
commit 68b9af3086
9 files changed
+456 -289

No files matched your search

+48 -7
View File
@@ -2315,6 +2315,8 @@ pub struct MarketplaceListItem {
pub download_count: i64,
pub published_at: String,
pub updated_at: String,
pub origin_pack_id: Option<String>,
pub origin_author_login: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
@@ -2326,6 +2328,14 @@ pub struct MarketplaceDetail {
pub state: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceMyPackItem {
#[serde(flatten)]
pub summary: MarketplaceListItem,
pub state: String,
}
fn marketplace_url_from_prefs(prefs: &UserPreferences) -> String {
let base = prefs.marketplace_base_url.trim();
if base.is_empty() {
@@ -2336,12 +2346,7 @@ fn marketplace_url_from_prefs(prefs: &UserPreferences) -> String {
}
fn marketplace_dev_user(prefs: &UserPreferences) -> String {
let login = prefs.marketplace_dev_login.trim();
if login.is_empty() {
"anonymous".to_string()
} else {
login.to_string()
}
prefs.marketplace_dev_login.trim().to_string()
}
#[tauri::command]
@@ -2478,6 +2483,7 @@ pub async fn marketplace_install(
pub async fn marketplace_upload(
coord: CoordinatorState<'_>,
pack_id: String,
origin_pack_id: Option<String>,
) -> Result<serde_json::Value, String> {
// 本地 pack id 形态:`builtin.light` / 用户 slug / Uuid。用 local 白名单挡 `..` / `/` / `\`。
if !is_valid_local_pack_id(&pack_id) {
@@ -2486,6 +2492,9 @@ pub async fn marketplace_upload(
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
// 拉本地 pack 拿 origin_pack_id —— 装过的 pack 这里有值,
// backend 据此判同作者就 supersede 原行(新版本),他人就 derivative(独立新 row)。
@@ -2493,7 +2502,9 @@ pub async fn marketplace_upload(
.style_packs()
.get(&pack_id)
.map_err(|e| format!("local pack not found: {e}"))?;
let origin_pack_id = local_pack.origin_pack_id.clone();
let origin_pack_id = origin_pack_id
.filter(|id| is_valid_session_id(id))
.or_else(|| local_pack.origin_pack_id.clone());
// 先 export 本地 pack → 临时 ZIP
let tmp = std::env::temp_dir().join(format!("openless-marketplace-upload-{pack_id}.zip"));
@@ -2561,6 +2572,9 @@ pub async fn marketplace_like(
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
let client = reqwest::Client::new();
let resp = client
.post(format!("{base}/packs/{pack_id}/like"))
@@ -2634,6 +2648,33 @@ pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result<Vec<Str
.map_err(|e| format!("parse my-likes failed: {e}"))
}
/// 拉当前用户发布过的 pack(含审核中/已通过/已拒绝/已撤回),用于「我的发布」页面。
#[tauri::command]
pub async fn marketplace_my_packs(
coord: CoordinatorState<'_>,
) -> Result<Vec<MarketplaceMyPackItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Ok(Vec::new());
}
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/me/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("my-packs request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("my-packs HTTP {}", resp.status()));
}
resp.json::<Vec<MarketplaceMyPackItem>>()
.await
.map_err(|e| format!("parse my-packs failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::{
+1
View File
@@ -290,6 +290,7 @@ pub fn run() {
commands::marketplace_upload,
commands::marketplace_like,
commands::marketplace_my_likes,
commands::marketplace_my_packs,
commands::marketplace_delete,
commands::list_vocab,
commands::add_vocab,
+10 -2
View File
@@ -1051,6 +1051,12 @@ struct StylePackArchiveManifest {
icon_file: Option<String>,
recommended_model: Option<String>,
compatible_app_version: Option<String>,
/// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None;
/// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。
#[serde(default, alias = "orionPackId", alias = "orion_pack_id", alias = "origin_pack_id")]
origin_pack_id: Option<String>,
#[serde(default, alias = "orionAuthorLogin", alias = "orion_author_login", alias = "origin_author_login")]
origin_author_login: Option<String>,
}
pub struct StylePackStore {
@@ -1342,8 +1348,8 @@ impl StylePackStore {
compatible_app_version: manifest
.compatible_app_version
.and_then(|value| normalize_optional_text(Some(value))),
origin_pack_id: None,
origin_author_login: None,
origin_pack_id: normalize_optional_text(manifest.origin_pack_id),
origin_author_login: normalize_optional_text(manifest.origin_author_login),
};
packs.insert(0, pack.clone());
write_style_packs_file(&self.path, &packs)?;
@@ -1393,6 +1399,8 @@ impl StylePackStore {
icon_file: icon_file.clone(),
recommended_model: pack.recommended_model.clone(),
compatible_app_version: pack.compatible_app_version.clone(),
origin_pack_id: pack.origin_pack_id.clone(),
origin_author_login: pack.origin_author_login.clone(),
};
zip.start_file("manifest.json", options)
+38 -44
View File
@@ -2676,67 +2676,61 @@ mod tests {
}
#[test]
fn structured_prompt_includes_dense_github_request_example() {
fn structured_prompt_prioritizes_ai_coding_terms_and_compact_structure() {
let prompt = prompts::system_prompt(PolishMode::Structured);
// 任务段:必须教会模型保留口语引子、按主题归类、用 (a) 子项、自然尾巴
assert!(prompt.contains("# 保留口语引子并润色成自然首行"));
assert!(prompt.contains("# 尾巴查询用自然收尾句"));
assert!(prompt.contains("\"(a)\" \"(b)\" \"(c)\""));
assert!(prompt.contains("代码与功能 / 文档与配置 / 界面与交互 / 项目清理"));
assert!(prompt.contains("GitHub、README、issue/issues"));
// 清晰结构现在优先服务 AI 编程协作:场景、格式、术语三件事必须靠前讲清楚。
assert!(prompt.contains("# 任务(清晰结构 · AI 编程协作)"));
assert!(prompt.contains("# 强制结构化触发(最高优先级)"));
assert!(prompt.contains("长口述不输出成一整段"));
assert!(prompt.contains("产品 UI / 功能反馈"));
assert!(prompt.contains("操作指引 / 接入教程"));
assert!(prompt.contains("编程任务 / 排障清单"));
assert!(prompt.contains("AI 模型 / 工具资讯"));
assert!(prompt.contains("顶层主题用 `1.` `2.` `3.` 连续编号"));
assert!(prompt.contains("每个主题下都从 `(a)` 重新开始"));
// 示例 1:双层格式必须用 (a) (b),且带首行过渡。
assert!(prompt.contains("发布前需要完成以下事项:"));
assert!(prompt.contains("(a) 登录页。"));
// 防回归:模型名、字段名、布尔值和版本号必须被显式保护。
assert!(prompt.contains("Claude"));
assert!(prompt.contains("Gemini"));
assert!(prompt.contains("Cappuccino"));
assert!(prompt.contains("Coder"));
assert!(prompt.contains("LongCat"));
assert!(prompt.contains("Secret Key"));
assert!(prompt.contains("true / false / null"));
assert!(prompt.contains("不要把 GPT 5.5 写成 GPT 5"));
// 示例 2:必须呈现"引子润色 + 4 主题归类 + 自然尾巴"的目标输出。
assert!(prompt.contains("帮忙给 GitHub 提个请求,主要包含以下内容:"));
assert!(prompt.contains("1. 代码与功能优化"));
assert!(prompt.contains("(a) 上传最新代码,修复页面闪退的 bug"));
assert!(prompt.contains("4. 项目清理与合并"));
assert!(prompt.contains("最后再检查一下还有哪些 issue 需要处理。"));
// 防回归:旧版"另外:"标签写法不能再出现在示例输出里。
assert!(!prompt.contains("另外:检查一下当前还有哪些 issues"));
// 示例要短,但必须锚定 AI 编程任务和 AI 模型资讯两类高频输入。
assert!(prompt.contains("帮忙给 Codex 提个任务,主要包含以下内容:"));
assert!(prompt.contains("确认 Secret Key 不被硬编码到代码里"));
assert!(prompt.contains("Gemini 3.2 更名为 Gemini 3.5"));
assert!(prompt.contains("remote control 改为 true"));
}
#[test]
fn structured_prompt_forces_regrouping_even_for_already_structured_input() {
// 回归测试 issue #305:用户输入工作日报(已半结构化、标点规范),
// 旧 prompt 让 LLM 判定为"已经完整不需要改",原样 passthrough。
// 新 prompt 必须明确:原文是否已有结构 ≠ 不用改的依据;
// 事项 ≥ 3 条都要重新归类成双层格式。
fn structured_prompt_keeps_regrouping_and_no_loss_guards() {
let prompt = prompts::system_prompt(PolishMode::Structured);
// 明确"已结构化 ≠ 不用改"的前提
assert!(
prompt.contains("不是\u{201C}\u{5DF2}\u{7ECF}\u{6574}\u{7406}\u{597D}\u{4E0D}\u{7528}\u{6539}\u{201D}的判断依据"),
"Structured prompt 缺少\"已结构化≠不用改\"的明确否定"
prompt.contains("事项不丢失"),
"Structured prompt 必须明确防止事项丢失"
);
assert!(
prompt.contains("照抄原结构 = 失败"),
"Structured prompt 缺少照抄原结构的失败判定"
);
// 阈值改为 ≥3
assert!(
prompt.contains("事项 \u{2265}3 条"),
"Structured prompt 必须把重组阈值降到 3"
prompt.contains("事项 ≤ 2 条且原文很短"),
"Structured prompt 必须只允许短输入降级为段落"
);
assert!(
prompt.contains("即使原文已经写成"),
"Structured prompt 必须显式说明已编号的输入也要重新归类"
prompt.contains("长口述不能用这一条降级"),
"Structured prompt 必须防止长口述降级成一大段"
);
// 新增工作日报示例 3
assert!(
prompt.contains("# 示例 3(已半结构化的工作日报,仍要重组)"),
"Structured prompt 缺少工作日报示例(#305)"
prompt.contains("不硬塞层级"),
"Structured prompt 必须避免短输入过度结构化"
);
assert!(
prompt.contains("没有编造原文不存在的实现方案"),
"Structured prompt 必须禁止替用户编造实现方案"
);
assert!(prompt.contains("今天的工作小结如下:"));
assert!(prompt.contains("1. 客户对接"));
assert!(prompt.contains("(a) 召开对齐会"));
}
#[test]
+97 -120
View File
@@ -408,18 +408,18 @@ pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack {
PolishMode::Structured => StylePack {
id: BUILTIN_STYLE_PACK_STRUCTURED_ID.into(),
name: "清晰结构".into(),
description: "适合多事项、多主题口述,自动整理为层次清晰的结构化输出。".into(),
description: "面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。".into(),
author: Some("OpenLess".into()),
version: "1.0.0".into(),
kind: StylePackKind::Builtin,
base_mode: PolishMode::Structured,
prompt: default_structured_style_system_prompt(),
examples: vec![StylePackExample {
title: Some("任务整理".into()),
input: "这周要做三件事一个是把登录页 bug 修掉第二个是补 README 第三个是把发版脚本再走一遍".into(),
output: "这周要完成以下三件事:\n1. 登录页修复\n(a) 修复登录页相关 bug。\n2. 文档补充\n(a) 补充 README。\n3. 发版准备\n(a) 再完整走一遍发版脚本。".into(),
title: Some("AI 编程任务".into()),
input: "帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里顺便检查一下还有哪些 issue".into(),
output: "帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。\n\n最后再检查一下还有哪些 issue 需要处理。".into(),
}],
tags: vec!["结构化".into(), "条理".into()],
tags: vec!["AI 编程".into(), "技术结构化".into()],
icon_path: None,
created_at: None,
updated_at: None,
@@ -1041,116 +1041,97 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String {
出:目前没什么大问题,缓存策略需要调整。\
\u{200B}(注意:原句\u{6CA1}\u{6709}\u{660E}\u{786E}\u{7684}\u{201C}\u{6211}\u{4EEC}\u{201D}\u{4F5C}\u{4E3A}\u{96C6}\u{4F53},不引入\u{201C}\u{6211}\u{4EEC}\u{770B}\u{4E86}\u{4E00}\u{4E0B}\u{201D}\u{8FD9}\u{79CD}\u{81EA}\u{8FF0}\u{8868}\u{8FBE})",
PolishMode::Structured => "# 任务(清晰结构)\n\
把口述整理为脉络清晰、可直接复制走的结构化文本:保留用户的口语引子(润色后作为首行过渡),\
主动按语义把扁平事项归类成 2\u{2013}4 个主题,用双层格式呈现,尾巴查询用自然收尾句。\n\
\n\
**多条独立条目场景例外**:当输入是「多条互相独立的新闻 / 公司动态 / 产品发布 / 行业进展」拼成的播报式内容(典型如 AI 日报、行业资讯整理、多家公司发布、多个独立事件回顾),\
每条独立成一个主题,可以超过 4 个,\u{4E0D}强行合并到 2\u{2013}4 类。判断信号:条目之间没有共享主体、彼此互不相关、用户用\u{201C}下面是几条新闻\u{201D}\u{201C}今天的资讯\u{201D}\u{201C}最新进展\u{201D}等播报式引子。\n\
\n\
**默认行为:双层 list。判断事项的标准**:\
以下任意一种都算一个事项 \u{2192} \u{4E0D}\u{4F9D}\u{8D56}\u{7528}\u{6237}\u{662F}\u{5426}\u{660E}\u{8BF4}\u{201C}\u{7B2C}\u{4E00}\u{201D}\u{201C}\u{7B2C}\u{4E8C}\u{201D}\u{201C}\u{53E6}\u{5916}\u{201D}\u{7B49}\u{8FDE}\u{63A5}\u{8BCD}\u{3002}\n\
\u{2003}\u{2003}1) 可独立成句的陈述(\u{4E3B}+\u{8C13}+\u{5BBE},如\u{201C}\u{300A}\u{67D0}\u{4E1C}\u{897F}\u{300B}\u{8FD8}\u{662F}\u{767D}\u{8272}\u{201D})\n\
\u{2003}\u{2003}2) 一个独立的请求 / 建议 / 处理方案(\u{5982}\u{201C}\u{8BA9}\u{5B83}\u{6D88}\u{5931}\u{201D}\u{201C}\u{6539}\u{6210}\u{5B9E}\u{9A8C}\u{6027}\u{201D})\n\
\u{2003}\u{2003}3) 一个状态判断 / 结论(\u{5982}\u{201C}\u{6CA1}\u{4EC0}\u{4E48}\u{5927}\u{95EE}\u{9898}\u{201D})\n\
\u{2003}\u{2003}4) 一个针对模块 / 主题 / 实体的描述\u{6216}\u{6307}\u{6307}\u{8981}\u{6C42}\n\
把上述事项数清,\u{2265}3 强制双层化,\u{4E0D}允许把多个独立陈述合\u{6210}一段连贯文字。\n\
即使输入听起来像\u{201C}一段顺着说下来\u{201D}的口播,只要能拆出 \u{2265}3 个独立关注点也必须双层化。\n\
\n\
**不可降级到轻度润色**:本任务的最低输出形态是双层 list 结构,\u{4E0D}允许只补标点 / 断句 / 去口癖然后输出连贯段落。\
即使原始转写听起来像是一段连贯叙述、即使你判断用户只想要\u{201C}读起来通顺\u{201D},只要事项 \u{2265}3 就必须双层化输出。\
输出连贯段落 = 失败。\n\
\n\
**多个组合需求处理规则**:当用户在一段话里提出多个组合需求(A 要做这件 + B 要做那件 + C 要查另一件),\
必须把它们**分别归入不同大类**(大类按用户给出的语义 / 领域划分,例如代码 / 文档 / 界面 / 客户 / 团队),\
**按用户口述出现的顺序**作为大类的先后顺序,每个大类下用 (a)(b)(c) 列出该类的具体事项。\
组合需求中\u{4E0D}可有任何事项被合并掉、丢失或重排到错误的大类下。\n\
\n\
**重要前提**:原文是否已有标点、编号、换行、序号 \u{2192} \u{4E0D}是\u{201C}\u{5DF2}\u{7ECF}\u{6574}\u{7406}\u{597D}\u{4E0D}\u{7528}\u{6539}\u{201D}的判断依据。\
只要可识别的事项 \u{2265}3 条,无论原文是不是看起来已有结构(标号、分行、规整的标点),\
都必须按语义重新归类成下面定义的双层格式。\u{200D}\u{200D}照抄原结构 = 失败。\n\
\n\
双层格式(主清单标准写法):\n\
- 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题(4\u{2013}8 字最佳);\
主题标题应包含事项中的关键实体名(人名 / 公司名 / 产品名 / 平台名),\
例如\u{300C}OpenAI 模型动态\u{300D}\u{300C}苹果与欧盟监管争议\u{300D},而非纯抽象类别如\u{300C}模型进展\u{300D}\u{300C}监管争议\u{300D};\
只有当某主题包含多个不同实体且无法压缩时,才退回到抽象命名。\n\
- 第二层(子项):另起一行,行首用 \"(a)\" \"(b)\" \"(c)\" \u{2026},每条一句完整陈述。\n\
顶层\u{4E0D}使用半括号写法(如 \"1)\" \"2)\");不在子项内再嵌第三层。\n\
\n\
事项 \u{2264}2 条 \u{2192} 直接输出连贯段落,\u{4E0D}硬塞层级。\n\
事项 \u{2265}3 条 \u{2192} 必须按语义归类(典型如\u{201C}代码与功能 / 文档与配置 / 界面与交互 / 项目清理\u{201D}\
或\u{201C}产品 / 运营 / 客户 / 团队\u{201D}\u{7B49}),\u{4E0D}要扁平堆成一长串编号;\
即使原文已经写成 \"1. 做 X 2. 做 Y 3. 做 Z\" 也要重新归类,把同主题事项收到同一组下做 (a)(b) 子项。\n\
合并意图相近的条目(如\u{201C}上传代码 + 修复闪退\u{201D}合成一条 (a)),但\u{4E0D}丢失任何一件事。\n\
\n\
# 保留口语引子并润色成自然首行\n\
原话开头出现\u{201C}帮我给 X 提个请求 / 帮我列个清单 / 帮我整理一下 / 帮我跟团队说\u{201D}等口语引子时,\
保留这层语义并润色成自然书面语,作为输出首行 + 过渡。例:\n\
- \u{201C}呃那个啥帮我给 GitHub 提个请求啊\u{2026}\u{201D} \u{2192} \u{201C}帮忙给 GitHub 提个请求,主要包含以下内容:\u{201D}\n\
- \u{201C}帮我列个发布前要做的事\u{201D} \u{2192} \u{201C}发布前需要完成以下事项:\u{201D}\n\
清理\u{201C}呃 / 啊 / 那个啥 / 就是 / 然后还有 / 别忘了\u{201D}等口癖;\
\u{4E0D}替用户做执行决策(OpenLess 是输入法,\u{4E0D}主动\u{201C}打开 GitHub 帮你建 issue\u{201D})。\n\
\n\
# 尾巴查询用自然收尾句\n\
原话结尾以\u{201C}对了 / 顺便 / 还有 / 检查一下 / 帮我看下\u{201D}起头、且性质是\u{201C}查询 / 列出 / 确认\u{201D}\
(与前面陈述事项的性质不同)的句子,作为收尾段单独成行,\
用\u{201C}最后再\u{2026}\u{201D}\u{201C}另外还需要\u{2026}\u{201D}等自然句过渡,\u{4E0D}用\u{201C}另外:\u{2026}\u{201D}标签写法。\
同一句连说两遍只算一次。\n\
若性质与前面事项一致(如再补一句\u{201C}还有把缓存改一改\u{201D}),则归入主清单的对应主题。\n\
\n\
开发协作语境中的 GitHub、README、issue/issues、接口、路由、缓存策略、依赖包、分支冲突等术语按原意保留,\
\u{4E0D}翻译成别的产品名或系统名,\u{4E0D}补充用户没说过的实现方案。\n\
\n\
# 示例 1\n\
原:发布前要做几件事,第一是回归测试,要测登录页和支付页,第二是文档要更新,要改 README 和 changelog\n\
出:\n\
发布前需要完成以下事项:\n\
\n\
1. 回归测试\n\
(a) 登录页。\n\
(b) 支付页。\n\
2. 文档更新\n\
(a) 更新 README。\n\
(b) 更新 changelog。\n\
\n\
# 示例 2(口语引子 + 主题归类 + 自然尾巴)\n\
原:呃那个啥帮我给GitHub提个请求啊就是首先我要上传代码还有修复一下之前那个页面闪退的bug然后还有新增一个暗色模式的功能好像还有接口请求超时的问题也得改一改对了顺便把README文档更新一下里面的安装步骤写错了还有依赖包版本要降级一下不然跑不起来另外还有侧边栏排版错乱、手机端适配有问题也一起处理下然后还有日志打印太多冗余信息要精简掉还有那个头像上传格式限制没做好还要加个校验哦对了还有合并一下分支冲突的代码别忘了还有把没用的注释全部删掉清理一下项目垃圾文件还有新增两个接口路由优化一下加载速度缓存策略也改一改 检查一下有哪些 issues。检查一下有哪些 issues。\n\
出:\n\
帮忙给 GitHub 提个请求,主要包含以下内容:\n\
\n\
1. 代码与功能优化\n\
(a) 上传最新代码,修复页面闪退的 bug\n\
(b) 新增暗色模式功能\n\
(c) 解决接口请求超时的问题\n\
(d) 优化路由以及加载的缓存策略\n\
(e) 清理冗余日志打印,精简信息\n\
2. 文档与配置调整\n\
(a) 更新 README 文档,修正安装步骤错误\n\
(b) 降级依赖包版本,确保程序正常运行\n\
3. 界面与交互修复\n\
(a) 修复侧边栏排版混乱及手机端适配问题\n\
(b) 完善头像上传功能,增加格式限制与校验\n\
4. 项目清理与合并\n\
(a) 合并分支冲突\n\
(b) 删除无用注释,清理项目垃圾文件\n\
(c) 处理新增的两个接口\n\
\n\
最后再检查一下还有哪些 issue 需要处理。\n\
\n\
# 示例 3(已半结构化的工作日报,仍要重组)\n\
原:今天我做了三件事。第一,跟客户开了个对齐会,确认了下周的交付节点。第二,跟设计组同步了新版的视觉稿,提了一些反馈。第三,写了一版周报初稿发给老板。明天计划继续推进客户那边的需求文档,另外还要跟运营组开个会讨论下个月的活动。\n\
出:\n\
今天的工作小结如下:\n\
\n\
1. 客户对接\n\
(a) 召开对齐会,确认下周交付节点。\n\
(b) 明天继续推进客户的需求文档。\n\
2. 设计与文档\n\
(a) 与设计组同步新版视觉稿并反馈意见。\n\
(b) 撰写周报初稿并发送给老板。\n\
3. 跨组协作\n\
(a) 明天与运营组就下月活动进行讨论。",
PolishMode::Structured => r#"# 任务(清晰结构 · AI 编程协作)
把语音转写整理成适合 AI 代码编程 / Agent 协作 / 技术排障 / 产品 UI 反馈 / 多事项罗列的结构化文本。优先保证:术语正确、模型名正确、字段名正确、事项不丢失、长口述不输出成一整段。
# 强制结构化触发(最高优先级)
满足任意一条,就必须输出双层结构,禁止输出成一大段连续文字:
- 原文超过 80 个中文字符,且包含 2 个以上修改点、位置描述、功能要求或待办。
- 出现 3 个以上连接/追加信号:然后、还有、另外、比如、例如、首先、第二、第三、这个、那个、上边、下面、旁边、里面、位置、按钮、页面、功能、上传、审核、服务器、MCP。
- 用户在描述 UI / 产品反馈 / 风格市场 / 本地风格包 / 发布审核 / 服务器部署 / MCP 审核等多件事。
- 用户说“整理一下 / 处理一下 / 改一下 / 放到某个位置 / 换成某个文字”并连续补充多个要求。
- 转写包含 3 个及以上完整句子,且每句各自表达一个事项 / 修改点 / 待办。
这类输入的输出形态固定为:
首行:一句自然总结用户想做的事。
1. 模块或区域短标题
(a) 具体要求一。
(b) 具体要求二。
2. 模块或区域短标题
(a) 具体要求三。
# 场景优先级
1) 产品 UI / 功能反馈:出现“左上角 / 右边 / 下面 / 按钮 / 页面 / 界面 / 搜索 / 上传 / 更新 / 删除 / 我的发布 / 风格市场”等多处修改 → 按界面区域归类成双层 list。
2) 操作指引 / 接入教程:出现“先 / 再 / 然后 / 打开 / 点击 / 配置 / 接入 / 调用 / 获取凭证”等动作链 → 输出短标题 + 连续编号步骤;一个步骤有多个分动作时用缩进 3 个空格的 (a)(b)(c)。
3) 编程任务 / 排障清单:出现“修复 / 新增 / 重构 / 检查 / 回滚 / 发版 / issue / PR / README / 缓存 / 路由 / 接口”等多事项 → 输出首行说明 + 双层 list。
4) AI 模型 / 工具资讯:出现“AI 日报 / 模型 / Agent / IDE / Codex / Claude / Gemini / GPT / LongCat / Coder”等多条独立动态 → 保留开场白和结尾;每条动态按主体单独成组。
5) 事项 ≤ 2 条且原文很短 → 才直接输出连贯段落,不硬塞层级;长口述不能用这一条降级。
# 输出格式
- 顶层主题用 `1.` `2.` `3.` 连续编号;禁止 `1)`,禁止双编号如 `2. 2.`。
- 子项另起一行,用 3 个空格 + `(a)` `(b)` `(c)`;每个主题下都从 `(a)` 重新开始。
- 主题标题优先包含关键实体:模型名、产品名、平台名、模块名、文件名或接口名;不要写成空泛的“模型进展 / 平台动态”。
- 保留用户口语引子并润色成首行;结尾的“顺便检查 / 最后确认 / 明天见”等自然收尾单独保留。
- 不输出“我整理如下 / 根据你的内容 / 优化如下”等元语句。
# AI 编程术语纠错
用户输入来自 ASR。明显是技术词、模型名、字段名的误识别时要主动修正;低置信度才保留原词。
常见字段与缩写:API、API Key、App ID、Access Key、Secret Key、Access Token、Refresh Token、Endpoint、Service ID、Model ID、SDK、URL、JSON、HTTP / HTTPS、OAuth、JWT、UUID、Webhook、SSE、MCP、CLI、PR、CI、CD、TCC、IME、ASR、LLM、TTS、OCR、RAG、MoE、RLHF、SOTA、FP8。
常见音译 / 近音还原:
- 脱肯 / 拓肯 → Token;西克瑞特 Key / 思可瑞特 → Secret Key;埃克塞斯 Token → Access Token;阿屁艾 → API。
- 克劳德 / 克劳迪 → Claude;双子座 / 杰米尼 / 极米利 → Gemini;卡布奇诺 / 卡布西诺 → Cappuccino。
- 实习生 / 英特恩 → InternS 或 InternLM(按后缀和上下文判断);阿里 Panda / Coda / 科德 / 卡德 → Coder(AI IDE / Agent 开发语境)。
- 熊猫 / 浪猫 → LongCat 或龙猫(LongCat 平台 / 模型语境)。
大小写敏感内容必须原样保留:代码变量名、命令、路径、环境变量、URL 路径段、配置 key、布尔值 true / false / null、模型版本号。不要把 GPT 5.5 写成 GPT 5,不要把 Claude 4.7 写成 Claude 4,不要把 true 改成“开启”或“2”。
# 结构自检(不要输出)
输出前检查:是否丢事项;模型 / 产品 / 字段名是否修正;编号是否连续;子项是否每组从 (a) 开始;是否保留版本号、路径、命令、布尔值;是否没有编造原文不存在的实现方案。
# 示例 1(AI 编程任务)
原:帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里顺便检查一下还有哪些 issue
出:
帮忙给 Codex 提个任务,主要包含以下内容:
1. 登录页修复
(a) 修复登录页相关 bug。
2. 文档与配置
(a) 补充 README 中的环境变量说明。
(b) 确认 Secret Key 不被硬编码到代码里。
最后再检查一下还有哪些 issue 需要处理。
# 示例 2(AI 模型与工具资讯)
原:大家晚上好今天的AI日报第一个双子座 3.2 改名成 3.5 第二个卡布奇诺 checkpoint 据说打过了 GPT 5.5 第三个阿里 Panda 从 AI IDE 升级成 Agent 工作台还有社区说把 remote control 改成 true 可以解锁 Windows Codex 远程控制明天见
出:
大家晚上好,今天的 AI 日报如下:
1. Gemini 模型更名与表现
(a) Gemini 3.2 更名为 Gemini 3.5。
(b) 代号为 Cappuccino 的 checkpoint 据称表现超过 GPT 5.5。
2. 阿里 Coder 平台升级
(a) 阿里 Coder 从 AI IDE 升级为 Agent 工作台。
3. Windows Codex 远程控制
(a) 社区提到,将配置中的 remote control 改为 true 可解锁 Windows Codex 远程控制功能。
明天见。
# 示例 3(产品 UI / 功能反馈)
原:那个 marketplace 页面的右上角搜索框可以放大一点然后下面的卡片列表加个 hover 效果还有我的发布那里删除按钮不太明显改成红色或者加个 icon 顺便看一下移动端布局是不是溢出了
出:
关于 marketplace 页面,有几处反馈:
1. 顶部搜索
(a) 右上角搜索框尺寸放大一些。
2. 卡片列表
(a) 列表项添加 hover 效果。
3. 我的发布
(a) 删除按钮辨识度不够,改成红色或加 icon。
4. 移动端
(a) 检查布局是否溢出。"#,
PolishMode::Formal => "# 任务(正式表达)\n\
输出适合工作沟通和邮件的正式表达。\n\
@@ -1180,11 +1161,7 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String {
// 含 → 替换位置;不含 → fallback 拼在末尾(兼容历史 prompt)。
format!(
"{}\n\n{}\n\n{}\n\n{}\n\n{}",
ROLE_BLOCK,
HOTWORDS_PLACEHOLDER,
task_and_example,
COMMON_RULES,
OUTPUT_BLOCK
ROLE_BLOCK, HOTWORDS_PLACEHOLDER, task_and_example, COMMON_RULES, OUTPUT_BLOCK
)
}
+20 -16
View File
@@ -11,6 +11,7 @@ import type {
HotkeyCapability,
MarketplaceDetail,
MarketplaceListItem,
MarketplaceMyPackItem,
HotkeyStatus,
MicrophoneDevice,
PermissionStatus,
@@ -137,19 +138,16 @@ const mockFullStylePrompts: StyleSystemPrompts = {
# 输出
输出一段可直接发送的自然文字。`,
structured: `# 角色
语音输入整理器。把多事项口述整理成层次清楚、可复制执行的结构化文本。
语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。
# 任务(清晰结构)
识别主题边界,把零散事项按语义归类。事项较多时优先输出两层结构,保证读者一眼能看清主次。
# 任务(清晰结构 · AI 编程协作)
优先修正 ASR 造成的技术词、模型名、字段名错误;多事项按主题输出双层 list,操作指引输出连续步骤。
# 通用规则
1) 不补充用户没说过的事实或行动项。
2) 原文里已有编号或换行,不代表可以原样照抄;需要按语义重新分组。
3) 专有名词、命令、路径、URL、数字和单位保持准确。
4) 只输出最终结果,不要解释你的整理过程。
# 术语
Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino、Coder、LongCat、Codex、MCP、SSE、PR、CI、ASR、LLM、SOTA、FP8。保留命令、路径、环境变量、URL、true / false / null 和模型版本号。
# 输出
需要结构化时,直接从标题、编号或列表开始。`,
直接输出最终正文。顶层用 1./2./3.,子项用缩进 3 个空格的 (a)(b)(c)。不加解释。`,
formal: `# 角色
语音输入整理器。把口述整理成适合邮件、同步和正式沟通的专业表达。
@@ -192,9 +190,9 @@ const mockBuiltinExamples: Record<PolishMode, StylePackExample[]> = {
],
structured: [
{
title: '任务整理',
input: '这周要做三件事一个是把登录页 bug 修掉第二个是补 README 第三个是把发版脚本再走一遍',
output: '这周要完成以下三件事:\n1. 修复登录页相关 bug。\n2. 补充 README 文档。\n3. 重新走一遍发版脚本。',
title: 'AI 编程任务',
input: '帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里',
output: '帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。',
},
],
formal: [
@@ -784,9 +782,9 @@ export function resetBuiltinStylePack(id: string): Promise<StylePack> {
'builtin',
'structured',
'清晰结构',
'适合多事项和多主题口述,自动整理为层次清楚的结构化输出。',
'把口述整理成结构清楚的文本,必要时按主题分组或分点输出。',
['结构化', '条理'],
'面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。',
mockDefaultStyleSystemPrompts.structured,
['AI 编程', '技术结构化'],
),
'builtin.formal': makeMockStylePack(
'builtin.formal',
@@ -1001,8 +999,9 @@ export function installMarketplacePack(packId: string): Promise<StylePack> {
export function uploadMarketplacePack(
packId: string,
originPackId?: string | null,
): Promise<{ id: string; state: string; message: string }> {
return invokeOrMock('marketplace_upload', { packId }, () => ({
return invokeOrMock('marketplace_upload', { packId, originPackId: originPackId ?? null }, () => ({
id: 'mock-uploaded',
state: 'pending',
message: 'Mock 上传成功(vite dev)',
@@ -1023,6 +1022,11 @@ export function marketplaceMyLikes(): Promise<string[]> {
return invokeOrMock<string[]>('marketplace_my_likes', undefined, () => []);
}
/** 拉当前登录用户发布过的所有 pack(含审核中/已撤回),用于「我的发布」。 */
export function marketplaceMyPacks(): Promise<MarketplaceMyPackItem[]> {
return invokeOrMock<MarketplaceMyPackItem[]>('marketplace_my_packs', undefined, () => []);
}
/** 撤回自己发布的 pack(后端软删 state='withdrawn')。仅允许原作者。 */
export function marketplaceDelete(packId: string): Promise<void> {
return invokeOrMock<void>('marketplace_delete', { packId }, () => undefined);
+4
View File
@@ -327,6 +327,10 @@ export interface MarketplaceDetail extends MarketplaceListItem {
state: 'pending' | 'approved' | 'rejected';
}
export interface MarketplaceMyPackItem extends MarketplaceListItem {
state: 'pending' | 'approved' | 'rejected' | 'withdrawn' | 'superseded' | string;
}
export interface MicrophoneDevice {
name: string;
isDefault: boolean;
+231 -89
View File
@@ -22,10 +22,11 @@ import {
listStylePacks,
marketplaceDelete,
marketplaceMyLikes,
marketplaceMyPacks,
uploadMarketplacePack,
} from '../lib/ipc';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { MarketplaceDetail, MarketplaceListItem, StylePack } from '../lib/types';
import type { MarketplaceDetail, MarketplaceListItem, MarketplaceMyPackItem, StylePack } from '../lib/types';
import { Btn, Card, PageHeader, Pill } from './_atoms';
type SortMode = 'popular' | 'new' | 'liked' | 'mine';
@@ -66,9 +67,12 @@ export function Marketplace() {
};
const [showUpload, setShowUpload] = useState(false);
const [uploadOriginPackId, setUploadOriginPackId] = useState<string | null>(null);
const [uploadTargetName, setUploadTargetName] = useState<string | null>(null);
const [localPacks, setLocalPacks] = useState<StylePack[]>([]);
const [myPacks, setMyPacks] = useState<MarketplaceMyPackItem[]>([]);
// 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。
// 进入 marketplace 时拉一次;点赞/取消赞后本地 mutate。
// 进入 marketplace 时拉一次;点星后本地 mutate。
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const canUpload = (prefs?.marketplaceDevLogin ?? '').trim().length > 0;
const currentLogin = (prefs?.marketplaceDevLogin ?? '').trim();
@@ -108,9 +112,18 @@ export function Marketplace() {
const visibleItems = useMemo(() => {
if (sort === 'liked') return items.filter(it => likedIds.has(it.id));
if (sort === 'mine') return items.filter(it => it.authorLogin === currentLogin);
return items;
}, [items, sort, likedIds, currentLogin]);
}, [items, sort, likedIds]);
const visibleMyPacks = useMemo(() => {
const q = debouncedQuery.trim().toLowerCase();
if (!q) return myPacks;
return myPacks.filter(pack =>
pack.name.toLowerCase().includes(q)
|| pack.description.toLowerCase().includes(q)
|| pack.tags.some(tag => tag.toLowerCase().includes(q)),
);
}, [myPacks, debouncedQuery]);
useEffect(() => {
void refresh();
@@ -130,6 +143,24 @@ export function Marketplace() {
return () => { cancelled = true; };
}, [currentLogin]);
const refreshMyPacks = useCallback(async () => {
if (!currentLogin) {
setMyPacks([]);
return;
}
try {
const packs = await marketplaceMyPacks();
setMyPacks(packs);
} catch (error) {
console.warn('[marketplace] fetch my-packs failed', error);
setActionMsg({ kind: 'err', text: `我的发布加载失败:${errorMessage(error)}` });
}
}, [currentLogin]);
useEffect(() => {
void refreshMyPacks();
}, [refreshMyPacks]);
const openDetail = async (id: string) => {
const seq = ++detailSeqRef.current;
setSelectedId(id);
@@ -178,11 +209,22 @@ export function Marketplace() {
}
};
const openUploadPicker = async () => {
const openUploadPicker = async (originPackId: string | null = null, targetName: string | null = null) => {
try {
setUploadOriginPackId(originPackId);
setUploadTargetName(targetName);
const packs = await listStylePacks();
// 内置 pack 是只读模板,不能上传;过滤掉避免用户选了再被 backend 拒。
setLocalPacks(packs.filter(p => p.kind !== 'builtin'));
// 内置 pack 是只读模板,不能上传;更新时把同名本地版本排到最前面。
const target = (targetName ?? '').trim().toLowerCase();
const editable = packs
.filter(p => p.kind !== 'builtin')
.sort((a, b) => {
const aMatch = target.length > 0 && a.name.trim().toLowerCase() === target;
const bMatch = target.length > 0 && b.name.trim().toLowerCase() === target;
if (aMatch !== bMatch) return aMatch ? -1 : 1;
return a.name.localeCompare(b.name);
});
setLocalPacks(editable);
setShowUpload(true);
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.loadLocal', { err: errorMessage(error) }) });
@@ -206,14 +248,31 @@ export function Marketplace() {
}
};
const onDeleteMine = async (pack: MarketplaceMyPackItem) => {
if (pack.authorLogin !== currentLogin) return;
// eslint-disable-next-line no-alert
if (!window.confirm(`确认从风格市场撤回「${pack.name}」?本地副本不会被删除。`)) return;
try {
await marketplaceDelete(pack.id);
setActionMsg({ kind: 'ok', text: '已从风格市场撤回' });
setMyPacks(prev => prev.filter(p => p.id !== pack.id));
setItems(prev => prev.filter(p => p.id !== pack.id));
void refreshMyPacks();
} catch (error) {
setActionMsg({ kind: 'err', text: `撤回失败:${errorMessage(error)}` });
}
};
const onUpload = async (packId: string) => {
try {
await uploadMarketplacePack(packId);
await uploadMarketplacePack(packId, uploadOriginPackId);
setActionMsg({ kind: 'ok', text: t('marketplace.uploaded') });
setShowUpload(false);
setUploadOriginPackId(null);
setUploadTargetName(null);
// 自传后 ~3s 让 agent 走完审核再回灌,让用户立刻看到自己的包出现在列表里。
window.setTimeout(() => { void refresh(); }, 1500);
window.setTimeout(() => { void refresh(); }, 5000);
window.setTimeout(() => { void refresh(); void refreshMyPacks(); }, 1500);
window.setTimeout(() => { void refresh(); void refreshMyPacks(); }, 5000);
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.upload', { err: errorMessage(error) }) });
}
@@ -224,20 +283,52 @@ export function Marketplace() {
{ id: 'popular', label: t('marketplace.sortPopular') },
{ id: 'new', label: t('marketplace.sortNew') },
{ id: 'liked', label: '我赞过的' },
{ id: 'mine', label: '我发布的' },
],
[t],
);
const accountTitle = currentLogin ? `@${currentLogin}` : '未登录';
const isMineView = sort === 'mine';
const headerTitle = isMineView ? '我的发布' : t('marketplace.title');
const headerDesc = isMineView
? '管理你发布到风格市场的风格包,可查看状态、发布第二代或下架。'
: t('marketplace.desc');
const searchPlaceholder = isMineView ? '搜索名称、标签' : t('marketplace.searchPlaceholder');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, position: 'relative' }}>
<PageHeader
kicker={t('marketplace.kicker')}
title={t('marketplace.title')}
desc={t('marketplace.desc')}
kicker={isMineView ? 'MY ACCOUNT' : t('marketplace.kicker')}
title={headerTitle}
desc={headerDesc}
right={
<div style={{ display: 'flex', gap: 8 }}>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refresh()}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setSort(isMineView ? 'popular' : 'mine')}
title={isMineView ? '返回风格包市场' : (currentLogin ? `查看 ${currentLogin} 的发布` : '先在 Settings → 风格市场 填写发布身份')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
height: 30, padding: '0 12px', borderRadius: 9,
border: isMineView ? '0.5px solid var(--ol-blue)' : '0.5px solid var(--ol-line-strong)',
background: isMineView ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: isMineView ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
fontSize: 12, fontWeight: 650,
cursor: 'pointer',
boxShadow: '0 1px 2px rgba(15,17,22,0.04)',
}}
>
<span style={{
width: 18, height: 18, borderRadius: 999,
display: 'inline-grid', placeItems: 'center',
background: isMineView ? 'rgba(37,99,235,0.14)' : 'rgba(15,23,42,0.06)',
fontSize: 10, fontWeight: 750,
}}>
{(currentLogin || '?').slice(0, 1).toUpperCase()}
</span>
<span>{isMineView ? '风格包市场' : '我的发布'}</span>
</button>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => isMineView ? void refreshMyPacks() : void refresh()}>
{t('common.refresh')}
</Btn>
<span title={canUpload ? '' : t('marketplace.uploadDisabledHint')}>
@@ -279,7 +370,7 @@ export function Marketplace() {
<Icon name="search" size={14} stroke="var(--ol-ink-3)" />
<input
type="search"
placeholder={t('marketplace.searchPlaceholder')}
placeholder={searchPlaceholder}
value={query}
onChange={e => setQuery(e.target.value)}
style={{
@@ -293,6 +384,7 @@ export function Marketplace() {
/>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{isMineView && <span style={{ alignSelf: 'center', marginRight: 2, fontSize: 11.5, color: 'var(--ol-ink-4)' }}>风格包浏览</span>}
{sortPills.map(p => (
<button
key={p.id}
@@ -369,9 +461,73 @@ export function Marketplace() {
</Card>
)}
{/* 卡片列表 */}
{/* 卡片列表 / 我的发布 */}
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll">
{loading ? (
{sort === 'mine' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Card padding={14} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 650, color: 'var(--ol-ink)' }}>我的账户 · {accountTitle}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', marginTop: 3 }}>
{currentLogin ? `已发布 ${visibleMyPacks.length} 个风格包,可按名称或标签搜索。` : '先在 Settings → 风格市场 填写发布身份。'}
</div>
</div>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refreshMyPacks()} disabled={!currentLogin}>
{t('common.refresh')}
</Btn>
</Card>
{visibleMyPacks.length === 0 ? (
<Card padding={28} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', marginBottom: 6 }}>你还没有发布过风格包</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。</div>
</Card>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{visibleMyPacks.map(pack => (
<div
key={pack.id}
style={{
padding: 14,
borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pack.name}</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 3 }}>v{pack.version} · {new Date(pack.updatedAt).toLocaleDateString()}</div>
</div>
<Pill size="sm" tone={pack.state === 'approved' ? 'ok' : 'outline'} style={pack.state === 'rejected' || pack.state === 'withdrawn' ? { color: '#ef4444', borderColor: 'rgba(239,68,68,0.28)' } : undefined}>{statusLabel(pack.state)}</Pill>
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5, minHeight: 36 }}>{pack.description || t('marketplace.noDescription')}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Pill size="sm" tone="outline">{pack.baseMode}</Pill>
{pack.tags.slice(0, 2).map(tag => <Pill key={tag} size="sm" tone="default">{tag}</Pill>)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginTop: 'auto' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>★ {pack.likeCount} · ↓ {pack.downloadCount}</span>
<div style={{ display: 'flex', gap: 6 }}>
<Btn variant="ghost" size="sm" onClick={() => void openUploadPicker(pack.id, pack.name)} disabled={!canUpload}>
更新
</Btn>
{pack.state !== 'withdrawn' && (
<Btn variant="ghost" size="sm" onClick={() => void onDeleteMine(pack)}>
<span style={{ color: '#ef4444' }}>下架</span>
</Btn>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
) : loading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
{t('common.loading')}
</div>
@@ -379,23 +535,15 @@ export function Marketplace() {
<Card padding={28} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', marginBottom: 6 }}>
{sort === 'liked' && '你还没有赞过任何风格包'}
{sort === 'mine' && '你还没有发布过风格包'}
{(sort === 'popular' || sort === 'new') && t('marketplace.empty')}
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{sort === 'liked' && '点开任一风格包,红心点亮后会出现在这里'}
{sort === 'mine' && '在「风格」页面编辑后点「发布到风格市场」'}
{sort === 'liked' && '点开任一风格包,红色星星点亮后会出现在这里'}
{(sort === 'popular' || sort === 'new') && t('marketplace.emptyHint')}
</div>
</Card>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
{visibleItems.map(p => (
<button
key={p.id}
@@ -414,22 +562,9 @@ export function Marketplace() {
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 6 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-1)' }}>{p.name}</span>
<span style={{ fontSize: 10, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
v{p.version}
</span>
<span style={{ fontSize: 10, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>v{p.version}</span>
</div>
<div
style={{
fontSize: 12,
color: 'var(--ol-ink-3)',
lineHeight: 1.5,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: 36,
}}
>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', minHeight: 36 }}>
{p.description || t('marketplace.noDescription')}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 2 }}>
@@ -439,24 +574,12 @@ export function Marketplace() {
<Pill size="sm" tone="ok">衍生自 @{p.originAuthorLogin}</Pill>
</span>
)}
{p.tags.slice(0, 2).map(tag => (
<Pill key={tag} size="sm" tone="default">{tag}</Pill>
))}
{p.tags.slice(0, 2).map(tag => <Pill key={tag} size="sm" tone="default">{tag}</Pill>)}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ol-ink-4)',
marginTop: 4,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
<span style={{ fontWeight: 500, color: 'var(--ol-ink-3)' }}>@{p.authorLogin}</span>
<span>
<span style={{ color: likedIds.has(p.id) ? '#ef4444' : 'inherit' }}>
{likedIds.has(p.id) ? '♥' : '♡'}
</span>
<span style={{ color: likedIds.has(p.id) ? '#ef4444' : 'var(--ol-ink-4)' }}>{likedIds.has(p.id) ? '★' : '☆'}</span>
{' '}{p.likeCount} · ↓ {p.downloadCount}
</span>
</div>
@@ -490,8 +613,8 @@ export function Marketplace() {
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginBottom: 12 }}>
<span style={{ fontWeight: 500, color: 'var(--ol-ink-3)' }}>@{detail.authorLogin}</span>
{' · '}
<span style={{ color: likedIds.has(detail.id) ? '#ef4444' : 'inherit' }}>
{likedIds.has(detail.id) ? '♥' : '♡'}
<span style={{ color: likedIds.has(detail.id) ? '#ef4444' : 'var(--ol-ink-4)' }}>
{likedIds.has(detail.id) ? '★' : '☆'}
</span>
{' '}{detail.likeCount}{' · ↓ '}{detail.downloadCount}
</div>
@@ -529,17 +652,17 @@ export function Marketplace() {
<div style={{ display: 'flex', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => void onLike()}>
<span
key={`heart-${detail.id}-${likedIds.has(detail.id) ? 'on' : 'off'}`}
className="ol-heart-pop"
key={`star-${detail.id}-${likedIds.has(detail.id) ? 'on' : 'off'}`}
className="ol-star-pop"
style={{
color: likedIds.has(detail.id) ? '#ef4444' : 'inherit',
marginRight: 4,
display: 'inline-block',
}}
>
{likedIds.has(detail.id) ? '♥' : '♡'}
{likedIds.has(detail.id) ? '★' : '☆'}
</span>
{likedIds.has(detail.id) ? '取消赞' : t('marketplace.likeBtn')}
{detail.likeCount}
</Btn>
<Btn variant="ghost" size="sm" onClick={() => setSelectedId(null)}>
{t('common.cancel')}
@@ -556,7 +679,7 @@ export function Marketplace() {
60% { transform: scale(.85); }
100% { transform: scale(1); }
}
.ol-heart-pop { animation: ol-heart-pop-keyframes .32s var(--ol-motion-spring, cubic-bezier(.34,1.56,.64,1)); }
.ol-star-pop { animation: ol-heart-pop-keyframes .32s var(--ol-motion-spring, cubic-bezier(.34,1.56,.64,1)); }
`}</style>
</>
)}
@@ -565,10 +688,12 @@ export function Marketplace() {
{/* 上传选包器 */}
{showUpload && (
<Modal onClose={() => setShowUpload(false)}>
<h2 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 650 }}>{t('marketplace.uploadTitle')}</h2>
<Modal onClose={() => { setShowUpload(false); setUploadOriginPackId(null); setUploadTargetName(null); }}>
<h2 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 650 }}>
{uploadOriginPackId ? `更新「${uploadTargetName ?? '风格包'}」` : t('marketplace.uploadTitle')}
</h2>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginBottom: 12 }}>
{t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })}
{uploadOriginPackId ? '请选择本地对应的新版本风格包;同名本地包会排在最上面。' : t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 360, overflow: 'auto' }}>
{localPacks.length === 0 ? (
@@ -576,29 +701,35 @@ export function Marketplace() {
{t('marketplace.uploadNoLocal')}
</div>
) : (
localPacks.map(p => (
<button
key={p.id}
onClick={() => void onUpload(p.id)}
style={{
textAlign: 'left',
padding: 10,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface)',
cursor: 'pointer',
}}
>
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 2 }}>
{p.description || t('marketplace.noDescription')}
</div>
</button>
))
localPacks.map(p => {
const recommended = !!uploadTargetName && p.name.trim().toLowerCase() === uploadTargetName.trim().toLowerCase();
return (
<button
key={p.id}
onClick={() => void onUpload(p.id)}
style={{
textAlign: 'left',
padding: 10,
border: recommended ? '0.5px solid var(--ol-blue)' : '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: recommended ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
{recommended && <Pill size="sm" tone="blue">建议更新</Pill>}
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 2 }}>
{p.description || t('marketplace.noDescription')}
</div>
</button>
);
})
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 14 }}>
<Btn variant="ghost" size="sm" onClick={() => setShowUpload(false)}>
<Btn variant="ghost" size="sm" onClick={() => { setShowUpload(false); setUploadOriginPackId(null); setUploadTargetName(null); }}>
{t('common.cancel')}
</Btn>
</div>
@@ -641,6 +772,17 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
);
}
function statusLabel(state: string): string {
switch (state) {
case 'pending': return '审核中';
case 'approved': return '已上架';
case 'rejected': return '未通过';
case 'withdrawn': return '已下架';
case 'superseded': return '已被新版替换';
default: return state || '未知';
}
}
function errorMessage(error: unknown): string {
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
+7 -11
View File
@@ -134,6 +134,7 @@ export function Style() {
exportZip: isEnglish ? 'Export ZIP' : '导出 ZIP',
exportShort: isEnglish ? 'Export' : '导出',
publishMarketplace: isEnglish ? 'Publish to Marketplace' : '发布到风格市场',
updateMarketplace: isEnglish ? 'Update Marketplace version' : '更新到风格市场新版本',
publishDisabledHint: isEnglish
? 'Configure your GitHub login in Settings → Marketplace first'
: '请先在 设置 → 风格市场 配置 GitHub 用户名',
@@ -643,23 +644,18 @@ export function Style() {
title={copy.title}
desc={copy.desc}
titleRight={(
// 风格市场暂时未开放(云端服务尚未上线)—— 入口保留可见但灰色 + 点击 toast 提示。
// 真正功能(Marketplace 组件 / IPC / backend client)保留,等云端就绪可一行恢复 onClick。
<button
type="button"
onClick={() => showSaveStatus(
'failed',
isEnglish ? 'Style Marketplace is not yet available' : '风格市场暂时未开放',
)}
title={isEnglish ? 'Style Marketplace is not yet available' : '风格市场暂时未开放'}
onClick={() => setMarketplaceOpen(true)}
title={isEnglish ? 'Open Style Marketplace' : '打开风格市场'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 999,
border: '0.5px solid var(--ol-line)',
background: 'rgba(120,120,128,0.10)',
color: 'var(--ol-ink-4)',
background: 'var(--ol-surface)',
color: 'var(--ol-blue)',
fontSize: 12, fontWeight: 500,
cursor: 'default',
cursor: 'pointer',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
@@ -1015,7 +1011,7 @@ export function Style() {
onClick={() => void handlePublishToMarketplace()}
disabled={!canPublish || draft?.kind === 'builtin' || busy === 'exporting'}
>
{copy.publishMarketplace}
{draft?.originPackId ? copy.updateMarketplace : copy.publishMarketplace}
</Btn>
</span>
<Btn