Skip to content

Commit 0aa3ee6

Browse files
authored
Merge pull request #435 from Open-Less/feat/style-pack-ui-refresh
feat(style-pack): rework UI + 模板新建 + 清理 PR #429 遗留死代码
2 parents eca6fae + e0a48ce commit 0aa3ee6

18 files changed

Lines changed: 698 additions & 269 deletions

File tree

docs/style-pack-marketplace.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Style Pack Marketplace — 规划文档
2+
3+
**状态**:规划中(API 已预留 stub,未实装)
4+
**起草日期**:2026-05-14
5+
**owner**:待定
6+
7+
## 1. 目标
8+
9+
把现在「ZIP 包本地导入 / 导出」的体验扩展成一个公开的风格包市场:
10+
11+
- 用户可以把自己调好的风格包**上传**到云端,附带名称、描述、作者署名、标签、效果示例
12+
- 其他用户可以**浏览 / 搜索 / 下载**别人的风格包,一键安装到本地
13+
- 后期支持**版本升级提醒****收藏 / 评分**等基础社交属性
14+
15+
非目标(v1 不做):
16+
- 付费 / 抽成
17+
- 风格包内嵌外部 prompt 注入 / 跨域 fetch(安全考虑,风格包始终是纯文本 prompt)
18+
- 多人协作编辑 / fork
19+
20+
## 2. 架构概览
21+
22+
```
23+
┌──────────────────┐ HTTPS ┌─────────────────────┐
24+
│ OpenLess client │ ◄──────────────────► │ marketplace API │
25+
│ (Tauri 2) │ JSON over TLS │ (TBD: Cloudflare │
26+
│ │ │ Workers / D1 / │
27+
│ Rust IPC → │ │ R2 for blobs) │
28+
│ reqwest client │ │ │
29+
└──────────────────┘ └─────────────────────┘
30+
│ │
31+
│ local cache (~/Library/Application │
32+
│ Support/OpenLess/market_cache/) │
33+
▼ ▼
34+
StylePackStore Postgres / D1
35+
(existing local listings + R2 blobs
36+
persistence layer)
37+
```
38+
39+
**关键约束**
40+
- 客户端只能上传 / 下载 ZIP **bundle**(不直接传 JSON),保持跟现有 ZIP import/export 同构
41+
- 服务端 ZIP 验证:解压后必须能反序列化成 `StylePack``prompt.chars().count() <= 50_000`、没有可执行附件
42+
- 风格包 ID 上传后由服务端分配(`{author_slug}-{name_slug}-{version}`),跟本地 ID 解耦
43+
- 客户端始终拿 ZIP 走现有 `import_style_pack_from_zip` 路径入库 —— 不另开一条「从市场直接写 Pack」的代码路径,避免双入口
44+
45+
## 3. HTTP API 规约
46+
47+
Base URL(待定):`https://api.openless.app/v1/marketplace/`
48+
49+
所有响应统一信封:
50+
```json
51+
{
52+
"ok": true,
53+
"data": <T> | null,
54+
"error": null | { "code": "ERR_XXX", "message": "..." }
55+
}
56+
```
57+
58+
### 3.1 GET `/packs` — 列表 / 搜索
59+
60+
Query:
61+
| 参数 | 类型 | 默认 | 说明 |
62+
|---|---|---|---|
63+
| `q` | string | `""` | 关键词(名称 / 描述 / 标签) |
64+
| `tag` | string | `""` | 单标签筛选 |
65+
| `sort` | `recent` \| `popular` \| `name` | `recent` | 排序 |
66+
| `cursor` | string | `null` | 分页游标 |
67+
| `limit` | int (1-100) | `20` | 每页条数 |
68+
69+
Response data:
70+
```typescript
71+
{
72+
packs: MarketPackListing[];
73+
next_cursor: string | null;
74+
}
75+
```
76+
77+
`MarketPackListing`
78+
```typescript
79+
{
80+
id: string; // server-assigned, e.g. "alice-formal-v2.1"
81+
name: string;
82+
description: string;
83+
author: string;
84+
version: string; // semver
85+
tags: string[];
86+
base_mode: "raw" | "light" | "structured" | "professional";
87+
recommended_model: string | null;
88+
compatible_app_version: string | null;
89+
downloads: number;
90+
rating_avg: number | null;
91+
rating_count: number;
92+
updated_at: string; // ISO8601
93+
zip_size_bytes: number;
94+
zip_sha256: string; // 客户端下载后校验
95+
}
96+
```
97+
98+
### 3.2 GET `/packs/{id}` — 详情
99+
100+
Response data:`MarketPackListing` + 额外字段:
101+
```typescript
102+
{
103+
...listing,
104+
examples: StylePackExample[]; // 解压 ZIP 前的预览
105+
changelog: string | null;
106+
homepage_url: string | null;
107+
}
108+
```
109+
110+
### 3.3 GET `/packs/{id}/download` — 下载 ZIP
111+
112+
Response:`application/zip` 二进制流,带 `X-Pack-SHA256` header 用于校验。
113+
114+
服务端通过 redirect 直接指向 R2 / S3 预签 URL,避免代理流量。
115+
116+
### 3.4 POST `/packs` — 上传(需鉴权)
117+
118+
Headers:`Authorization: Bearer <api_key>`
119+
Body:`multipart/form-data` with field `pack=@xxx.zip`
120+
121+
Response data:`MarketPackListing`(含新分配 id)
122+
123+
错误码:
124+
- `ERR_INVALID_ZIP` — ZIP 解压失败 / 不是合法 StylePack JSON
125+
- `ERR_PROMPT_TOO_LARGE` — prompt 字数超 50k
126+
- `ERR_DUPLICATE_VERSION` — 同 author+name+version 已存在
127+
- `ERR_RATE_LIMITED` — 触发限频
128+
129+
### 3.5 DELETE `/packs/{id}` — 撤回(需鉴权 + 必须是上传者)
130+
131+
### 3.6 POST `/packs/{id}/rate` — 评分(需鉴权)
132+
133+
Body:`{ score: 1..5, comment?: string }`
134+
135+
## 4. IPC 契约(Rust ↔ TS)
136+
137+
`src-tauri/src/commands.rs` 新增以下 stub(暂返回 `Err("not implemented yet")`,等服务端落地后实装):
138+
139+
```rust
140+
// 列表 / 搜索
141+
#[tauri::command]
142+
pub async fn market_list_packs(
143+
query: Option<String>,
144+
tag: Option<String>,
145+
sort: Option<String>,
146+
cursor: Option<String>,
147+
limit: Option<u32>,
148+
) -> Result<MarketListResponse, String>;
149+
150+
// 详情
151+
#[tauri::command]
152+
pub async fn market_get_pack(id: String) -> Result<MarketPackDetail, String>;
153+
154+
// 下载 + 自动调用现有的 import_style_pack_from_zip 入库
155+
#[tauri::command]
156+
pub async fn market_download_pack(
157+
coord: CoordinatorState<'_>,
158+
app: AppHandle,
159+
id: String,
160+
) -> Result<StylePack, String>;
161+
162+
// 上传(dirty 字段 = 已编辑、未保存)
163+
#[tauri::command]
164+
pub async fn market_upload_pack(
165+
coord: CoordinatorState<'_>,
166+
pack_id: String,
167+
api_key: String,
168+
) -> Result<MarketPackListing, String>;
169+
170+
// 撤回
171+
#[tauri::command]
172+
pub async fn market_delete_pack(id: String, api_key: String) -> Result<(), String>;
173+
174+
// 评分
175+
#[tauri::command]
176+
pub async fn market_rate_pack(
177+
id: String,
178+
api_key: String,
179+
score: u8,
180+
comment: Option<String>,
181+
) -> Result<(), String>;
182+
```
183+
184+
DTO(在 `types.rs` 新增):
185+
```rust
186+
#[derive(Debug, Serialize, Deserialize, Clone)]
187+
pub struct MarketPackListing {
188+
pub id: String,
189+
pub name: String,
190+
pub description: String,
191+
pub author: String,
192+
pub version: String,
193+
pub tags: Vec<String>,
194+
pub base_mode: PolishMode,
195+
pub recommended_model: Option<String>,
196+
pub compatible_app_version: Option<String>,
197+
pub downloads: u64,
198+
pub rating_avg: Option<f32>,
199+
pub rating_count: u32,
200+
pub updated_at: String,
201+
pub zip_size_bytes: u64,
202+
pub zip_sha256: String,
203+
}
204+
205+
#[derive(Debug, Serialize, Deserialize, Clone)]
206+
pub struct MarketPackDetail {
207+
#[serde(flatten)]
208+
pub listing: MarketPackListing,
209+
pub examples: Vec<StylePackExample>,
210+
pub changelog: Option<String>,
211+
pub homepage_url: Option<String>,
212+
}
213+
214+
#[derive(Debug, Serialize, Deserialize, Clone)]
215+
pub struct MarketListResponse {
216+
pub packs: Vec<MarketPackListing>,
217+
pub next_cursor: Option<String>,
218+
}
219+
```
220+
221+
TS wrappers(`src/lib/ipc.ts`):
222+
```typescript
223+
export interface MarketPackListing { /* same shape */ }
224+
export interface MarketPackDetail extends MarketPackListing { /* + examples, changelog, homepage_url */ }
225+
export interface MarketListResponse { packs: MarketPackListing[]; next_cursor: string | null; }
226+
227+
export function marketListPacks(opts: {
228+
query?: string; tag?: string; sort?: 'recent' | 'popular' | 'name';
229+
cursor?: string; limit?: number;
230+
}): Promise<MarketListResponse>;
231+
export function marketGetPack(id: string): Promise<MarketPackDetail>;
232+
export function marketDownloadPack(id: string): Promise<StylePack>;
233+
export function marketUploadPack(packId: string, apiKey: string): Promise<MarketPackListing>;
234+
export function marketDeletePack(id: string, apiKey: string): Promise<void>;
235+
export function marketRatePack(id: string, apiKey: string, score: number, comment?: string): Promise<void>;
236+
```
237+
238+
## 5. 鉴权模型
239+
240+
**v1 简化方案**
241+
- 用户在设置页输入个人 API key(服务端发放)
242+
- API key 存到 OS Keychain,账户名 `com.openless.app.market_api_key`
243+
- 客户端在 Header 加 `Authorization: Bearer <key>`
244+
- 服务端校验 + 限频(每小时 60 次写、600 次读)
245+
246+
**v2 升级路径**(暂不做):
247+
- OAuth via GitHub / Google
248+
- 上传时自动签名 ZIP,下载端校验签名
249+
250+
## 6. 缓存与版本检查
251+
252+
本地缓存目录:`<app_data>/market_cache/`
253+
- `listings.json` — 上次拉的 listings(带 ETag)
254+
- `packs/{id}.zip` — 已下载的 ZIP(按需保留,30 天自动清理)
255+
256+
版本升级提示:
257+
- 启动时(带 dev-cap 24h 节流)调用 `/packs?ids=<已安装的 market_id...>` 拉对比
258+
- 本地包记录 `installed_market_id``installed_market_version` 字段,新建 `StylePack` 时填,本地从 ZIP 安装也填
259+
- 发现新版本 → 在 Style 页该包卡片角标显示 `New version: 2.3.0 →`
260+
261+
## 7. 客户端 UI 入口(v1 不做,先留位)
262+
263+
- Style 页头部加一个 tab:`本地 / 市场`
264+
- 市场页:搜索栏 + tag 过滤 + 卡片列表 + 详情抽屉
265+
- 上传:编辑某个本地包时,"导出 ZIP" 按钮旁边出现 "上传到市场"(需要先在设置里填 API key)
266+
267+
## 8. 安全 / 滥用对策
268+
269+
- ZIP 解压走 streaming,限制最大解压后大小 5 MB
270+
- prompt 字段过滤明显的 prompt injection / 越狱(关键词预扫描 + 异步内容审核)
271+
- 每用户每天上传上限 10 包,单包大小 ≤ 2 MB
272+
- 上传后挂 24h 公开延迟(防恶意刷榜)
273+
274+
## 9. 实装 TODO(按优先级)
275+
276+
- [ ] 服务端选型(CF Workers + D1 + R2 vs Supabase vs 自托管 FastAPI)
277+
- [ ] 服务端实装 + 部署环境(dev / staging / prod)
278+
- [ ] 客户端 `types.rs` 加 DTO
279+
- [ ] `commands.rs` 加 6 个 stub(**已完成**,返回 `not implemented yet`
280+
- [ ] `lib/ipc.ts` 加 wrapper(**已完成**
281+
- [ ] 实装 `market_download_pack`(先做单条路径打通:URL → 下载 → 走现有 import_style_pack_from_zip)
282+
- [ ] 加凭据存储(Keychain 复用现有 `CredentialsVault`
283+
- [ ] UI:本地 / 市场 tab
284+
- [ ] UI:搜索 + 卡片
285+
- [ ] UI:详情面板
286+
- [ ] UI:上传流程
287+
- [ ] 升级提醒 badge
288+
- [ ] 缓存清理 + ETag
289+
290+
## 10. 决策 / 风险记录
291+
292+
|| 决策 | Why |
293+
|---|---|---|
294+
| ZIP 而非 JSON 上传 | 用 ZIP | 跟现有 import/export 同构;prompt 长文 + examples 用 ZIP 包压缩 |
295+
| 服务端分配 ID || 防本地 ID 碰撞、用户重命名包不影响订阅 |
296+
| 上传立刻可见 vs 审核 | 24h 公开延迟 | 防刷榜 + 给审核留空间 |
297+
| API key vs OAuth | 先 API key | 简化 v1;登录态可 v2 升级 |
298+
| 客户端缓存策略 | listings ETag + 已下载 ZIP 30 天 | 平衡流量和体验 |
299+
| 国际化 / 跨境 | API 全英文 + 客户端 i18n | 服务端不存翻译,名称/描述支持任意 UTF-8 |

openless-all/app/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openless-all/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "openless-app",
33
"private": true,
4-
"version": "1.3.1",
4+
"version": "1.3.2-1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

openless-all/app/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openless-all/app/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "openless"
3-
version = "1.3.1"
3+
version = "1.3.2-1"
44
description = "OpenLess — local voice input that types where your cursor is"
55
authors = ["OpenLess"]
66
edition = "2021"

openless-all/app/src-tauri/src/commands.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,26 @@ pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result<Vec<StylePack>, S
12561256
.map_err(|e| e.to_string())
12571257
}
12581258

1259+
#[tauri::command]
1260+
pub fn create_style_pack_from_template(
1261+
coord: CoordinatorState<'_>,
1262+
app: AppHandle,
1263+
template: StylePack,
1264+
) -> Result<StylePack, String> {
1265+
log::info!(
1266+
"[style-pack] command create_from_template name={} base_mode={:?}",
1267+
template.name,
1268+
template.base_mode
1269+
);
1270+
let created = coord
1271+
.style_packs()
1272+
.create_from_template(template)
1273+
.map_err(|e| e.to_string())?;
1274+
let prefs = coord.prefs().get();
1275+
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
1276+
Ok(created)
1277+
}
1278+
12591279
#[tauri::command]
12601280
pub fn save_style_pack(
12611281
coord: CoordinatorState<'_>,

openless-all/app/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ pub fn run() {
301301
commands::inject_hotkey_click_for_dev,
302302
commands::repolish,
303303
commands::list_style_packs,
304+
commands::create_style_pack_from_template,
304305
commands::save_style_pack,
305306
commands::preview_style_pack_runtime,
306307
commands::set_active_style_pack,

0 commit comments

Comments
 (0)