diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index da1f70b2..ca5cc2cb 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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.)。 +// 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, + 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, + sort: Option, + limit: Option, +) -> Result, 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 = + 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 { + 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::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +#[tauri::command] +pub async fn marketplace_install( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + // 安全校验: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 { + // 本地 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::(&body) + .map_err(|e| format!("parse upload response failed: {e}")) +} + +#[tauri::command] +pub async fn marketplace_like( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + 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::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + #[cfg(test)] mod tests { use super::{ diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 82a3f54a..d5eeac8c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 75b38ad5..632882f7 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -622,6 +622,14 @@ pub struct UserPreferences { /// 这种「文本档案多 + 录音不占盘」组合下精确控制。 #[serde(default)] pub audio_recording_max_entries: Option, + /// 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, + #[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(), } } } diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 93dbfed2..1dcc4b89 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -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 中 // 通过 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。 import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; @@ -44,6 +45,7 @@ const NAV_BASE: Array> = [ { 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 }, ]; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 0f427217..65e915b2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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..', + 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}}', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 3347027f..bd7b5b08 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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}}', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 8cf2d1a1..19a54b51 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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}}', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 70c940fb..cd95e0ef 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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}}', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 72a170fb..f86426fa 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -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}}', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index f13c7a82..93052442 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -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 { + return invokeOrMock('marketplace_list', options, () => MOCK_MARKETPLACE); +} + +export function fetchMarketplaceDetail(packId: string): Promise { + return invokeOrMock('marketplace_detail', { packId }, () => ({ + ...MOCK_MARKETPLACE[0], + prompt: '# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。', + state: 'approved' as const, + })); +} + +export function installMarketplacePack(packId: string): Promise { + 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, + })); +} diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 5981d780..a028c348 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -65,6 +65,8 @@ const previousPrefs: UserPreferences = { historyMaxEntries: null, recordAudioForDebug: false, audioRecordingMaxEntries: null, + marketplaceBaseUrl: '', + marketplaceDevLogin: '', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d1c1bd5c..1b044898 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -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.。 */ + 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 { diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx new file mode 100644 index 00000000..04f15cca --- /dev/null +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [sort, setSort] = useState('popular'); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(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([]); + 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>( + () => [ + { id: 'popular', label: t('marketplace.sortPopular') }, + { id: 'new', label: t('marketplace.sortNew') }, + ], + [t], + ); + + return ( +
+ + void refresh()}> + {t('common.refresh')} + + + void openUploadPicker()} + disabled={!canUpload} + > + {t('marketplace.uploadBtn')} + + +
+ } + /> + + {/* 顶部搜索 + 排序 */} +
+
+ + setQuery(e.target.value)} + style={{ + flex: 1, + outline: 'none', + border: 0, + background: 'transparent', + fontSize: 13, + color: 'var(--ol-ink-1)', + }} + /> +
+
+ {sortPills.map(p => ( + + ))} +
+
+ + {actionMsg && ( +
+ {actionMsg.text} +
+ )} + + {loadError && ( + +
+ {t('marketplace.loadFailed', { err: loadError })} +
+
+ )} + + {/* 卡片列表 */} +
+ {loading ? ( +
+ {t('common.loading')} +
+ ) : items.length === 0 ? ( + +
+ {t('marketplace.empty')} +
+
+ {t('marketplace.emptyHint')} +
+
+ ) : ( +
+ {items.map(p => ( + + ))} +
+ )} +
+ + {/* 详情弹窗 */} + {selectedId && ( + setSelectedId(null)}> + {detailLoading || !detail ? ( +
+ {t('common.loading')} +
+ ) : ( + <> +
+

{detail.name}

+ {detail.baseMode} + + v{detail.version} + +
+
+ by {detail.authorLogin} · ❤ {detail.likeCount} · ↓ {detail.downloadCount} +
+ {detail.description && ( +
+ {detail.description} +
+ )} +
+ {detail.prompt} +
+
+ void onLike()}> + ❤ {t('marketplace.likeBtn')} + + setSelectedId(null)}> + {t('common.cancel')} + + void onInstall()}> + {t('marketplace.installBtn')} + +
+ + )} +
+ )} + + {/* 上传选包器 */} + {showUpload && ( + setShowUpload(false)}> +

{t('marketplace.uploadTitle')}

+
+ {t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })} +
+
+ {localPacks.length === 0 ? ( +
+ {t('marketplace.uploadNoLocal')} +
+ ) : ( + localPacks.map(p => ( + + )) + )} +
+
+ setShowUpload(false)}> + {t('common.cancel')} + +
+
+ )} + + ); +} + +function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + return ( +
+
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} +
+
+ ); +} + +function errorMessage(error: unknown): string { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index d3a077f4..725ea492 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -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() { )} + + {/* ─── 风格市场(折叠) ────────────────────────────────────────── */} + + + onMarketplaceBaseUrlChange(e.target.value)} + style={{ ...inputStyle, width: 280 }} + /> + + + onMarketplaceDevLoginChange(e.target.value)} + style={{ ...inputStyle, width: 180 }} + /> + + ); } diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 78dff645..d71c433f 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -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() { void handleExportZip()} disabled={busy === 'exporting'}> {copy.exportZip} + + void handlePublishToMarketplace()} + disabled={!canPublish || busy === 'exporting'} + > + {copy.publishMarketplace} + +