sqlseed-web 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
sqlseed_web/state.py ADDED
@@ -0,0 +1,348 @@
1
+ """In-memory state: connection registry and background job tracker.
2
+
3
+ The UI backend is intentionally stateful: each opened database holds one
4
+ long-lived ``DataOrchestrator`` (lazy-connected), and fills run as
5
+ background jobs so the HTTP layer never blocks on large generations.
6
+
7
+ Thread-safety model:
8
+ - One global lock guards the connection/job dicts (short critical sections).
9
+ - One per-connection lock serializes fills: a single DataOrchestrator is
10
+ not safe for concurrent ``fill_table`` calls (shared PRAGMA state,
11
+ shared pool, SQLite single-writer).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import getpass
17
+ import json
18
+ import os
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from collections.abc import Iterator
23
+ from contextlib import contextmanager
24
+ from dataclasses import dataclass, field, replace
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from sqlalchemy.engine import URL, make_url
29
+ from sqlseed._utils.logger import get_logger
30
+ from sqlseed.core.orchestrator import DataOrchestrator
31
+
32
+ from sqlseed_web.sqlite_target import sqlite_target
33
+
34
+ logger = get_logger(__name__)
35
+
36
+
37
+ class ConnectionBusyError(RuntimeError):
38
+ """A connection cannot be closed while a job or operation is using it."""
39
+
40
+
41
+ class UnknownConnectionError(KeyError):
42
+ """A connection identifier is absent or was closed before an operation."""
43
+
44
+
45
+ @dataclass
46
+ class Job:
47
+ """A background job (fill or auto-heal) tracked for the UI."""
48
+
49
+ job_id: str
50
+ conn_id: str
51
+ kind: str # "fill" | "auto_heal"
52
+ label: str
53
+ status: str = "running" # running | done | error
54
+ started_at: float = 0.0
55
+ finished_at: float = 0.0
56
+ rows_before: int = 0
57
+ rows_inserted: int = 0
58
+ error: str | None = None
59
+ result: dict[str, Any] = field(default_factory=dict)
60
+
61
+
62
+ @dataclass
63
+ class Connection:
64
+ """A registered database connection with its orchestrator."""
65
+
66
+ conn_id: str
67
+ target: str # SQLite file path or SQLAlchemy URL
68
+ provider: str
69
+ locale: str
70
+ orchestrator: DataOrchestrator
71
+
72
+
73
+ class UIState:
74
+ """Registry of connections and jobs for the lifetime of the server."""
75
+
76
+ def __init__(self) -> None:
77
+ self._conns: dict[str, Connection] = {}
78
+ self._jobs: dict[str, Job] = {}
79
+ self._conn_locks: dict[str, threading.Lock] = {}
80
+ self._operation_owners: dict[str, int] = {}
81
+ self._global_lock = threading.Lock()
82
+ # In-session AI config overrides (UI 内 AI 配置面板). Empty values mean
83
+ # "fall back to environment" — the panel lets users switch online/
84
+ # local backends without editing env vars or restarting the server.
85
+ self._ai_override: dict[str, str] = {}
86
+
87
+ # ---- AI config override -------------------------------------------------
88
+
89
+ def set_ai_override(self, values: dict[str, str | None]) -> None:
90
+ with self._global_lock:
91
+ self._ai_override = {k: v for k, v in (values or {}).items() if v}
92
+
93
+ def get_ai_override(self) -> dict[str, str]:
94
+ with self._global_lock:
95
+ return dict(self._ai_override)
96
+
97
+ # ---- connections ----------------------------------------------------
98
+
99
+ def add_connection(
100
+ self, target: str, provider: str = "mimesis", locale: str = "en_US", *, connection_id: str | None = None
101
+ ) -> Connection:
102
+ """Create and register a DataOrchestrator for the given target."""
103
+ if connection_id is not None and not connection_id:
104
+ raise ValueError("connection ID must not be empty")
105
+ conn_id = connection_id if connection_id is not None else uuid.uuid4().hex[:12]
106
+ # Reject unsupported SQLite identities before opening a database or
107
+ # registering a session that grouping/admission could not identify.
108
+ sqlite_target(target, conn_id)
109
+ orch = DataOrchestrator(target, provider_name=provider, locale=locale)
110
+ conn = Connection(conn_id=conn_id, target=target, provider=provider, locale=locale, orchestrator=orch)
111
+ with self._global_lock:
112
+ if conn_id in self._conns:
113
+ orch.close()
114
+ raise ValueError("connection ID is already registered")
115
+ self._conns[conn_id] = conn
116
+ self._conn_locks[conn_id] = threading.Lock()
117
+ return conn
118
+
119
+ def get_connection(self, conn_id: str) -> Connection:
120
+ """Return a registered connection or reject an expired identifier."""
121
+ with self._global_lock:
122
+ if (conn := self._conns.get(conn_id)) is None:
123
+ raise UnknownConnectionError(f"unknown connection: {conn_id}")
124
+ return conn
125
+
126
+ def list_connections(self) -> list[dict[str, Any]]:
127
+ """List connections with same-target grouping metadata.
128
+
129
+ Multiple connections to the same DB file are legal (SQLite allows
130
+ concurrent readers + serialized writers) but visually confusing.
131
+ Each entry gets a stable ``group_key`` (normalized target) and a
132
+ 1-based ``group_index``; the frontend renders the first connection
133
+ of a group as the primary and the rest as parallel connections.
134
+ """
135
+ counts: dict[str, int] = {}
136
+ entries: list[dict[str, Any]] = []
137
+ with self._global_lock:
138
+ connections = list(self._conns.values())
139
+ for c in connections:
140
+ group_key = _normalize_target(c.target, c.conn_id)
141
+ counts[group_key] = counts.get(group_key, 0) + 1
142
+ entries.append(
143
+ {
144
+ "conn_id": c.conn_id,
145
+ "target": c.target,
146
+ "provider": c.provider,
147
+ "locale": c.locale,
148
+ "group_key": group_key,
149
+ "group_index": counts[group_key],
150
+ }
151
+ )
152
+ # Annotate group size so the UI can show "1/3" style labels.
153
+ totals: dict[str, int] = {}
154
+ for e in entries:
155
+ totals[e["group_key"]] = totals.get(e["group_key"], 0) + 1
156
+ for e in entries:
157
+ e["group_size"] = totals[e["group_key"]]
158
+ return entries
159
+
160
+ def close_connection(self, conn_id: str) -> None:
161
+ """Close an idle connection atomically with respect to job creation."""
162
+ with self._global_lock:
163
+ if any(j.conn_id == conn_id and j.status == "running" for j in self._jobs.values()):
164
+ raise ConnectionBusyError("当前连接有任务正在运行,请等待任务完成后再断开。")
165
+ lock = self._conn_locks.get(conn_id)
166
+ if lock is not None and not lock.acquire(blocking=False):
167
+ raise ConnectionBusyError("当前连接正在处理请求,请等待完成后再断开。")
168
+ conn = self._conns.pop(conn_id, None)
169
+ self._conn_locks.pop(conn_id, None)
170
+ try:
171
+ if conn is not None:
172
+ conn.orchestrator.close()
173
+ finally:
174
+ if lock is not None:
175
+ lock.release()
176
+
177
+ def connection_lock(self, conn_id: str) -> threading.Lock:
178
+ """Return the serialization lock for a live connection."""
179
+ with self._global_lock:
180
+ return self._conn_locks[conn_id]
181
+
182
+ @contextmanager
183
+ def connection_operation(
184
+ self, conn_id: str, *, job_id: str | None = None, write: bool = False
185
+ ) -> Iterator[Connection]:
186
+ """Admit one operation immediately; never queue interactive HTTP workers.
187
+
188
+ A reserved background worker supplies its own job ID. Other requests
189
+ cannot steal the gap between reservation and worker startup. Reentry
190
+ also fails promptly instead of deadlocking the non-reentrant lock.
191
+ """
192
+ with self._global_lock:
193
+ if (conn := self._conns.get(conn_id)) is None:
194
+ raise UnknownConnectionError(f"unknown connection: {conn_id}")
195
+ if job_id is not None:
196
+ job = self._jobs.get(job_id)
197
+ if job is None or job.conn_id != conn_id or job.status != "running":
198
+ raise UnknownConnectionError(f"unknown running job: {job_id}")
199
+ self._check_job_admission(conn, write=write, job_id=job_id)
200
+ lock = self._conn_locks[conn_id]
201
+ if not lock.acquire(blocking=False):
202
+ raise ConnectionBusyError("当前连接正在处理请求,请等待完成后重试。")
203
+ self._operation_owners[conn_id] = threading.get_ident()
204
+ try:
205
+ yield conn
206
+ finally:
207
+ with self._global_lock:
208
+ self._operation_owners.pop(conn_id, None)
209
+ lock.release()
210
+
211
+ def _check_job_admission(self, conn: Connection, *, write: bool, job_id: str | None = None) -> None:
212
+ """Check reservations while the caller holds the short registry lock."""
213
+ target = _write_target(conn)
214
+ for job in self._jobs.values():
215
+ if job.status != "running" or job.job_id == job_id:
216
+ continue
217
+ if job.conn_id == conn.conn_id:
218
+ raise ConnectionBusyError("当前连接有任务正在运行,请等待完成后重试。")
219
+ other = self._conns.get(job.conn_id)
220
+ if write and job.kind in {"fill", "workbench"} and other and _write_target(other) & target:
221
+ raise ConnectionBusyError("此数据库已有生成任务正在运行,请查看运行记录并等待完成。")
222
+
223
+ # ---- jobs -----------------------------------------------------------
224
+
225
+ def create_job(self, conn_id: str, kind: str, label: str) -> Job:
226
+ """Reserve a live connection for a job before its worker is started."""
227
+ with self._global_lock:
228
+ if (conn := self._conns.get(conn_id)) is None:
229
+ raise UnknownConnectionError(f"unknown connection: {conn_id}")
230
+ self._check_job_admission(conn, write=kind in {"fill", "workbench"})
231
+ if self._conn_locks[conn_id].locked() and self._operation_owners.get(conn_id) != threading.get_ident():
232
+ raise ConnectionBusyError("当前连接正在处理请求,请等待完成后再生成。")
233
+ job = Job(job_id=uuid.uuid4().hex[:12], conn_id=conn_id, kind=kind, label=label, started_at=time.time())
234
+ self._jobs[job.job_id] = job
235
+ return job
236
+
237
+ def get_job(self, job_id: str) -> Job:
238
+ """Return a job to its worker; HTTP readers should use a snapshot."""
239
+ with self._global_lock:
240
+ if (job := self._jobs.get(job_id)) is None:
241
+ raise KeyError(f"unknown job: {job_id}")
242
+ return job
243
+
244
+ def complete_job(
245
+ self,
246
+ job_id: str,
247
+ *,
248
+ result: dict[str, Any] | None = None,
249
+ error: str | None = None,
250
+ rows_inserted: int = 0,
251
+ ) -> None:
252
+ """Publish all terminal fields together, with status assigned last."""
253
+ with self._global_lock:
254
+ job = self._jobs[job_id]
255
+ if result is not None:
256
+ job.result = result
257
+ job.rows_inserted = rows_inserted
258
+ job.error = error
259
+ job.finished_at = time.time()
260
+ job.status = "error" if error else "done"
261
+
262
+ @contextmanager
263
+ def job_completion(self, job_id: str) -> Iterator[None]:
264
+ """A worker must publish a terminal state even when a programming error escapes."""
265
+ try:
266
+ yield
267
+ finally:
268
+ with self._global_lock:
269
+ job = self._jobs[job_id]
270
+ if job.status == "running":
271
+ job.error = "后台任务意外终止,请检查服务日志后重试。"
272
+ job.finished_at = time.time()
273
+ job.status = "error"
274
+
275
+ def job_snapshot(self, job_id: str) -> Job:
276
+ """Read terminal state and its result from the same publication."""
277
+ with self._global_lock:
278
+ if (job := self._jobs.get(job_id)) is None:
279
+ raise KeyError(f"unknown job: {job_id}")
280
+ return replace(job, result=dict(job.result))
281
+
282
+ def recent_jobs(self, limit: int = 20) -> list[Job]:
283
+ """Return consistent snapshots of the newest jobs first."""
284
+ with self._global_lock:
285
+ return [replace(job, result=dict(job.result)) for job in list(self._jobs.values())[-limit:][::-1]]
286
+
287
+
288
+ def _normalize_target(target: str, conn_id: str = "") -> str:
289
+ """Normalize a connection target into a grouping key.
290
+
291
+ SQLite filenames follow DBAPI URI semantics, shared memory uses its name,
292
+ and private memory uses the registered connection id. Network grouping is
293
+ display-only; PostgreSQL write admission separately resolves URL endpoints.
294
+ """
295
+ if (sqlite := sqlite_target(target, conn_id)) is not None:
296
+ return sqlite.key
297
+ scheme, rest = target.split("://", 1)
298
+ if "@" in rest:
299
+ _, hostpart = rest.rsplit("@", 1)
300
+ rest = f"***:{hostpart}"
301
+ return f"{scheme}://{rest}"
302
+
303
+
304
+ def _write_target(conn: Connection) -> frozenset[str]:
305
+ """Normalize known targets without claiming DNS/proxy/service resolution.
306
+
307
+ PostgreSQL multihost URLs reserve each possible endpoint; credentials and
308
+ driver/SSL options do not change the write target. SQLite URI filenames
309
+ must use DBAPI semantics, including named shared-memory databases.
310
+ """
311
+ if (sqlite := sqlite_target(conn.target, conn.conn_id)) is not None:
312
+ return frozenset({sqlite.key})
313
+ url = make_url(conn.target)
314
+ if url.get_backend_name() == "postgresql":
315
+ return _postgres_write_targets(url)
316
+ return frozenset({_normalize_target(conn.target)})
317
+
318
+
319
+ def _postgres_endpoint_keys(host: str, address: str, port: str, database: str) -> set[str]:
320
+ keys: set[str] = set()
321
+ # Socket paths are case sensitive; DNS hostnames are not. Missing
322
+ # host/service remains an unresolved local endpoint, never a DNS lookup.
323
+ for endpoint in {host, address} - {""} or {"<default>"}:
324
+ endpoint = str(Path(endpoint).resolve()) if endpoint.startswith("/") else endpoint.lower()
325
+ keys.add(json.dumps(["postgresql", endpoint, int(port or "5432"), database]))
326
+ return keys
327
+
328
+
329
+ def _postgres_write_targets(url: URL) -> frozenset[str]:
330
+ """Use SQLAlchemy's libpq argument rules, including query host overrides."""
331
+ dialect = url.set(drivername="postgresql+psycopg2").get_dialect()()
332
+ _, options = dialect.create_connect_args(url)
333
+ user = options.get("user") or os.environ.get("PGUSER") or getpass.getuser()
334
+ database = options.get("dbname") or os.environ.get("PGDATABASE") or user
335
+ hosts = str(options.get("host") or os.environ.get("PGHOST") or "").split(",")
336
+ ports = str(options.get("port") or os.environ.get("PGPORT") or "5432").split(",")
337
+ addresses = str(options.get("hostaddr") or os.environ.get("PGHOSTADDR") or "").split(",")
338
+ keys: set[str] = set()
339
+ for index in range(max(len(hosts), len(addresses))):
340
+ host = hosts[index] if index < len(hosts) else ""
341
+ address = addresses[index] if index < len(addresses) else ""
342
+ port = ports[index] if index < len(ports) else ports[0]
343
+ keys.update(_postgres_endpoint_keys(host, address, port, database))
344
+ return frozenset(keys)
345
+
346
+
347
+ # Module-level singleton shared by all routers.
348
+ state = UIState()
@@ -0,0 +1,180 @@
1
+ # sqlseed-web 静态前端
2
+
3
+ 上层见 [后端指南](../AGENTS.md)。本目录由 FastAPI 直接提供,使用原生 ES modules、DOM 与 CSS,无 bundler、CDN 或 npm 构建。
4
+
5
+ ## 当前产品契约
6
+
7
+ - v8 整体重建已完成,当前以 [可用性与 AI 辅助计划](../../../../../docs/superpowers/plans/2026-09-07-workbench-usability-ai.md) 为细化依据;其后依次是 [v8 重建契约](../../../../../docs/superpowers/plans/2026-09-07-web-v8-rebuild.md)、v8 原型 README/HTML 和重设计提案未被后续评审覆盖的业务规则。保留 v8 单一主题与当前四项导航;本轮浏览器、真实 LLM 与 PostgreSQL 验收分别记录,不能把计划或自动测试当作全部验收通过。
8
+ - 正式主导航为“工作台 / 配置管理 / 运行记录 / 设置”;连接、首次空状态与切换目标使用同主题弹窗。`connect/wizard/browse/heal/meta` 历史页面保留文件,不在正式 router 中注册或动态加载,旧/未知 hash 回到工作台。
9
+ - 从 v8 重建整个 shell、叶芽表格 SVG logo、字体、色彩与空间节奏;只有一套生效主题。`index.html` 仅加载 `style.css`,后者导入组件样式并承载 v8 基础规则;`workbench.css` 只补组件类,不定义第二套 tokens、body 或旧 Material 3 覆盖。
10
+ - 复用后端、model/session 与图计算;重新设计 presentation 不得退回模拟数据或独立 YAML/表单状态。生成引擎和语言地区属于同一生成配置;连接仅绑定目标。
11
+
12
+ ## 修改入口
13
+
14
+ | 文件 | 职责 |
15
+ | --- | --- |
16
+ | [index.html](index.html)、[style.css](style.css) | v8 页面 shell、唯一主题、品牌与共享布局控件 |
17
+ | [js/app.js](js/app.js)、[js/api.js](js/api.js) | hash router、HTTP/DOM helpers、共享连接 `store` 与恢复 |
18
+ | [js/pages/](js/pages/) | 正式 workbench/runs;其余页面为历史模块,不可由 router 加载 |
19
+ | [js/workbench/](js/workbench/) | model/session、真实 schema graph、右侧规则抽屉、connection 弹窗与共享 UI;`workbench.css` 仅组件样式 |
20
+ | [js/genform.js](js/genform.js) | 历史向导列属性面板,以下 legacy 回归规则仅适用于维护该文件 |
21
+ | [js/dropdown.js](js/dropdown.js)、[js/tree.js](js/tree.js)、[js/filepicker.js](js/filepicker.js) | 自绘下拉、表列树、服务器目录选择器 |
22
+ | [js/labels.js](js/labels.js) | generator/param 中文标签与分类 |
23
+
24
+ ## DOM 与状态
25
+
26
+ - 主导航为工作台、运行记录、配置管理和设置;不可因旧页面或 API 仍存在而增加产品入口。用户后续评审已授权工作台内的 AI 辅助面板,仍不恢复旧 heal、诊断或历史页面。
27
+ - AI 的当前表/勾选表/整库/指定表/指定列范围与生成勾选分离。`ai.js` 传完整 document 与 tableDrafts,展示业务说明、关联来源及服务端只读样例;相同 group_id 只能整组选择并经 model 原子应用。列范围仍保留整表与上游分析上下文,不能扩张允许修改列。初次设置加载、分析、保存和探测期间防止迟到响应覆盖编辑;关闭/epoch/schema 变化后不能应用。
28
+ - 表单标题使用中文:“数据生成引擎”“数据语言与地区”;保留 SQLite、Mimesis、Faker 等产品名。Locale 影响生成内容的语言与地区格式,不称“默认市场”,也不暗示改变界面语言。
29
+ - 新页面在 router 注册并导出 `render()`,可选 `mount()`;`render()` 发生在 DOM mount 前,访问新元素时传引用,挂载后再查 DOM。
30
+ - 页面切换保留 module-level 状态;刷新会清空 JS store,但服务器连接仍在。`restoreConnection()` 优先 localStorage 的 `sqlseed.connId`,其次主连接,再取表列表;失效时清除旧 key。显式断开使用空标识且优先于自动恢复,即使还有其他会话也不能自动切换;连接操作版本防止迟到恢复覆盖用户选择。
31
+ - router 支持 `unmount()` 并检查异步加载版本;工作台/记录页离开时销毁组件、关闭弹窗和停止轮询。网络迟到结果不可重开页面或覆盖新文档。
32
+ - 不用原生 `<select>`:嵌入式 WebView 的系统弹窗曾错位。用 `createDropdown()`;genform 新建实例必须经过 `track()`,重渲染前 `destroy()`,避免 document 监听器泄漏。
33
+ - 下拉展开时浮层挂在当前 overlay(无弹窗则 body),以视口固定定位避开滚动体裁切,并按剩余空间上下展开。关闭后还原 DOM,移除滚动、窗口与焦点监听;Escape 先关浮层,不能同时丢弃编辑抽屉。测试查找展开选项应从唯一 `.dropdown-floating` 进入。
34
+ - `h()` 的 boolean attributes 传真正布尔值;原生 `setAttribute('disabled', false)` 仍会禁用。数组节点用 `append(...nodes)`,直接 `append(nodes)` 会变成文本。
35
+ - input 通用宽度规则必须排除 checkbox/radio,避免勾选框挤开标签。
36
+
37
+ ## 历史 genform 面板回归(不作为新工作台布局约束)
38
+
39
+ 本节记录旧 `genform.js` 的行为,只有维护历史模块时适用。数据库硬约束、配置无损往返和参数单位仍需在新实现保持;旧面板顺序、默认交互、分类占位与能力裁剪不得覆盖当前契约。
40
+
41
+ - 保持顺序:字段名/类型 → generator → 参数 → 预览/刷新 → NULL/唯一通用区 → 重置。重建时保存明确的 `paramsHolder` 引用,不按第一个 `.genform-section` 猜位置。
42
+ - 参数与预览共同使用 `buildCfg()`;UI NULL 百分比是 0–100,提交除以 100,`fromInferred()` 展示时乘以 100。勾选默认 5%,未勾选禁用输入。
43
+ - NOT NULL 禁止提交 null_ratio;数据库唯一列强制 unique,优先于通用区裁剪。显式自增主键整面板只读,依据 `ColumnInfo` 判断,不把所有 `skip` 都当成自增。
44
+ - 外键身份与引用来源由 schema `foreign_keys` 经 `foreignKeysOf` 传入,不按 generator 标签或 `_id` 名称判断。外键面板先于普通/派生配置显示引用列及预览;可空时允许 NULL 设置,保存为 `foreign_key_or_integer` 并保留采样策略和显式约束,不携带运行时父表值或普通生成器参数。空表自引用初始化不能承诺精确 NULL 比例。
45
+ - 外键的单列唯一性使用 schema `unique_columns`,复合主键成员不能仅因 `is_primary_key` 就各自设置 unique。
46
+ - `derive_from`/`expression` 必须在 `applyAiYaml`、`showColumnInPanel`、`fromInferred`、`buildCfg`、YAML 保存中保留;derived mode 与 generator 互斥,不能回落成 `string`。
47
+ - 派生列展示来源/表达式/预览;用户点“重置属性”才回到 `zeroConfig` 的普通生成器基线,不能以 AI 改写后的 inferred spec 当作原始基线。
48
+ - 旧普通生成器均保留 NULL 通用区;旧 `NO_UNIQUE_GENS` 隐藏 text/choice/weighted_choice/bytes 的普通唯一选项,`NO_PREVIEW_GENS` 隐藏 bytes 预览。新工作台不继承此裁剪:有限词表的 unique 根据候选容量和真实能力检查,数据库唯一约束始终保留。
49
+ - `schedulePreview()` 对 generator/params/NULL/unique 变化做 400ms 防抖,更新 `previewBox` 并检查 `isConnected`。choice/weighted_choice 缺必填参数时显示“待填写”,不发送注定失败的预览。
50
+ - 新参数同时维护 `PARAM_LABELS`、`NUMERIC_PARAMS`/`TEXTAREA_PARAMS` 等显式集合,不用名称正则猜控件类型。
51
+ - `HIDDEN_PARAMS` + `normalizeAliasParams()` 合并 pattern/regex 与 weighted_choice 的 choices/weighted_choices;加权值使用对象结构,不把自由文本直接作为对象列表处理。
52
+ - JSON generator 的 `schema` 使用 JSON 对象编辑器,不能用 `String(object)` 或按普通字符串提交;无效输入显示错误并保留最近有效配置,停止自动/手动列预览。
53
+ - 日期参数以精确日期为主,隐藏年份兼容字段;只有完全没有任何日期/年份边界时,`applyDateDefaults()` 才补默认范围,保留部分 AI 配置。
54
+ - date/datetime/time 控件保留“一整天”与时间输入联动、weekday 模式;weekdays 序列化为 `"all"`、`"workdays"` 或 `[0…6]`(Monday=0)。
55
+ - bytes 图像/目录两种模式要保留互斥参数清理:切换时删除另一模式参数,避免 core 的 folder 优先级覆盖用户新选择。
56
+ - 旧 `GEN_CATEGORIES` 的 `pending` 分类占位仅属历史行为;新工作台不显示空分类,不创建虚构 generator。
57
+
58
+ ## 历史向导与 AI 页面回归
59
+
60
+ 本节仅约束保留的 legacy 模块与原有 API 集成;不要求新产品显示这些页面,不代表新工作台的执行边界。
61
+
62
+ - `treeSelection` 通过 `initialSelection` 与 `onChange` 往返保存,返回 Step 2 时不能重置用户或 AI 的表列选择。
63
+ - 向导配置按连接隔离;提交时深拷贝连接、表顺序、列配置和数量,所有后续请求使用同一快照。异步返回必须核对连接及页面版本。
64
+ - YAML 导出以完整配置对象调用 `/api/config/serialize`,不手拼 YAML;内部 `null_ratio` 始终为 0–1。导入的根级 associations/custom mappings 仅保留导出,向导需明确提示执行边界。
65
+ - browse 为每个连接独立读取表列表,选择后同时更新连接 ID、target、tables 和持久化记录;旧请求不能覆盖新选择。
66
+ - `applyAiYaml()` 替换当前列配置,只接受当前 schema 中存在的表/列,并同步树标注与当前属性面板;该动作不写库,写库由 Step 3“开始生成”触发。
67
+ - 读取 `/api/ai/config` 的有效配置做就绪检查:AI 已安装且本地 backend 或在线 key 可用;点击生成时再探测连接。不要把本地 backend 错拦在 API key 检查上。
68
+ - AI job 使用更长轮询预算(wizard 当前 900 次);普通 fill 的 `pollJob` 默认 120 次、间隔 400ms,不能套给可能运行数分钟的 LLM 流程。
69
+ - 展示 `job.result.llm_calls`,允许 0;Step 3 填充遵循外键 topo-order。当前预览按选中表列表迭代,不要误称预览也已拓扑排序。
70
+
71
+ ## 正式工作台
72
+
73
+ - [js/workbench/ai.js](js/workbench/ai.js) 提供同主题 AI 服务摘要、范围选择与建议审阅;支持当前表、已勾选表、整库、指定表和指定列,明确披露发送结构而非连接/记录。建议默认不勾选,显示当前规则、建议规则和原因后才允许应用。
74
+ - AI 面板捕获 model identity / epoch / schema hash;离页、关闭、结构改变、配置改变及过期响应不能应用。保存、检测和分析期间禁用可改变请求含义的控件(包含已列出的模型快捷按钮),迟到结果不能覆写新输入。
75
+ - AI 指定字段搜索仅过滤显示,不改变 `allowed_targets` 的既有选择;计数区分已选总量、筛选内与筛选外。选择筛选结果只追加当前可修改字段,清空选择清除全部授权;受保护字段按表折叠说明,仅作上下文,不得加入授权。忙碌时禁用搜索和批量选择,范围变化清除旧建议,生成勾选不变。
76
+ - AI 只调用 `/api/workbench/ai`;请求/审阅共用模态生命周期,销毁 dropdown、abort 请求和定时器。缺插件、缺模型或连接失败时显示具体下一步,不以本地匹配伪装成 AI 结果;不能自动选择或加入生成表。
77
+
78
+ - `WorkbenchDocument.document` 是唯一可执行配置;取消勾选的表配置保存在 `view.tableDrafts`,换表/字段规则/关系图不改变生成范围。点表名固定打开字段,右侧 schema 图标打开完整依赖路径,库名打开整库。
79
+ - 字段名打开只读字段信息;取值规则打开编辑页,两者使用具有 tablist/tab/tabpanel 语义的同一右侧抽屉。图旁规则打开编辑页;应用、取消、切字段与关闭后恢复焦点,不沿用旧 inline auto-apply 布局。配置级保存/打开、provider/locale 与配置文档属于同一模型。
80
+ - `WorkbenchSession` 捕获 model 身份、epoch、connId 和保存版本;异步 check/preview/save 必须检查身份,不能只比较相同 epoch。run 只提交已保存并检查通过的版本/hash,由服务端接管整个计划。
81
+ - `model.samples` 与执行授权分离;预览未勾选表只临时加入请求,不能改变勾选或拿该检查授权运行。本表预览只包含当前表与执行所需的已选上游,遇未选来源停止纳入其祖先;FK 与 associations 一致,根配置无损。所选表与本表预览共用请求版本,迟到结果不能覆盖新样例。有效编辑清掉旧样例和检查。
82
+ - 无效抽屉输入就地保留并禁用“应用规则”,不得污染 model 或静默应用旧值;取消/离页关闭抽屉并丢弃未应用修改,返回后保留已应用配置。销毁 dropdown 监听器。表级 count 的无效值仍保留在视图草稿中并阻止保存/运行。
83
+ - graph 的搜索框不随重画替换;IME composition 结束才搜索。百分比显示实际图形尺度,100% 是自然尺寸;适应画布按当前范围缩放,阅读当前表依赖切换完整路径并以 100% 居中,搜索结果支持同一定位动作。匹配数量可见,结果列表有限高度滚动。内部 `zoom` 保持相对 fit 的快照语义,不可直接作为显示百分比。完整路径包括下游及所有必需上游,字段标签使用实际成组 FK,不能凭字段名猜边。
84
+ - graph 的 `pathFocus` 是展示路径的起点,`focus` 是当前检查表;图内单击保持画布和路径标题稳定,不同时另标当前查看对象,明确读取路径才同步二者。两个值均保存在视图快照,旧快照缺 `pathFocus` 时回退到 `focus`;定位与重算不改变生成勾选,并保留宿主导航 veto。
85
+ - 图旁展示当前表完整结构上游及本表相关生成顺序;顶栏依赖检查覆盖所有勾选表。结果先呈现阻断/提醒摘要及对应问题,再展示可展开的来源明细和生成顺序;全局来源默认折叠,本表来源默认展开,重画保留展开状态。局部执行顺序不混入无关表,不把仅引用来源的祖先误作本次写入前置;未勾选表不能借用全局通过结果声称已检查。依赖弹窗的迟到响应不能替换后来打开的抽屉,修复问题后同步更新当前面板,阻断未解决时禁用生成入口。
86
+ - 数据库结构导入/导出位于数据库工具栏,配置保存/检查/摘要位于配置顶栏,行数位于当前表,规则参数位于当前列。导入结构 JSON 只浏览,不替换当前连接或可执行文档。
87
+ - 运行记录里的 snapshot 固定;轮询网络错误不等于任务失败。running 表数量尚未收齐时显示统计中;中断记录必须显示提交数量不确定。
88
+
89
+ ## 验证与参考
90
+
91
+ - 页面变更用浏览器检查首次渲染、切页/刷新、下拉打开后重渲染、NULL 单位、硬约束锁定、derived 配置导入/重置/导出及相关预览。
92
+ - 回归从仓库根运行 `pytest plugins/sqlseed-web/tests/ -q` 和 `node --test plugins/sqlseed-web/tests/test_*.cjs`。
93
+ - [generator_parity.md](../../../../../docs/superpowers/plans/generator_parity.md) 与 [generator_ui_reference.md](../../../../../docs/superpowers/plans/generator_ui_reference.md) 仅供截图和历史能力差距查询,不是新 UI 检查基线;当前规范不要求照搬分类、空组占位或连接级 locale。
94
+ - 用户可见文案、注释和文档用“参考工具/参考设计”,不要引入商业工具品牌名称。
95
+
96
+ ## 标准化交互(2026-09-07 后续评审)
97
+
98
+ - 当前实施依据为 [标准化实施计划](../../../../../docs/superpowers/plans/2026-09-07-workbench-standards-implementation.md)。配置管理的第三项导航是用户后续授权,不恢复旧产品页面。
99
+ - 当前表使用“字段规则 / 预览数据 / 关系图”三个语义 tab;手动键盘激活,方向键只移动焦点。预览内嵌且首次自动请求当前表;多表选择区提供固定所选范围的“预览已选表”窗口。每表 1–100 行(默认 10),按真实返回记录展示。规则表只有字段/取值规则,不重复放样例。数据库结构操作位于左侧库上下文,重新读取结构不生成数据。基础引擎/语言可见,“编辑 YAML”弱化直达且兼容 JSON;文件与下载格式工具在编辑区上方,footer只留取消/应用,不为单项入口增加折叠;原生方法和复杂参数继续折叠。
100
+ - 自绘 dropdown 遵循 select-only combobox/listbox:初始即提供语义名称、aria-expanded/controls/activedescendant,箭头探索不改值,Enter/Space/Tab 确认、Esc 取消;浮层不可被滚动区裁切。
101
+ - date-picker.js 使用 YYYY-MM-DD 文本、自绘日历、月份/年份跳转及网格键盘;无效文本保留并阻止应用。Tab/Escape 由最上层日历优先处理,关闭返回触发点,不误关父抽屉。
102
+ - configs.js 提供筛选、重命名/复制/带 revision 的删除和离线导出;生命周期事件使缓存 model 失效,迟到保存不能复活删除的 id 或回退重命名版本。
103
+ - 生成确认默认追加;SQLite 可选择清空所选表与独立重置计数。replace 必须使用服务端 execution-plan 和 plan_hash,确认页显示真实清空行数、阻断项和事务边界。快照复用只复用规则,清空必须重新确认。
104
+
105
+ ## 请求并发与状态反馈
106
+
107
+ - 同一工作台会话的数据库操作共用在途门禁,主按钮、侧栏与配置文档快捷入口都必须接入;只读预览期间可继续查看/编辑字段。重新绘制不能丢失 busy 状态,失败必须恢复按钮并显示原因。
108
+ - 相同连接的结构读取共用在途请求;切页返回时不能丢弃正在刷新的结构。服务端 409 是忙碌提示,不自动重试生成。
109
+ - 连接添加、切换、断开和文件浏览分别显示在途反馈,保留服务端具体错误。原生 append/replaceChildren 不传 null,否则会渲染字面量。
110
+ - AI 入口使用 ai-eligibility.js 与服务端保护一致的显式规则判定;数据库分配、PK/FK、实际使用数据库默认值的列、计算列以及已有 derived/native 规则显示保护原因。
111
+ - provider-guide.js 区分本地化自然数据和 Base 占位值;格式示例不得表述为实时生成,不以未测数据宣称引擎性能优势。
112
+
113
+ - `preview.js` 的范围/行数仅属只读预览状态,不能改变正式生成数或勾选;关闭、改配置和过期响应不能重开结果。结果缺少的自增/默认值/计算字段按结构和当前规则解释,不虚构 ID;缺少关联样例保留具体问题和空表标签。
114
+
115
+ - 最新对象上下文细化见 [当前表与业务夹具计划](../../../../../docs/superpowers/plans/2026-09-07-workbench-context-and-scenario.md) 与 [阶段二评审实施计划](../../../../../docs/superpowers/plans/2026-09-08-workbench-review-improvements.md)。配置工具与引擎/语言合并为紧凑上下文区,保留直接可见的全局设置、保存状态及生成主入口;侧栏表名与生成/引用状态分行,勾选、字段和依赖路径入口独立,完整名称可获取。AI 使用同一“AI 配置助手”,顶栏保留固定入口,后续批准的使用引导可提供范围快捷入口;NOT NULL 不渲染 NULL 控件,可空百分比按开关显示。侧栏展开状态按 model 保留,查找表不改勾选;内容按自身高度收缩,长表有限滚动且固定表头。
116
+ - 预览缓存必须同时匹配 model、epoch 和 count,保留完整 ok/preview_complete/issues。结果接收与视图生命周期分开:在途离开/重入/remount或关闭批量窗口后,有效结果可在当前匹配的表内显示,但不能重开旧窗口或覆盖新的配置/无效输入。重建预览控件也须接入全局操作门禁。
117
+
118
+ - 用户后续批准首次使用引导:`guidance.js` 只根据现有配置、输入问题和当前 epoch 的完整预览计算下一步,不追认业务规则已审阅。`workbench.js` 复用现有操作门禁和写入确认;引导内 AI 快捷操作与顶栏打开同一助手,默认已选表,不增加独立 AI 流程或自动分析。收起偏好属于浏览器,不改变生成文档;缺插件/待配置/配置已填写仅来自脱敏配置响应,不能当作连通性检测。
119
+
120
+ - 2026-09-09 实测修正:AI DEFAULT 保护按当前实际生成模式判断;生成器主动提供值时可优化,真正省略使用 DEFAULT 时保持保护。PK/FK、计算列及已有派生/原生规则继续保护。追加失败只有完整精确计数才能创建剩余配置,扣除已提交行数,保留原快照与已完成表草稿,不自动提交;中断/未知计数/清空模式不能直接推导剩余量。
121
+
122
+ - 自定义映射/enrichment 涉及 DEFAULT 时,AI 助手先调用只读 eligibility 预检,并与 suggest 共用实际规则解析;响应只含生成模式,不含样例或父键。普通配置不增加请求;编辑、关闭或离页后的旧结果不得打开可分析界面。
123
+
124
+ ## 2026-09-09 应用设置评审
125
+
126
+ - 用户已批准第四项主导航“设置”,由新 `pages/settings.js` 提供 AI 服务、插件与版本;不恢复旧 meta/heal 页面。普通设置由 Web 的 `ai_settings.py` 持久化,密钥保持环境变量或进程内存且按服务绑定。新环境接口由 `settings_environment.py` 只读汇总当前 Python 环境。
127
+ - 工作台 AI 助手仅展示服务/模型摘要与设置入口;范围、业务说明、分析和审阅继续留在助手。`ai-handoff.js` 只在内存保存明确往返的上下文,身份/epoch/schema/生命周期失效时拒绝恢复。设置检测草稿不保存,不以模型列表成功声称推理成功;保存/检测防重复,迟到响应不覆盖新页面。
128
+ - AI 普通设置字段为 backend/model/base_url;`SQLSEED_WEB_SETTINGS_PATH` 可覆盖。UI key 不入磁盘,不随跨 endpoint 切换继承,清除只在当前进程有效。配置表单不把同一进程中的设置称为浏览器私有。
129
+ - `preview.js` 重新预览保留上次 DOM、选中表和滚动;状态标明旧结果,失败保留旧结果。`preview.css` 只提供首载占位和状态高度。使用当前行为回归与实际浏览器尺寸核验,不能把历史测试数当成本轮验收。
130
+
131
+ ## 2026-09-10 设置与关系图细化
132
+
133
+ - 保存状态与服务就绪状态分别呈现。无修改须说明原因,本地服务空 Key 不阻止保存;已有密钥提示按服务和 endpoint 匹配,等价地址/本地默认地址不应误判切换,路径改变不能沿用旧凭据提示。
134
+ - 普通模式环境页消费后端分类、必要性与安装/修复事实,展示指引;显式维护模式的组件操作见后续维护契约。AI 助手及工作台引导保留 `availability_status`,区分未安装与加载异常。
135
+ - `graph-clarity.css` 随主样式加载。节点内框表示生成/引用/其他状态,当前查看使用独立外圈并有图例。完整相关链高亮复用现有依赖选择语义,仅修改视觉类,不重排、不修改 viewBox、`pathFocus` 或生成勾选;环与分叉须可终止。单边选择优先,问题色保留,键盘焦点线与箭头同色,无持续动画。
136
+ - 写入确认的“写入目标”继续直接使用 schema 的动态脱敏 `target_label`,附当前数据库类型与解释,不重建或猜测连接地址。
137
+
138
+ ## 2026-09-10 预览编辑与当前数据
139
+
140
+ - 预览表头使用真实列元数据;就地编辑复用字段规则抽屉与同一 AI 助手,显式绑定 shownTable/column。取消保持配置,应用标记旧样例失效。批量标签按 result.order 且保留错误表。预览→AI→设置显式返回通过内存 handoff 携带预览上下文,沿用身份/epoch/schema/lifecycle 守卫,不持久化样例。
141
+ - `workbench/table-data.js` 是工作台与运行记录共享的只读分页面板。运行结果只承诺“数据库当前数据”,不伪称本次新增快照;运行入口按目标匹配连接,后端再次验证。关闭、切运行或离页后丢弃迟到响应。
142
+ - 安装指引来自后端针对服务解释器生成的 pip/uv 命令,缺工具明确说明;引擎设置跳转插件页复用指引,不另拼 pip 命令。Faker 仍为 Core 必需依赖,UI 标签为“随 sqlseed 安装”。
143
+
144
+ ## 2026-09-10 导航与折叠动效
145
+
146
+ - `navigation.css` 保持品牌、四项导航和连接入口的稳定应用外壳;窄屏导航独立成行。当前页状态与键盘焦点分别表达,强制颜色模式也要同时可辨。
147
+ - `scrollbars.css` 统一根页面、面板、下拉和表格的滚动条。通用模态与连接弹窗通过 `workbench/scroll-lock.js` 共同锁定背景;最后一个持有者关闭才恢复原滚动位置与原有锁状态。关闭返回焦点使用 `preventScroll`,数据表仍保留必要的横向滚动。
148
+ - `app.js` 只在不同已提交顶层页面间添加短暂淡入;初载、同页参数和连接重挂载不播放。不等待动画再执行 import/mount,保留 `routeVersion` 与模块清理,不给页面添加 transform 或持久动画层。
149
+ - `disclosure.css` 统一正式页面与弹窗的圆头 Chevron,保留原生 details/summary、已有默认展开状态和整行点击范围;不为图标增加翻译字符串。
150
+ - 高度动画同时检测 `::details-content`、`interpolate-size`、离散过渡与 `interactivity: inert`。关闭期间内容立即 inert,展开完成后释放 overflow;缺任一能力时回退原生开合。减少动态效果时关闭新增过渡。
151
+ - 弹窗焦点循环包含首个有效 summary,排除隐藏区域和收起内容。CSS 动画的焦点、快速反转和裁切行为必须实测浏览器,Node DOM 回归不能代替。
152
+
153
+ ## 2026-09-10 十项反馈第一阶段
154
+
155
+ - 根滚动容器使用稳定滚动条槽位;不支持时保留纵向滚动条空间,避免异步切页改变应用外壳位置。
156
+ - 无生成表时,依赖检查展示中性选择引导,不发 check 请求;定位入口不得自动勾选。后端 `empty_plan` 校验与非空计划阻断仍保留。
157
+ - 预览交互表头整格提供 hover/focus 背景;列名与明确标注“规则:…”的属性分别使用兄弟按钮,仅对应按钮 hover/focus 时显示下划线。列名直达字段信息,规则直达编辑页;数据库类型/PK/NOT NULL 元数据与只读数据表头不伪装为可编辑规则。关闭或应用后恢复表、列和具体入口焦点,保留样例,规则变更后标明旧结果。路径按钮的 `aria-pressed` 同时取决于当前表和 graph 视图。
158
+ - CHECK 折叠使用单层边界;展开标题仅上角圆角,长约束换行。运行列表的容器边界与卡片分层,结果文字与查看按钮使用独立布局容器。
159
+ - “导出关系图 JSON / 导入关系图 JSON”只对应表与外键关系浏览,保留 `sqlseed-schema-graph` 格式及既有文件兼容性,不暗示完整 schema 导入或数据库建表。
160
+ - 未受管部署的缺包/异常安装指引收进管理员折叠区,命令原样作为文本复制,不执行。复制在途门禁直到实际结束才释放;环境刷新、切分类、编辑与离页使旧反馈失效。默认受管启动的安装卸载使用下述网页内流程。
161
+ - 本轮验收见 [十项反馈实施记录](../../../../../docs/design-review/2026-09-10-ui-review-verification.md)。
162
+
163
+ ## 2026-09-11 插件维护(兼容显式维护启动)
164
+
165
+ - 只有服务端启动时注入的 `data-plugin-maintenance` 能切换维护 shell:隐藏业务导航与连接入口,固定设置页插件分类,不请求 AI 设置。业务隔离还必须由后端执行,不能依靠隐藏按钮。
166
+ - `plugin-management.js` 消费服务端能力,不自行推断可卸载关系。可选组件先请求操作计划,展示具体组件、解释器、依赖影响和重启要求,确认后只提交一次 execute;失败或未知响应不可自动重发。
167
+ - 维护状态来自发行包 metadata,`installed` 表示“已安装(待验证)”,不能因 `available:false` 误报导入异常。开始包变更后锁定后续操作,完成后正常重启验证。
168
+ - 旧维护模式任务轮询停止于离页、错误或终态;未知结果仍锁定变更动作,但保留只读完整刷新,以恢复服务重启后丢失的任务和新凭据。此兼容流程不再是默认启动的用户路径。
169
+
170
+ ## 2026-09-11 网页内组件管理与预览收敛
171
+
172
+ - 当前修正依据为 [网页内组件管理与预览设计](../../../../../docs/superpowers/specs/2026-09-11-web-native-plugin-and-preview-design.md)。默认受管启动由 `automatic_lifecycle` 表示;`enabled` 不等于旧维护模式,不得因安装能力可用而禁用 AI 设置或锁死导航。
173
+ - 组件安装/卸载在组件行发起,具体计划经确认后仅提交一次;界面显示准备、处理组件、恢复服务的真实阶段。短断连只自动重试读取状态,绝不重发 execute。任务终态后仍须完成能力与服务代次同步;同步失败保留只读重试。
174
+ - `recover` 返回完整 management snapshot,`active_task` 是其任务字段;重试恢复不重复安装。`instance_id` 与 `service_generation` 变化后刷新环境和当前连接的表列表;只匹配既有 connId,禁止调用会回退其他数据库的启动恢复逻辑。未保存的 AI 草稿继续保留。
175
+ - `data-plugin-supervised-maintenance` 只使恢复期间首次打开的页面落在组件状态,不永久固定设置页;业务暂停由后端准入门禁处理。已打开的页面自动恢复,无需刷新浏览器或输入命令。
176
+ - 管理不可用时说明部署限制;管理员命令与解释器信息默认折叠。自动管理可用的缺失可选组件直接给安装按钮,Faker/Core/Web 的必需依赖保护仍来自后端能力。
177
+ - 预览表头只保留列名→字段信息、规则→取值规则两个直接入口;不再插入上方字段操作条。AI 调整位于规则面板中,同一时刻只有一个编辑/AI 面板。手动草稿未应用或无效时阻止跳转并解释原因,避免静默丢弃。
178
+ - 关闭字段面板后恢复具体入口焦点和原预览位置;预览→AI→设置往返仍保留身份、schema、epoch 和生命周期检查,不自动分析或写库。
179
+ - `preview-scroll-layout.js` 负责预览滚动归属:内嵌短表自然高度,长表保留固定表头;批量弹窗按实际空间选择表格或 body 单一纵向滚动。尺寸观察须在清空/销毁时释放,垂直位置按表统一保存,避免跨尺寸返回或切表时重复计入 body 偏移。恢复入口焦点使用 `preventScroll`,不可覆盖已恢复的位置。
180
+ - 卸载确认与缺组件状态使用具体功能影响提示;AI/引擎的受影响入口保留安装或修复路径。卸载后的可用性刷新不可因未保存设置而跳过,草稿与可用性分别更新。全局和列级引擎缺失须区分,不能静默改为其他引擎。DEFAULT 预检遇 `ai_unavailable` 时显示恢复入口,不能绕过预检进入分析。
@@ -0,0 +1,57 @@
1
+ /* Optional AI review stays inside the workbench's shared visual system. */
2
+ .wb-ai-scope { border:0; padding:0; min-width:0; }
3
+ .wb-ai-scope legend { font-weight:600; margin-bottom:8px; }
4
+ .wb-ai-scope label { min-height:36px; padding:5px 9px; border:1px solid var(--line); border-radius:6px; }
5
+ .wb-ai-scope label:has(input:checked) { border-color:var(--teal); background:var(--soft); }
6
+ .wb-ai-scope.wb-ai-scope-selector { display:block; margin:18px 0 12px; }
7
+ .wb-ai-scope-options { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
8
+ .wb-ai-scope-options .wb-ai-scope-choice { display:flex; align-items:flex-start; gap:9px; min-width:0; padding:10px 12px; cursor:pointer; }
9
+ .wb-ai-scope-options .wb-ai-scope-choice > input { flex-shrink:0; margin-top:3px; }
10
+ .wb-ai-scope-choice > span { display:grid; gap:5px; min-width:0; overflow-wrap:anywhere; }
11
+ .wb-ai-scope-choice strong { font-size:13px; font-weight:600; }
12
+ .wb-ai-scope-description { display:block; font-size:11px; line-height:1.5; color:var(--muted); }
13
+ .wb-ai-scope-choice:has(input:disabled) { cursor:default; }
14
+ .wb-ai-scope-choice:has(input:focus-visible) { outline:3px solid #74b7c9; outline-offset:2px; }
15
+ .wb-ai-scope-summary { padding:10px 12px; margin:10px 0; border-radius:7px; background:var(--soft); font-size:12px; line-height:1.6; }
16
+ .wb-ai-scope-summary strong { display:block; overflow-wrap:anywhere; }
17
+ .wb-ai-scope-summary p { margin:5px 0 0; color:var(--muted); }
18
+ .wb-ai-targets { min-width:0; max-height:250px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:12px; margin:8px 0; }
19
+ .wb-ai-targets[hidden] { display:none; }
20
+ .wb-ai-targets > legend { font-weight:600; }
21
+ .wb-ai-targets fieldset { min-width:0; border:0; margin:8px 0; padding:2px 4px; }
22
+ .wb-ai-targets fieldset legend { font-size:12px; color:var(--muted); }
23
+ .wb-ai-targets label { display:flex; align-items:center; flex-wrap:wrap; gap:9px; min-height:36px; padding:5px 4px; overflow-wrap:anywhere; }
24
+ .wb-ai-targets input[type=checkbox] { width:16px; height:16px; margin:0; flex-shrink:0; }
25
+ .wb-ai-targets summary { cursor:pointer; min-height:36px; padding:7px 4px; }
26
+ .wb-ai-targets.wb-ai-columns { max-height:none; overflow:visible; }
27
+ .wb-ai-columns [hidden] { display:none; }
28
+ .wb-ai-columns .wb-ai-field-search { display:grid; gap:6px; padding:0; font-weight:600; }
29
+ .wb-ai-field-search input { min-width:0; width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:6px; background:var(--paper); color:var(--ink); font:inherit; font-weight:400; }
30
+ .wb-ai-field-search input:focus-visible { outline:3px solid #74b7c9; outline-offset:2px; }
31
+ .wb-ai-field-count { margin:10px 0 5px; font-size:12px; font-weight:600; overflow-wrap:anywhere; }
32
+ .wb-ai-field-results { margin:5px 0; }
33
+ .wb-ai-field-list { max-height:250px; overflow:auto; padding:3px; margin-top:10px; border-top:1px solid var(--line); }
34
+ .wb-ai-protected-fields { margin:4px 8px; color:var(--muted); font-size:12px; }
35
+ .wb-ai-protected-field { display:grid; gap:5px; padding:8px; overflow-wrap:anywhere; }
36
+ .wb-ai-protected-field + .wb-ai-protected-field { border-top:1px solid var(--line); }
37
+ .wb-ai-field-empty { margin:10px 4px; }
38
+ .wb-ai-business { display:grid; gap:7px; margin:16px 0; font-weight:600; }
39
+ .wb-ai-business textarea { width:100%; min-width:0; resize:vertical; padding:10px; border:1px solid var(--line); border-radius:7px; background:var(--paper); color:var(--ink); font:inherit; font-weight:400; }
40
+ .wb-ai-business small { font-weight:400; }
41
+ .wb-ai-business textarea:focus-visible,.wb-ai-targets summary:focus-visible { outline:3px solid #74b7c9; outline-offset:2px; }
42
+ .wb-ai-suggestion > label { min-height:36px; }
43
+ .wb-ai-suggestion h4 { margin:18px 0 6px; overflow-wrap:anywhere; }
44
+ .wb-ai-relation { color:var(--teal); font-size:13px; overflow-wrap:anywhere; }
45
+ .wb-ai-evidence { max-width:100%; overflow:auto; margin:10px 0; border:1px solid var(--line); border-radius:6px; }
46
+ .wb-ai-evidence table { width:100%; border-collapse:collapse; font-size:12px; }
47
+ .wb-ai-evidence caption { text-align:left; padding:9px; color:var(--muted); }
48
+ .wb-ai-evidence th,.wb-ai-evidence td { padding:8px 10px; text-align:left; border-top:1px solid var(--line); overflow-wrap:anywhere; }
49
+ .wb-ai-evidence th { background:var(--canvas); }
50
+ #ai-scope-error:empty { display:none; }
51
+ @media(max-width:600px) { .wb-ai-scope-options { grid-template-columns:1fr; } }
52
+ /* Compact service summary; credentials and connection editing live in Settings. */
53
+ .wb-ai-service-card { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 16px; align-items:center; }
54
+ .wb-ai-service-card > strong { grid-column:1; grid-row:1; }
55
+ .wb-ai-service-card > [aria-label="AI 服务摘要"] { grid-column:1; grid-row:2; margin:0; overflow-wrap:anywhere; }
56
+ .wb-ai-service-card > .wb-ai-status { grid-column:1 / -1; grid-row:3; margin:0; }
57
+ .wb-ai-service-card > .btn { grid-column:2; grid-row:1 / 3; }