Merge pull request #444 from Open-Less/feat/marketplace-tab

feat: marketplace tab + 一键发布 + Settings 风格市场配置
This commit is contained in:
TRIP authored and GitHub committed 2026-05-15 15:00:34 +08:00
commit 74208322bb
16 files changed
+1026

No files matched your search

+240
View File
@@ -2273,6 +2273,246 @@ pub fn export_error_log(target_path: String) -> Result<(), String> {
#[allow(dead_code)]
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
// ─────────────────────────── marketplace (Phase A) ───────────────────────────
//
// 客户端跟 marketplace backend 的 HTTP 客户端封装。Backend URL 走 prefs
// `marketplace_base_url`(默认 http://127.0.0.1:8090 开发;生产用户填 https://api.<domain>)。
// dev-mode auth:用户在 Settings 填 `marketplace_dev_login`(GitHub 风格 username),
// 后续 OAuth 接入时换成 token 字段。
//
// 5 个 IPC:
// - marketplace_list 列表 + 搜索 + 排序
// - marketplace_detail 详情(含完整 prompt)
// - marketplace_install 下载 ZIP + 直接调 import_from_zip 装到本地
// - marketplace_upload 把本地某个 style pack export ZIP → multipart 上传
// - marketplace_like 点赞
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceListItem {
pub id: String,
pub slug: String,
pub name: String,
pub description: String,
#[serde(default)]
pub author_login: String,
pub version: String,
pub base_mode: String,
#[serde(default)]
pub tags: Vec<String>,
pub like_count: i64,
pub download_count: i64,
pub published_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceDetail {
#[serde(flatten)]
pub summary: MarketplaceListItem,
pub prompt: String,
pub state: String,
}
fn marketplace_url_from_prefs(prefs: &UserPreferences) -> String {
let base = prefs.marketplace_base_url.trim();
if base.is_empty() {
"http://127.0.0.1:8090".to_string()
} else {
base.trim_end_matches('/').to_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()
}
}
#[tauri::command]
pub async fn marketplace_list(
coord: CoordinatorState<'_>,
query: Option<String>,
sort: Option<String>,
limit: Option<u32>,
) -> Result<Vec<MarketplaceListItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let mut url = reqwest::Url::parse(&format!("{base}/packs"))
.map_err(|e| format!("invalid marketplace url: {e}"))?;
if let Some(q) = query.as_deref() {
if !q.trim().is_empty() {
url.query_pairs_mut().append_pair("q", q.trim());
}
}
if let Some(s) = sort.as_deref() {
if !s.trim().is_empty() {
url.query_pairs_mut().append_pair("sort", s.trim());
}
}
if let Some(n) = limit {
url.query_pairs_mut().append_pair("limit", &n.to_string());
}
let client = reqwest::Client::new();
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("marketplace HTTP {status}: {body}"));
}
let items: Vec<MarketplaceListItem> =
resp.json().await.map_err(|e| format!("parse failed: {e}"))?;
Ok(items)
}
#[tauri::command]
pub async fn marketplace_detail(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<MarketplaceDetail, String> {
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/packs/{pack_id}"))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
return Err(format!("marketplace HTTP {status}"));
}
resp.json::<MarketplaceDetail>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[tauri::command]
pub async fn marketplace_install(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<StylePack, String> {
// 安全校验:pack_id 来自远端 backend,可能含路径遍历 segment。
// 用跟 read_audio_recording 同样的 UUID-v4 白名单挡住 ../ / 绝对路径等。
// backend 当前用 Uuid::new_v4 生成所有 id,合法 id 必然匹配。
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let client = reqwest::Client::new();
let bytes = client
.get(format!("{base}/packs/{pack_id}/download"))
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("marketplace download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace HTTP error: {e}"))?
.bytes()
.await
.map_err(|e| format!("read body failed: {e}"))?;
// pack_id 已经过 UUID 白名单,拼临时文件路径安全。
let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip"));
std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?;
let result = coord
.style_packs()
.import_from_zip(&tmp)
.map_err(|e| e.to_string());
let _ = std::fs::remove_file(&tmp);
result
}
#[tauri::command]
pub async fn marketplace_upload(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<serde_json::Value, String> {
// 本地 style pack id 也是 Uuid::new_v4 字面,跟远端同形态。挡 path traversal。
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
// 先 export 本地 pack → 临时 ZIP
let tmp = std::env::temp_dir().join(format!("openless-marketplace-upload-{pack_id}.zip"));
coord
.style_packs()
.export_to_zip(&pack_id, &tmp)
.map_err(|e| format!("export local pack failed: {e}"))?;
let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?;
let _ = std::fs::remove_file(&tmp);
let client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(format!("{pack_id}.zip"))
.mime_str("application/zip")
.map_err(|e| format!("multipart build failed: {e}"))?;
let form = reqwest::multipart::Form::new().part("file", part);
let resp = client
.post(format!("{base}/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(30))
.multipart(form)
.send()
.await
.map_err(|e| format!("upload request failed: {e}"))?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("read body failed: {e}"))
.clone();
if !status.is_success() {
return Err(format!("upload HTTP {status}: {body}"));
}
serde_json::from_str::<serde_json::Value>(&body)
.map_err(|e| format!("parse upload response failed: {e}"))
}
#[tauri::command]
pub async fn marketplace_like(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<serde_json::Value, String> {
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
let client = reqwest::Client::new();
let resp = client
.post(format!("{base}/packs/{pack_id}/like"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("like request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("like HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::{
+5
View File
@@ -284,6 +284,11 @@ pub fn run() {
commands::delete_history_entry,
commands::clear_history,
commands::read_audio_recording,
commands::marketplace_list,
commands::marketplace_detail,
commands::marketplace_install,
commands::marketplace_upload,
commands::marketplace_like,
commands::list_vocab,
commands::add_vocab,
commands::remove_vocab,
+18
View File
@@ -622,6 +622,14 @@ pub struct UserPreferences {
/// 这种「文本档案多 + 录音不占盘」组合下精确控制。
#[serde(default)]
pub audio_recording_max_entries: Option<u32>,
/// Style Pack Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;
/// 用户在 Settings 里填生产 URL (如 https://api.openless-marketplace.com)。
#[serde(default)]
pub marketplace_base_url: String,
/// Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。
/// 上传 / 点赞需要带这个 header;空时上传被后端 401。
#[serde(default)]
pub marketplace_dev_login: String,
}
fn default_local_asr_model() -> String {
@@ -735,6 +743,10 @@ struct UserPreferencesWire {
record_audio_for_debug: bool,
#[serde(default)]
audio_recording_max_entries: Option<u32>,
#[serde(default)]
marketplace_base_url: String,
#[serde(default)]
marketplace_dev_login: String,
}
impl Default for UserPreferencesWire {
@@ -785,6 +797,8 @@ impl Default for UserPreferencesWire {
history_max_entries: prefs.history_max_entries,
record_audio_for_debug: prefs.record_audio_for_debug,
audio_recording_max_entries: prefs.audio_recording_max_entries,
marketplace_base_url: prefs.marketplace_base_url,
marketplace_dev_login: prefs.marketplace_dev_login,
}
}
}
@@ -857,6 +871,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
history_max_entries: wire.history_max_entries,
record_audio_for_debug: wire.record_audio_for_debug,
audio_recording_max_entries: wire.audio_recording_max_entries,
marketplace_base_url: wire.marketplace_base_url,
marketplace_dev_login: wire.marketplace_dev_login,
})
}
}
@@ -1210,6 +1226,8 @@ impl Default for UserPreferences {
history_max_entries: None,
record_audio_for_debug: false,
audio_recording_max_entries: None,
marketplace_base_url: String::new(),
marketplace_dev_login: String::new(),
}
}
}
@@ -15,6 +15,7 @@ import { Vocab } from '../pages/Vocab';
import { Style } from '../pages/Style';
import { Translation } from '../pages/Translation';
import { SelectionAsk } from '../pages/SelectionAsk';
import { Marketplace } from '../pages/Marketplace';
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
@@ -44,6 +45,7 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
{ id: 'history', icon: 'history', cmp: History },
{ id: 'vocab', icon: 'vocab', cmp: Vocab },
{ id: 'style', icon: 'style', cmp: Style },
{ id: 'marketplace', icon: 'cloud', cmp: Marketplace },
{ id: 'translation', icon: 'translate', cmp: Translation },
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
];
+35
View File
@@ -58,10 +58,40 @@ export const en: typeof zhCN = {
history: 'History',
vocab: 'Vocabulary',
style: 'Style',
marketplace: 'Marketplace',
translation: 'Translation',
selectionAsk: 'Ask',
localAsr: 'Models',
},
marketplace: {
kicker: 'MARKETPLACE',
title: 'Style Pack Marketplace',
desc: 'Browse community style packs, install in one click, like, and share your own.',
searchPlaceholder: 'Search name / description / tags…',
sortPopular: 'Popular',
sortNew: 'Newest',
uploadBtn: 'Upload',
uploadDisabledHint: 'Set your GitHub login in Settings → Marketplace first',
refreshBtn: 'Refresh',
empty: 'No style packs yet',
emptyHint: 'Try a different keyword, or upload your own',
loadFailed: 'Load failed: {{err}}',
noDescription: '(no description)',
installBtn: 'Install',
likeBtn: 'Like',
installed: 'Installed "{{name}}" locally',
uploaded: 'Uploaded — waiting for review',
uploadTitle: 'Pick a style pack to upload',
uploadHint: 'Uploading as {{login}}. Content goes to the cloud review queue.',
uploadNoLocal: 'No local style packs to upload',
errors: {
detail: 'Detail load failed: {{err}}',
install: 'Install failed: {{err}}',
like: 'Like failed: {{err}}',
upload: 'Upload failed: {{err}}',
loadLocal: 'Load local packs failed: {{err}}',
},
},
shell: {
shortcutLabel: 'Recording shortcut',
shortcutHint: 'Start / Stop',
@@ -368,6 +398,11 @@ export const en: typeof zhCN = {
startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.',
autoUpdateCheckLabel: 'Auto-check for updates',
autoUpdateCheckDesc: 'Check for new releases on main window launch and every 60 minutes. When off, only the manual "Check for updates" button in About works.',
marketplaceGroupTitle: 'Style Pack Marketplace',
marketplaceBaseUrlLabel: 'Backend URL',
marketplaceBaseUrlDesc: 'Marketplace backend HTTP URL. Blank = local dev default http://127.0.0.1:8090; production: https://api.<your-domain>.',
marketplaceDevLoginLabel: 'GitHub login (upload identity)',
marketplaceDevLoginDesc: 'Dev-mode identifies uploader by GitHub login. When blank, upload/like is disabled. Will be replaced by OAuth later.',
startupAtBoot: 'Launch at login',
startupAtBootDesc: 'Start OpenLess automatically when you sign in.',
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
+6
View File
@@ -60,6 +60,7 @@ export const ja: typeof zhCN = {
history: '履歴',
vocab: '語彙',
style: 'スタイル',
marketplace: 'マーケット',
translation: '翻訳',
selectionAsk: '選択追問',
localAsr: 'モデル設定',
@@ -370,6 +371,11 @@ export const ja: typeof zhCN = {
startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。',
autoUpdateCheckLabel: 'アップデートを自動チェック',
autoUpdateCheckDesc: 'メインウィンドウ起動時と 60 分ごとに新バージョンを確認します。オフ時は「バージョン情報」内の手動ボタンのみ有効。',
marketplaceGroupTitle: 'スタイルパックマーケット',
marketplaceBaseUrlLabel: 'バックエンド URL',
marketplaceBaseUrlDesc: 'マーケットプレイス バックエンド HTTP URL。空 = ローカル開発 http://127.0.0.1:8090;本番は https://api.<ドメイン>。',
marketplaceDevLoginLabel: 'GitHub ログイン名(アップロード ID)',
marketplaceDevLoginDesc: 'dev モードでは GitHub ログイン名でアップロード者を識別。空時はアップロード/いいね不可。',
startupAtBoot: '起動時に自動起動',
startupAtBootDesc: 'ログイン時に OpenLess を自動起動。',
startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
+6
View File
@@ -60,6 +60,7 @@ export const ko: typeof zhCN = {
history: '기록',
vocab: '어휘',
style: '스타일',
marketplace: '마켓',
translation: '번역',
selectionAsk: '선택 질문',
localAsr: '모델 설정',
@@ -370,6 +371,11 @@ export const ko: typeof zhCN = {
startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.',
autoUpdateCheckLabel: '자동 업데이트 확인',
autoUpdateCheckDesc: '메인 창 시작 시와 60분마다 새 버전을 확인합니다. 끄면 "정보" 패널의 수동 버튼만 동작합니다.',
marketplaceGroupTitle: '스타일 팩 마켓플레이스',
marketplaceBaseUrlLabel: '백엔드 URL',
marketplaceBaseUrlDesc: '마켓플레이스 백엔드 HTTP URL. 비워두면 로컬 개발 http://127.0.0.1:8090; 프로덕션은 https://api.<도메인>.',
marketplaceDevLoginLabel: 'GitHub 로그인 이름 (업로드 ID)',
marketplaceDevLoginDesc: 'dev 모드에서는 GitHub 로그인 이름으로 업로더 식별. 비워두면 업로드/좋아요 불가.',
startupAtBoot: '부팅 시 자동 시작',
startupAtBootDesc: '로그인 시 OpenLess 자동 시작.',
startupAtBootError: '자동 시작 전환 실패: {{message}}',
+35
View File
@@ -56,10 +56,40 @@ export const zhCN = {
history: '历史',
vocab: '词汇表',
style: '风格',
marketplace: '风格市场',
translation: '翻译',
selectionAsk: '划词追问',
localAsr: '模型设置',
},
marketplace: {
kicker: 'MARKETPLACE',
title: '风格包市场',
desc: '浏览社区风格包,一键安装到本地;点赞和上传你自己的风格包。',
searchPlaceholder: '搜索名称 / 描述 / 标签…',
sortPopular: '按热度',
sortNew: '最新',
uploadBtn: '上传',
uploadDisabledHint: '请先在 设置 → 风格市场 配置 GitHub 用户名',
refreshBtn: '刷新',
empty: '还没有风格包',
emptyHint: '换个搜索词,或自己上传一个分享给社区',
loadFailed: '加载失败:{{err}}',
noDescription: '(暂无描述)',
installBtn: '安装到本地',
likeBtn: '点赞',
installed: '已安装「{{name}}」到本地风格包',
uploaded: '上传成功,等待审核',
uploadTitle: '选择要上传的风格包',
uploadHint: '上传以 {{login}} 身份登录。包内容会发到云端审核队列。',
uploadNoLocal: '本地没有可上传的风格包',
errors: {
detail: '加载详情失败:{{err}}',
install: '安装失败:{{err}}',
like: '点赞失败:{{err}}',
upload: '上传失败:{{err}}',
loadLocal: '加载本地风格包失败:{{err}}',
},
},
shell: {
shortcutLabel: '录音快捷键',
shortcutHint: '开始 / 停止',
@@ -366,6 +396,11 @@ export const zhCN = {
startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。',
autoUpdateCheckLabel: '自动检查更新',
autoUpdateCheckDesc: '主窗口启动 + 后台每 60 分钟检查云端新版本。关闭后仅"关于"的手动按钮可用。',
marketplaceGroupTitle: '风格市场',
marketplaceBaseUrlLabel: '云端服务地址',
marketplaceBaseUrlDesc: '风格包市场后端 URL。留空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<你的域名>。',
marketplaceDevLoginLabel: 'GitHub 用户名(上传身份)',
marketplaceDevLoginDesc: 'dev 模式用 GitHub 用户名标识上传者;空时不能上传 / 点赞。后期接入 OAuth 后此字段废弃。',
startupAtBoot: '开机自启',
startupAtBootDesc: '登录系统时自动启动 OpenLess。',
startupAtBootError: '开机自启切换失败:{{message}}',
+35
View File
@@ -58,10 +58,40 @@ export const zhTW: typeof zhCN = {
history: '歷史',
vocab: '詞彙表',
style: '風格',
marketplace: '風格市場',
translation: '翻譯',
selectionAsk: '劃詞追問',
localAsr: '模型設置',
},
marketplace: {
kicker: 'MARKETPLACE',
title: '風格包市場',
desc: '瀏覽社群風格包,一鍵安裝到本機;點讚和上傳你自己的風格包。',
searchPlaceholder: '搜尋名稱 / 描述 / 標籤…',
sortPopular: '按熱度',
sortNew: '最新',
uploadBtn: '上傳',
uploadDisabledHint: '請先在 設定 → 風格市場 配置 GitHub 使用者名稱',
refreshBtn: '重新整理',
empty: '還沒有風格包',
emptyHint: '換個搜尋詞,或自己上傳一個分享給社群',
loadFailed: '載入失敗:{{err}}',
noDescription: '(暫無描述)',
installBtn: '安裝到本機',
likeBtn: '點讚',
installed: '已安裝「{{name}}」到本機風格包',
uploaded: '上傳成功,等待審核',
uploadTitle: '選擇要上傳的風格包',
uploadHint: '以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。',
uploadNoLocal: '本機沒有可上傳的風格包',
errors: {
detail: '載入詳情失敗:{{err}}',
install: '安裝失敗:{{err}}',
like: '點讚失敗:{{err}}',
upload: '上傳失敗:{{err}}',
loadLocal: '載入本機風格包失敗:{{err}}',
},
},
shell: {
shortcutLabel: '錄音快捷鍵',
shortcutHint: '開始 / 停止',
@@ -368,6 +398,11 @@ export const zhTW: typeof zhCN = {
startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。',
autoUpdateCheckLabel: '自動檢查更新',
autoUpdateCheckDesc: '主視窗啟動時 + 後台每 60 分鐘檢查雲端新版本。關閉後僅「關於」的手動按鈕可用。',
marketplaceGroupTitle: '風格市場',
marketplaceBaseUrlLabel: '雲端服務位址',
marketplaceBaseUrlDesc: '風格包市場後端 URL。留空 = 本機開發預設 http://127.0.0.1:8090;生產填 https://api.<你的網域>。',
marketplaceDevLoginLabel: 'GitHub 使用者名稱(上傳身份)',
marketplaceDevLoginDesc: 'dev 模式用 GitHub 使用者名稱識別上傳者;空時不能上傳 / 點讚。',
startupAtBoot: '開機自啓',
startupAtBootDesc: '登錄系統時自動啓動 OpenLess。',
startupAtBootError: '開機自啓切換失敗:{{message}}',
+62
View File
@@ -9,6 +9,8 @@ import type {
DictationSession,
DictionaryEntry,
HotkeyCapability,
MarketplaceDetail,
MarketplaceListItem,
HotkeyStatus,
MicrophoneDevice,
PermissionStatus,
@@ -101,6 +103,8 @@ let mockSettings: UserPreferences = {
historyMaxEntries: null,
recordAudioForDebug: false,
audioRecordingMaxEntries: null,
marketplaceBaseUrl: '',
marketplaceDevLogin: '',
};
const mockFullStylePrompts: StyleSystemPrompts = {
@@ -954,3 +958,61 @@ export async function exportErrorLog(suggestedFileName: string): Promise<string
}
export { isTauri };
// ── Marketplace (Phase A) ─────────────────────────────────────────────
// 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback
// 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。
const MOCK_MARKETPLACE: MarketplaceListItem[] = [
{
id: '00000000-0000-0000-0000-000000000001',
slug: 'demo-pack',
name: '示范风格包',
description: 'Mock 数据 - vite dev 模式下显示',
authorLogin: 'demo',
version: '1.0.0',
baseMode: 'structured',
tags: ['demo'],
likeCount: 12,
downloadCount: 50,
publishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
export function listMarketplace(
options: { query?: string; sort?: 'new' | 'popular'; limit?: number } = {},
): Promise<MarketplaceListItem[]> {
return invokeOrMock('marketplace_list', options, () => MOCK_MARKETPLACE);
}
export function fetchMarketplaceDetail(packId: string): Promise<MarketplaceDetail> {
return invokeOrMock('marketplace_detail', { packId }, () => ({
...MOCK_MARKETPLACE[0],
prompt: '# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。',
state: 'approved' as const,
}));
}
export function installMarketplacePack(packId: string): Promise<StylePack> {
return invokeOrMock('marketplace_install', { packId }, () => mockStylePacks[0]);
}
export function uploadMarketplacePack(
packId: string,
): Promise<{ id: string; state: string; message: string }> {
return invokeOrMock('marketplace_upload', { packId }, () => ({
id: 'mock-uploaded',
state: 'pending',
message: 'Mock 上传成功(vite dev)',
}));
}
export function likeMarketplacePack(
packId: string,
): Promise<{ likeCount: number; alreadyLiked: boolean }> {
return invokeOrMock('marketplace_like', { packId }, () => ({
likeCount: 13,
alreadyLiked: false,
}));
}
@@ -65,6 +65,8 @@ const previousPrefs: UserPreferences = {
historyMaxEntries: null,
recordAudioForDebug: false,
audioRecordingMaxEntries: null,
marketplaceBaseUrl: '',
marketplaceDevLogin: '',
};
const nextPrefs: UserPreferences = {
+24
View File
@@ -292,6 +292,30 @@ export interface UserPreferences {
/** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。
* 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */
audioRecordingMaxEntries: number | null;
/** Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<domain>。 */
marketplaceBaseUrl: string;
/** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */
marketplaceDevLogin: string;
}
export interface MarketplaceListItem {
id: string;
slug: string;
name: string;
description: string;
authorLogin: string;
version: string;
baseMode: PolishMode;
tags: string[];
likeCount: number;
downloadCount: number;
publishedAt: string;
updatedAt: string;
}
export interface MarketplaceDetail extends MarketplaceListItem {
prompt: string;
state: 'pending' | 'approved' | 'rejected';
}
export interface MicrophoneDevice {
+483
View File
@@ -0,0 +1,483 @@
// Marketplace.tsx — Style Pack Marketplace 浏览面板。
//
// Phase A 目标(goal 1.a-e):
// (a) 后端验证 — 通过 marketplace_* IPC 跟后端通信
// (b) 上传与拉取功能 — Install / Upload 按钮
// (c) 单独弹窗界面 — modal-style detail 卡片
// (d) 搜索框 — 顶部 input + server-side ?q=
// (e) 按排名自动推荐 — 默认 sort=popular
//
// 后端 URL 走 prefs.marketplaceBaseUrl,dev 模式默认 http://127.0.0.1:8090;
// 用户在 Settings 填生产 URL 后客户端自动切换。
// dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import {
fetchMarketplaceDetail,
installMarketplacePack,
likeMarketplacePack,
listMarketplace,
listStylePacks,
uploadMarketplacePack,
} from '../lib/ipc';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { MarketplaceDetail, MarketplaceListItem, StylePack } from '../lib/types';
import { Btn, Card, PageHeader, Pill } from './_atoms';
type SortMode = 'popular' | 'new';
export function Marketplace() {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
const [items, setItems] = useState<MarketplaceListItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [sort, setSort] = useState<SortMode>('popular');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<MarketplaceDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [localPacks, setLocalPacks] = useState<StylePack[]>([]);
const canUpload = (prefs?.marketplaceDevLogin ?? '').trim().length > 0;
// search 防抖 300ms
useEffect(() => {
const id = window.setTimeout(() => setDebouncedQuery(query), 300);
return () => window.clearTimeout(id);
}, [query]);
// 单调递增 seq 防 stale 响应覆盖:用户快速改 query / 切换 pack 时旧请求 response
// 可能晚于新请求到达,比较 seq 丢弃过期结果。
const reqSeqRef = useRef(0);
const detailSeqRef = useRef(0);
const refresh = useCallback(async () => {
const seq = ++reqSeqRef.current;
setLoading(true);
setLoadError(null);
try {
const list = await listMarketplace({ query: debouncedQuery, sort, limit: 50 });
if (seq !== reqSeqRef.current) return; // stale response
setItems(list);
} catch (error) {
if (seq !== reqSeqRef.current) return;
console.error('[marketplace] list failed', error);
setLoadError(errorMessage(error));
} finally {
if (seq === reqSeqRef.current) setLoading(false);
}
}, [debouncedQuery, sort]);
useEffect(() => {
void refresh();
}, [refresh]);
const openDetail = async (id: string) => {
const seq = ++detailSeqRef.current;
setSelectedId(id);
setDetail(null);
setDetailLoading(true);
try {
const d = await fetchMarketplaceDetail(id);
if (seq !== detailSeqRef.current) return; // stale: 用户已切到另一个 pack
setDetail(d);
} catch (error) {
if (seq !== detailSeqRef.current) return;
console.error('[marketplace] detail failed', error);
setActionMsg({ kind: 'err', text: t('marketplace.errors.detail', { err: errorMessage(error) }) });
setSelectedId(null);
} finally {
if (seq === detailSeqRef.current) setDetailLoading(false);
}
};
const onInstall = async () => {
if (!detail) return;
try {
await installMarketplacePack(detail.id);
setActionMsg({ kind: 'ok', text: t('marketplace.installed', { name: detail.name }) });
setSelectedId(null);
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.install', { err: errorMessage(error) }) });
}
};
const onLike = async () => {
if (!detail) return;
try {
const r = await likeMarketplacePack(detail.id);
setDetail({ ...detail, likeCount: r.likeCount });
setItems(prev => prev.map(p => (p.id === detail.id ? { ...p, likeCount: r.likeCount } : p)));
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.like', { err: errorMessage(error) }) });
}
};
const openUploadPicker = async () => {
try {
const packs = await listStylePacks();
setLocalPacks(packs);
setShowUpload(true);
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.loadLocal', { err: errorMessage(error) }) });
}
};
const onUpload = async (packId: string) => {
try {
await uploadMarketplacePack(packId);
setActionMsg({ kind: 'ok', text: t('marketplace.uploaded') });
setShowUpload(false);
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.upload', { err: errorMessage(error) }) });
}
};
const sortPills = useMemo<Array<{ id: SortMode; label: string }>>(
() => [
{ id: 'popular', label: t('marketplace.sortPopular') },
{ id: 'new', label: t('marketplace.sortNew') },
],
[t],
);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<PageHeader
kicker={t('marketplace.kicker')}
title={t('marketplace.title')}
desc={t('marketplace.desc')}
right={
<div style={{ display: 'flex', gap: 8 }}>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refresh()}>
{t('common.refresh')}
</Btn>
<span title={canUpload ? '' : t('marketplace.uploadDisabledHint')}>
<Btn
icon="cloud"
variant="blue"
size="sm"
onClick={() => void openUploadPicker()}
disabled={!canUpload}
>
{t('marketplace.uploadBtn')}
</Btn>
</span>
</div>
}
/>
{/* 顶部搜索 + 排序 */}
<div
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '4px 0 14px',
}}
>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 10,
background: 'var(--ol-surface)',
}}
>
<Icon name="search" size={14} stroke="var(--ol-ink-3)" />
<input
type="search"
placeholder={t('marketplace.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
style={{
flex: 1,
outline: 'none',
border: 0,
background: 'transparent',
fontSize: 13,
color: 'var(--ol-ink-1)',
}}
/>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{sortPills.map(p => (
<button
key={p.id}
onClick={() => setSort(p.id)}
style={{
padding: '6px 10px',
fontSize: 12,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
cursor: 'pointer',
background: sort === p.id ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: sort === p.id ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
}}
>
{p.label}
</button>
))}
</div>
</div>
{actionMsg && (
<div
style={{
marginBottom: 12,
padding: '8px 12px',
borderRadius: 8,
fontSize: 12,
background: actionMsg.kind === 'ok' ? 'var(--ol-blue-soft)' : 'rgba(220,38,38,0.08)',
color: actionMsg.kind === 'ok' ? 'var(--ol-blue)' : 'var(--ol-err)',
border: '0.5px solid',
borderColor: actionMsg.kind === 'ok' ? 'var(--ol-blue)' : 'var(--ol-err)',
}}
>
{actionMsg.text}
</div>
)}
{loadError && (
<Card padding={16} style={{ marginBottom: 12, borderColor: 'var(--ol-err)' }}>
<div style={{ fontSize: 12, color: 'var(--ol-err)' }}>
{t('marketplace.loadFailed', { err: loadError })}
</div>
</Card>
)}
{/* 卡片列表 */}
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll">
{loading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
{t('common.loading')}
</div>
) : items.length === 0 ? (
<Card padding={28} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', marginBottom: 6 }}>
{t('marketplace.empty')}
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('marketplace.emptyHint')}
</div>
</Card>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}
>
{items.map(p => (
<button
key={p.id}
onClick={() => void openDetail(p.id)}
style={{
textAlign: 'left',
padding: 14,
borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<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>
</div>
<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 }}>
<Pill size="sm" tone="outline">{p.baseMode}</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,
}}
>
<span>by {p.authorLogin}</span>
<span>
❤ {p.likeCount} · ↓ {p.downloadCount}
</span>
</div>
</button>
))}
</div>
)}
</div>
{/* 详情弹窗 */}
{selectedId && (
<Modal onClose={() => setSelectedId(null)}>
{detailLoading || !detail ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
{t('common.loading')}
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 6 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 650 }}>{detail.name}</h2>
<Pill size="sm" tone="outline">{detail.baseMode}</Pill>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
v{detail.version}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginBottom: 12 }}>
by {detail.authorLogin} · ❤ {detail.likeCount} · ↓ {detail.downloadCount}
</div>
{detail.description && (
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6, marginBottom: 14 }}>
{detail.description}
</div>
)}
<div
style={{
padding: 12,
border: '0.5px solid var(--ol-line)',
borderRadius: 10,
background: 'var(--ol-surface-2)',
marginBottom: 14,
maxHeight: 280,
overflow: 'auto',
fontSize: 12,
fontFamily: 'var(--ol-font-mono)',
whiteSpace: 'pre-wrap',
color: 'var(--ol-ink-2)',
}}
>
{detail.prompt}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => void onLike()}>
❤ {t('marketplace.likeBtn')}
</Btn>
<Btn variant="ghost" size="sm" onClick={() => setSelectedId(null)}>
{t('common.cancel')}
</Btn>
<Btn variant="blue" size="sm" onClick={() => void onInstall()}>
{t('marketplace.installBtn')}
</Btn>
</div>
</>
)}
</Modal>
)}
{/* 上传选包器 */}
{showUpload && (
<Modal onClose={() => setShowUpload(false)}>
<h2 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 650 }}>{t('marketplace.uploadTitle')}</h2>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginBottom: 12 }}>
{t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 360, overflow: 'auto' }}>
{localPacks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', textAlign: 'center', padding: 20 }}>
{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>
))
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 14 }}>
<Btn variant="ghost" size="sm" onClick={() => setShowUpload(false)}>
{t('common.cancel')}
</Btn>
</div>
</Modal>
)}
</div>
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.22)',
display: 'grid',
placeItems: 'center',
zIndex: 50,
padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: 'min(560px, 100%)',
maxHeight: '85vh',
overflow: 'auto',
borderRadius: 16,
background: 'var(--ol-surface)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 18px 42px rgba(0,0,0,0.18)',
padding: 22,
}}
>
{children}
</div>
</div>
);
}
function errorMessage(error: unknown): string {
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
return String(error);
}
+32
View File
@@ -317,6 +317,10 @@ function RecordingSection() {
savePrefs({ ...prefs, startMinimized });
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
savePrefs({ ...prefs, autoUpdateCheck });
const onMarketplaceBaseUrlChange = (marketplaceBaseUrl: string) =>
savePrefs({ ...prefs, marketplaceBaseUrl });
const onMarketplaceDevLoginChange = (marketplaceDevLogin: string) =>
savePrefs({ ...prefs, marketplaceDevLogin });
const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) =>
savePrefs({ ...prefs, recordAudioForDebug });
// 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致
@@ -613,6 +617,34 @@ function RecordingSection() {
</div>
)}
</Collapsible>
{/* ─── 风格市场(折叠) ────────────────────────────────────────── */}
<Collapsible title={t('settings.recording.marketplaceGroupTitle')}>
<SettingRow
label={t('settings.recording.marketplaceBaseUrlLabel')}
desc={t('settings.recording.marketplaceBaseUrlDesc')}
>
<input
type="text"
placeholder="http://127.0.0.1:8090"
value={prefs.marketplaceBaseUrl}
onChange={e => onMarketplaceBaseUrlChange(e.target.value)}
style={{ ...inputStyle, width: 280 }}
/>
</SettingRow>
<SettingRow
label={t('settings.recording.marketplaceDevLoginLabel')}
desc={t('settings.recording.marketplaceDevLoginDesc')}
>
<input
type="text"
placeholder="your-github-login"
value={prefs.marketplaceDevLogin}
onChange={e => onMarketplaceDevLoginChange(e.target.value)}
style={{ ...inputStyle, width: 180 }}
/>
</SettingRow>
</Collapsible>
</>
);
}
+40
View File
@@ -11,7 +11,9 @@ import {
resetBuiltinStylePack,
saveStylePack,
setActiveStylePack,
uploadMarketplacePack,
} from '../lib/ipc';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnostics } from '../lib/types';
import { Btn, Card, PageHeader, Pill } from './_atoms';
import { Icon } from '../components/Icon';
@@ -118,6 +120,8 @@ function sanitizeZipFileName(name: string) {
export function Style() {
const { t, i18n } = useTranslation();
const isEnglish = i18n.language.toLowerCase().startsWith('en');
const { prefs: marketplacePrefs } = useHotkeySettings();
const canPublish = (marketplacePrefs?.marketplaceDevLogin ?? '').trim().length > 0;
const copy = {
kicker: 'STYLE PACKS',
title: isEnglish ? 'Style Packs' : '风格包',
@@ -128,6 +132,15 @@ export function Style() {
importZip: isEnglish ? 'Import ZIP' : '导入 ZIP',
exportZip: isEnglish ? 'Export ZIP' : '导出 ZIP',
exportShort: isEnglish ? 'Export' : '导出',
publishMarketplace: isEnglish ? 'Publish to Marketplace' : '发布到风格市场',
publishDisabledHint: isEnglish
? 'Configure your GitHub login in Settings → Marketplace first'
: '请先在 设置 → 风格市场 配置 GitHub 用户名',
publishSuccess: isEnglish
? 'Published — pending review on marketplace'
: '发布成功,等待 marketplace 审核',
publishFailed: (msg: string) =>
isEnglish ? `Publish failed: ${msg}` : `发布失败:${msg}`,
builtin: isEnglish ? 'Built-in' : '内置',
imported: isEnglish ? 'Imported' : '导入',
active: isEnglish ? 'Active' : '当前',
@@ -560,6 +573,23 @@ export function Style() {
}
};
const handlePublishToMarketplace = async (pack = selectedPack) => {
if (!pack) return;
if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) {
showSaveStatus('failed', copy.exportDirtyFirst);
return;
}
setBusy('exporting');
try {
await uploadMarketplacePack(pack.id);
showSaveStatus('saved', copy.publishSuccess, true);
} catch (publishError) {
showSaveStatus('failed', copy.publishFailed(String(publishError)));
} finally {
setBusy(null);
}
};
const handleExportZip = async (pack = selectedPack) => {
if (!pack) return;
if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) {
@@ -909,6 +939,16 @@ export function Style() {
<Btn variant="ghost" icon="archive" onClick={() => void handleExportZip()} disabled={busy === 'exporting'}>
{copy.exportZip}
</Btn>
<span title={canPublish ? '' : copy.publishDisabledHint}>
<Btn
variant="ghost"
icon="cloud"
onClick={() => void handlePublishToMarketplace()}
disabled={!canPublish || busy === 'exporting'}
>
{copy.publishMarketplace}
</Btn>
</span>
<Btn
variant={draft.active ? 'soft' : 'blue'}
icon="check"
@@ -9,6 +9,7 @@ export type AppTab =
| 'style'
| 'translation'
| 'selectionAsk'
| 'marketplace'
| 'localAsr';
export interface AppState {