Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
| ON-4 网络错误文案走查 + i18n pass:三轴并行审读(错误/一致性/僵尸);45 键修订(错误归因与下一步动作、术语统一工作室/提示词/服务商/图片、省略号统一);删 canvasNodeType* 6 僵尸键 | #197 |
| GAP-7 Inspector 测试欠账收口(预设点选应用+成本文案精确断言)+ ON-5 五屏空态 golden(Studio empty/error、Canvas empty、Gallery empty、Settings;ubuntu 铸线)——第 10 条(ON-3/4/GAP-7/ON-5)收官;**余 GAP-1 整卡、GAP-3 余量未清**(评审 P1 纠偏:勿宣「全部收官」) | #198 |
| GAP-3 余量收口(24 站点审计:方向读错并入横幅链;raw toString 上屏收敛——设置页探测诊断行为有意例外;base style 编辑器读错中止防空覆盖(评审 P2-3);InkErrorBanner.onRetry 死参数删除;**InkAsyncSlot 判 YAGNI**——列表槽位 LB-06 已全收口) | #199 |
| **GAP-1 设置页 Custom Provider 编辑 UI(上线前必做最后一卡)**:CustomProviderStore 写侧(raw 保真+损坏拒写+原子写);校验抽 core 纯函数双端共用;列表+表单+删除确认+重启生效常驻条;API Keys custom:* 行 displayName 顺带修——**随本卡合入,上线前必做 1-10 全部落地** | #200 |

## M1 补遗(审计发现的悬空项)

Expand Down Expand Up @@ -153,6 +154,7 @@
| 三大重操作互斥只在导入侧单向查(LB-12 拍板 9):还原/导出入口不查 projectImportBusyProvider——导入进行中仍可点还原 | 🅿️ | 反向补查三行;或统一 heavyOperationBusyProvider 归一三个 busy 位 |
| 导入补偿删除失败→projects/{uuid} 孤儿目录无回收路径(#192 评审 P3-2:无 .import- 前缀 sweep 不认,reaper 又 DRY-RUN);另记拍板 4 三处字面偏差(U+FFFD 奇名可过/最终路径长未预检/isWithin 代 resolveInProject)均安全失败 | 🅿️ | 随 LB-13 reaper 转真删同窗:无行背书目录纳入回收;字面偏差随安全面复审顺修 |
| **迁移纪律备忘(#192 评审 P3-6)**:导入的列白名单过滤依赖「迁移只加可空/有默认列」——将来任何「新增 NOT NULL 无默认」迁移会让旧项目包导入必炸 | 🅿️ | ADR-0012 补一句:新增列必须可空或带默认,否则同时给导入侧加填充逻辑 |
| GAP-1 评审 P3 残留(#200):①unknownTemplate 在 UI 错误文案映射到 InvalidId 键(当前不可达——模板恒下拉;改自由输入即活雷,补专用键或注释);②_openEditor 读失败报「保存失败」文案微错位;③写无顺序化(模态门控下重合概率趋零,硬化=_queue.then 串行链);④_parseEntry seenIds 在 template/url 校验前占坑,被拒条目致后续同 id 合法条目误判 duplicate(既有债非本卡引入);⑤section 内 provider 定义应迁 features/settings/providers/(风格) | 🅿️ | 均低害;①随模板扩展窗强制处理 |
| GAP-3 评审 P3 残留(#199):①方向读错期 lane_toolbar 置灰未做(二元域无损毁,但 toggle 到不了 horizontal 的怪异 UX);②横幅三源 `??` 链+单 `_dismissed` 遮蔽——关掉 edges 错后并发 lanes/direction 错不上屏(改集合);③非 InkError→errorUnknown 后无任何日志线索(此前 raw toString 至少可报障),建议 error 分支补 log 或 ProviderObserver.providerDidFail | 🅿️ | ①②低害 UX;③可观测性,随日志面收口 |
| ON-5 评审 P3 残留(#198):①golden sentinel 单点——删 studio_empty.png 五测静默 skip 而 node_card 仍 ran>0 骗过整 job 守卫;评审提的 `skipped>0&&baselines>0→fail` 会误伤增量铸线 bootstrap(本 PR 自身流程即反例),需更细粒度方案;②成本断言 0.01×1 测不出漏乘 batch,补 maxBatchSize>1→\$0.02 用例;③ci.yml 与 update-goldens.yml 双 pin FLUTTER_VERSION 升级必须同步+重铸 | 🅿️ | ①设计再议 ②一测的事 ③升级 checklist 项 |
| ON-3 评审 P3 残留(#196):卸载 ffmpeg 后设置页旧 Available 滞留(hit 缓存 app 级,设置页不调 invalidate;导出失败路径会自愈);PATH 命中显示裸 `ffmpeg` 当路径 | 🅿️ | 低害:重启/导出失败自愈;若做刷新按钮同窗顺修 |
Expand Down
9 changes: 7 additions & 2 deletions docs/MASTERPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ W1(LB-03/04/06/17)→ W2(LB-07/09/13/14/16)→ W3(LB-10/11/15/18)→ W4(LB-12
6. **GAP-3 AsyncValue error 态统一**(M,新共享件 InkAsyncSlot;清单含复审补充的
library_sidebar:45 与 canvas_view 裸 toString 站点;LB-06/#166 已合,余量归本卡)+ **GAP-4 slot error 文案**(S)
✅ **#167 已合**(Tooltip + danger 文案)。
7. **GAP-1 设置页 Custom Provider 编辑 UI**(L,守住"重启生效"边界)。
7. **GAP-1 设置页 Custom Provider 编辑 UI**(L)✅ **#200 已合**(Store 写侧 raw
保真+损坏拒写+原子写;校验纯函数双端共用;列表+表单+删除确认+重启生效常驻条;
API Keys custom:* displayName 顺带修——"重启生效"边界守住,不碰 registry 变异)。
8. **GAP-2 软删项目回收站 UI**(M)✅ **#190 已合**(≡LB-15:sidebar 真回收站入口取代 ARCHIVE 死行、
管理画布已删区;首版无永久删除)。
9. **PL-6 窗口状态记忆**(S–M)✅ **#170 已合**(退出捕获+启动恢复+多显示器 clamp)。
Expand All @@ -185,7 +187,10 @@ W1(LB-03/04/06/17)→ W2(LB-07/09/13/14/16)→ W3(LB-10/11/15/18)→ W4(LB-12
**GAP-3 余量**✅ **#199 已合**(24 站点审计:泳道方向读错并入横幅链;raw toString
上屏收敛,探测诊断行有意例外;base style 读错中止防空覆盖;InkAsyncSlot 判
YAGNI——第 6 条关闭)。
**清单余量(未收官)**:仅第 7 条 GAP-1 设置页 Custom Provider 编辑 UI(L,整卡未做)。
**第 7 条 GAP-1**✅ #200——**上线前必做 1-10 全部落地**(2026-07-18 逐条核对:
1 CV-1/#174,2 ON-1+2/#175+#195,3 GAP-8/#164,4 PL-4a/#168,5 PL-2+PL-1/#176+#174,
6 GAP-3/#166+#199,7 GAP-1/#200,8 GAP-4/#167,9 PL-6/#170,10 ON-3/4/GAP-7/ON-5
/#196+#197+#198)。

**上线后首迭代起**:PL-4b undo 栈、CV-4 左工具栏实装/CV-5 视口 chrome(zoom 指示+fit,minimap 后置)、
CV-2/CV-3(节点色条/Inspector 浮动,拍板后随时)、PL-3 右键菜单(建议尽早)、PL-5 框选群拖、
Expand Down
65 changes: 65 additions & 0 deletions docs/superpowers/plans/2026-07-18-gap-1-custom-providers-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# GAP-1 设置页 Custom Provider 编辑 UI Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 设置页可视化增删改 custom_providers.json(列表+5 字段表单+删除确认+内联校验),守住「重启生效」边界;顺带 API Keys 行对 `custom:*` 显示 displayName。

**Architecture:** 读写分离——`CustomProviderSource`(既有,registry 消费,会话内不变)不动;新增 `CustomProviderStore` 接口(fresh list/upsert/remove),由 CustomProvidersFileService 一并实现。校验抽成 core 纯函数(服务解析与 UI 内联复用同一事实源)。写回走 raw 条目保真 + `.partial`→rename 原子盘。

**Tech Stack:** 既有 kProviderProtocolTemplates 白名单、AppPaths.config、InkCard/InkButton 组件、ARB。

## Global Constraints

- **不碰 registry 变异深水区**:会话内 configs 不变;所有写操作仅落盘,UI 常驻「重启后生效」提示。
- **写回保真(本卡最大风险面,拍板③)**:写路径重读 raw 数组,按 id 匹配替换/删除/追加;**读侧剔除的非法/未知 raw 条目原位保留**——编辑一条不销毁用户手编的坏条目。
- **损坏文件写保护(拍板④)**:文件损坏(不可解析/顶层非数组)时写操作**拒绝执行**抛 LocalIOError(绝不覆盖用户可能想抢救的文件),UI 呈现错误。
- 原子写:`.partial`→rename;2 空格缩进 pretty JSON(保持可手编)。
- i18n:新 ARB 键 en/zh 全覆盖 + gen-l10n 同 commit。
- 校验=PROVIDER-API §13.1 全集:id 正则/唯一/不撞内置;template 白名单下拉;base_url 绝对 http(s) 无 query/fragment/userinfo;5 字段非空。

---

### Task 1: 校验纯函数抽取 + CustomProviderStore 写侧(TDD)

**Files:**
- Create: `lib/core/models/custom_provider_validation.dart`(纯函数,无 IO/Flutter)
- Create: `lib/core/interfaces/custom_provider_store.dart`
- Modify: `lib/services/custom_providers_file_service.dart`(_parseEntry 改用共享校验;实现 Store)
- Modify: `lib/core/di/providers.dart`(customProviderStoreProvider)
- Test: `test/core/models/custom_provider_validation_test.dart`、`test/services/custom_providers_file_service_test.dart`(扩写侧)

**Interfaces:**
- Produces: `CustomProviderFieldError` enum(emptyField/invalidId/duplicateId/reservedId/unknownTemplate/invalidBaseUrl);`CustomProviderFieldError? validateId(String, {required Set<String> takenIds, required Set<String> reservedProviderIds})` / `validateBaseUrl(String)` / `validateTemplate(String)` / `validateRequired(String)`;`abstract class CustomProviderStore { Future<List<CustomProviderConfig>> list(); Future<void> upsert(CustomProviderConfig c); Future<void> remove(String id); }`
- upsert 语义:raw 数组中首个 `id` 字段==目标者原位替换,无匹配则追加;remove 同匹配删除;其余 raw 条目逐字保留。

- [ ] 红测:校验纯函数全分支;写侧=upsert 新增/替换、remove、**保真**(含一条非法 raw 条目的文件写后该条仍在)、损坏文件拒写抛 LocalIOError、原子性(.partial 不残留)、写后 fresh list 可读回。
- [ ] 实现;`flutter analyze` + 目标测试绿;commit `feat(providers): GAP-1 校验纯函数 + custom provider 写侧`

### Task 2: 设置页 CustomProvidersSection + ARB(TDD)

**Files:**
- Create: `lib/features/settings/widgets/custom_providers_section.dart`
- Modify: `lib/features/settings/settings_screen.dart`(挂载,置 API Keys 之后)
- Modify: ARB en/zh(settingsCustomProviders* ~18 键)+ generated/
- Test: `test/features/settings/custom_providers_section_test.dart`(fake Store)

**Interfaces:**
- Consumes: customProviderStoreProvider;`customProvidersListProvider = FutureProvider.autoDispose`(store.list() fresh 读,写后 invalidate)。
- 结构:标题+hint;条目行(displayName + `custom:<id>` + template 徽标 + 编辑/删除 icon);空态文案;「Add provider」按钮;表单对话框(id 仅新增可编/编辑锁定,display_name,template 下拉,base_url,model_id;内联错误经校验纯函数);删除确认对话框;会话内发生过写操作后常驻 warning 条「重启后生效」。

- [ ] 红测:列表渲染/添加流(含 id 冲突与 base_url 带 query 内联报错不提交)/编辑流(id 锁定)/删除确认/写后 restart 条出现/store 抛 LocalIOError(损坏文件)→ 错误呈现不崩。
- [ ] 实现 + gen-l10n;全量闸门;commit `feat(settings): GAP-1 Custom Providers 编辑区`

### Task 3: API Keys 行 displayName 顺带修 + docs + PR

**Files:**
- Modify: `lib/features/settings/widgets/api_keys_section.dart`(`custom:*` 行经 providerCapabilitiesListProvider 取 displayName)
- Test: 既有 api_keys_section_test 扩一例
- Modify: docs/BOARD.md、docs/MASTERPLAN.md(GAP-1 登记,上线前清单收官宣称**须按 #198 教训如实核对**)

- [ ] displayName 测试红→绿;全量闸门;PR → 对抗评审(关注:写回保真/损坏拒写/校验双源一致/重启边界泄漏)→ 修 P1/P2 → CI 一次性核验 → squash merge。

## Self-Review

- 卡面验收逐条对齐:不碰 json 完成增删改 ✓(Task 1/2);非法输入内联报错 ✓;损坏 json 不崩 ✓(拒写+呈现);重启后下拉出现=既有 bootstrap 链天然满足 ✓;fake Source/Store widget 测试 ✓;顺带修 displayName ✓(Task 3)。
- 类型一致:CustomProviderFieldError 名称在 Task 1/2 一致;Store 三方法签名一致 ✓。
10 changes: 10 additions & 0 deletions lib/core/di/custom_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../services/custom_providers_file_service.dart';
import '../interfaces/custom_provider_source.dart';
import '../interfaces/custom_provider_store.dart';

final customProviderSourceProvider = Provider<CustomProviderSource>(
(ref) => const EmptyCustomProviderSource(),
name: 'customProviderSourceProvider',
);

/// 编辑侧(GAP-1):写仅落盘、重启生效。main 用同一 CustomProvidersFileService
/// 实例覆盖;默认抛——设置页测试必须显式注入 fake。
final customProviderStoreProvider = Provider<CustomProviderStore>(
(ref) => throw UnimplementedError(
'customProviderStoreProvider must be overridden in main()',
),
name: 'customProviderStoreProvider',
);
21 changes: 21 additions & 0 deletions lib/core/interfaces/custom_provider_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// CustomProviderStore:custom_providers.json 的编辑侧契约(GAP-1)。
//
// 与只读的 CustomProviderSource 分离——registry 消费的会话内快照不变,
// 本接口的写操作仅落盘,重启后生效(不做运行时 registry 变异)。
//
// 保真约束:写路径按 raw 条目操作,读侧剔除的非法/未知条目原位保留——
// 编辑一条绝不销毁用户手编的其他条目。文件损坏(不可解析/顶层非数组)时
// 写操作拒绝执行抛 LocalIOError,绝不覆盖用户可能想抢救的文件。

import '../models/custom_provider_config.dart';

abstract class CustomProviderStore {
/// 全新读盘的合法条目列表(不影响、也不使用会话内快照)。
Future<List<CustomProviderConfig>> list();

/// raw 数组中首个 `id` 字段==目标者原位替换;无匹配则追加到末尾。
Future<void> upsert(CustomProviderConfig config);

/// 按 `id` 字段匹配删除;无匹配为 no-op。
Future<void> remove(String id);
}
78 changes: 78 additions & 0 deletions lib/core/models/custom_provider_validation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// custom provider 字段校验纯函数(PROVIDER-API §13.1)——服务解析与设置页
// 内联校验共用同一事实源,规则改动只此一处。无 IO、无 Flutter 依赖。

import 'custom_provider_config.dart';
import 'provider_protocol_template.dart';

/// 字段级校验失败原因;UI 据此映射 l10n 文案,服务据此写 WARN reason。
enum CustomProviderFieldError {
emptyField,
invalidId,
duplicateId,
reservedId,
unknownTemplate,
invalidBaseUrl,
}

/// `id` 白名单模式——进 SecureStorage key / jobs.provider_id / 日志,收紧字符集。
final RegExp kCustomProviderIdPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9_-]*$');

/// 非空(trim 后)通用校验。
CustomProviderFieldError? validateRequired(String value) =>
value.trim().isEmpty ? CustomProviderFieldError.emptyField : null;

/// id 校验:非空 → 模式 → 文件内唯一 → 不撞内置 providerId。
/// [takenIds] 为文件内其他条目的 id 集合(编辑自身时调用方先剔除自身)。
CustomProviderFieldError? validateId(
String value, {
required Set<String> takenIds,
required Set<String> reservedProviderIds,
}) {
final id = value.trim();
if (id.isEmpty) return CustomProviderFieldError.emptyField;
if (!kCustomProviderIdPattern.hasMatch(id)) {
return CustomProviderFieldError.invalidId;
}
if (takenIds.contains(id)) return CustomProviderFieldError.duplicateId;
if (reservedProviderIds.contains('$kCustomProviderIdPrefix$id')) {
return CustomProviderFieldError.reservedId;
}
return null;
}

/// template 校验:白名单之一。
CustomProviderFieldError? validateTemplate(String value) {
final t = value.trim();
if (t.isEmpty) return CustomProviderFieldError.emptyField;
if (!kProviderProtocolTemplates.containsKey(t)) {
return CustomProviderFieldError.unknownTemplate;
}
return null;
}

/// base_url 校验:绝对 http(s)、有 host、无 query/fragment/userinfo
/// (Dio baseUrl 为字符串拼接,带 query 必产坏请求)。
CustomProviderFieldError? validateBaseUrl(String value) {
final raw = value.trim();
if (raw.isEmpty) return CustomProviderFieldError.emptyField;
final uri = Uri.tryParse(raw);
if (uri == null ||
!uri.isAbsolute ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty ||
uri.hasQuery ||
uri.hasFragment ||
uri.userInfo.isNotEmpty) {
return CustomProviderFieldError.invalidBaseUrl;
}
return null;
}

/// base_url 规范化:尾部 `/` 剔除(Dio baseUrl + '/path' 拼接避免双斜杠)。
String normalizeBaseUrl(String url) {
var out = url.trim();
while (out.endsWith('/')) {
out = out.substring(0, out.length - 1);
}
return out;
}
3 changes: 3 additions & 0 deletions lib/features/settings/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'widgets/about_section.dart';
import 'widgets/api_keys_section.dart';
import 'widgets/backup_section.dart';
import 'widgets/canvas_appearance_section.dart';
import 'widgets/custom_providers_section.dart';
import 'widgets/diagnostics_section.dart';
import 'widgets/language_section.dart';
import 'widgets/storage_path_section.dart';
Expand Down Expand Up @@ -44,6 +45,8 @@ class SettingsScreen extends ConsumerWidget {
children: <Widget>[
ApiKeysSection(),
SizedBox(height: InkSpacing.xl),
CustomProvidersSection(),
SizedBox(height: InkSpacing.xl),
ThemeSection(),
SizedBox(height: InkSpacing.xl),
CanvasAppearanceSection(),
Expand Down
9 changes: 8 additions & 1 deletion lib/features/settings/widgets/api_keys_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/secure_storage_keys.dart';
import '../../../core/di/providers.dart';
import '../../../core/errors/ink_error.dart';
import '../../../core/models/custom_provider_config.dart';
import '../../../core/models/provider_capabilities.dart';
import '../../../l10n/l10n_x.dart';
import '../../../theme/app_theme.dart';
Expand Down Expand Up @@ -133,7 +134,13 @@ class _ApiKeyRowState extends ConsumerState<_ApiKeyRow> {
final keyState = ref.watch(apiKeyScopeControllerProvider(_providerId));
final loading = keyState.isLoading;
final isSet = keyState.valueOrNull ?? false;
final label = SecureStorageKeys.displayNameOf(widget.scope);
// custom:* 行显示配置里的 displayName(GAP-1 顺带修——此前裸 custom:<id>)。
final String? customDisplayName = widget.members.first.displayName;
final label = widget.scope.startsWith(kCustomProviderIdPrefix) &&
customDisplayName != null &&
customDisplayName.isNotEmpty
? customDisplayName
: SecureStorageKeys.displayNameOf(widget.scope);
final memberIds =
widget.members.map((c) => c.providerId).join(' / ');
final showMembers =
Expand Down
Loading
Loading