feat: Implement project management features including rename and delete functionality

- Added project renaming capability in the workspace with appropriate API endpoints.
- Implemented project deletion functionality, ensuring project directories are removed.
- Updated UI to support project renaming and deletion, including inline editing and confirmation dialogs.
- Enhanced batch generation feature for project items with selection and progress tracking.
- Added tests for project renaming and deletion to ensure functionality and integrity.
This commit is contained in:
Saberlve 2026-04-21 16:55:42 +08:00
parent 4d45602c45
commit cf7e2feb19
12 changed files with 1860 additions and 54 deletions

View File

@ -0,0 +1,254 @@
# Import Window Responsive 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:** Make the floating import window recompute a viewport-appropriate size and position every time it opens, and stack collection/item panes vertically on narrower viewports.
**Architecture:** Keep the existing floating-window shell and collection importer flow, but replace reopen-time geometry reuse with viewport-derived geometry helpers. Add a narrow-layout breakpoint in the template CSS and a resize handler that constrains normal-mode geometry without overriding minimized or maximized state.
**Tech Stack:** FastAPI template rendering, inline HTML/CSS/JavaScript, pytest, Node syntax checking
---
### Task 1: Update UI Contract Tests For Responsive Window Behavior
**Files:**
- Modify: `tests/test_ui.py`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing test**
```python
def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "function computeResponsiveWindowRect()" in html
assert "function applyResponsiveWindowRect()" in html
assert "function syncWindowToViewport()" in html
assert "window.addEventListener(\"resize\"" in html
```
```python
def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".window-pane.items" in html
assert "@media (max-width: 980px)" in html
assert ".window-body {" in html
assert "grid-template-columns: 1fr;" in html
assert "border-bottom: 1px solid var(--line);" in html
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: FAIL because the new responsive geometry helper names are not present yet.
- [ ] **Step 3: Keep the existing syntax guard test**
Do not remove `test_index_inline_script_is_valid_javascript`; it remains the regression guard for the inline script.
- [ ] **Step 4: Run test to verify the contract file still parses**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add tests/test_ui.py
git commit -m "test: define responsive import window contract"
```
### Task 2: Implement Viewport-Derived Window Geometry
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing test**
Use the Task 1 tests as the failing contract. Do not add production code first.
- [ ] **Step 2: Run the failing test**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: FAIL with missing responsive helper function names or missing layout assertions.
- [ ] **Step 3: Write minimal implementation**
Add responsive geometry helpers to the inline script in `src/zotero_kb/templates/index.html`:
```javascript
function computeResponsiveWindowRect() {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = viewportWidth <= 980 ? 8 : 24;
const minWidth = viewportWidth <= 980 ? 320 : 640;
const minHeight = viewportHeight <= 980 ? 420 : 480;
const maxWidth = Math.max(minWidth, viewportWidth - margin * 2);
const maxHeight = Math.max(minHeight, viewportHeight - margin * 2);
const width = Math.min(maxWidth, Math.max(minWidth, Math.round(viewportWidth * 0.88)));
const height = Math.min(maxHeight, Math.max(minHeight, Math.round(viewportHeight * 0.82)));
const left = Math.max(margin, Math.round((viewportWidth - width) / 2));
const top = Math.max(margin, Math.round((viewportHeight - height) / 2));
return { width, height, left, top };
}
function applyResponsiveWindowRect() {
const fw = elements.importWindow;
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
const rect = computeResponsiveWindowRect();
fw.style.width = `${rect.width}px`;
fw.style.height = `${rect.height}px`;
fw.style.left = `${rect.left}px`;
fw.style.top = `${rect.top}px`;
state.windowWidth = rect.width;
state.windowHeight = rect.height;
state.windowX = rect.left;
state.windowY = rect.top;
}
function syncWindowToViewport() {
const fw = elements.importWindow;
if (!fw || state.isWindowMinimized || state.isWindowMaximized) return;
const margin = window.innerWidth <= 980 ? 8 : 24;
const rect = computeResponsiveWindowRect();
const currentWidth = parseFloat(fw.style.width) || rect.width;
const currentHeight = parseFloat(fw.style.height) || rect.height;
const width = Math.min(currentWidth, rect.width);
const height = Math.min(currentHeight, rect.height);
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
const maxTop = Math.max(margin, window.innerHeight - height - margin);
const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, margin), maxLeft);
const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, margin), maxTop);
fw.style.width = `${width}px`;
fw.style.height = `${height}px`;
fw.style.left = `${left}px`;
fw.style.top = `${top}px`;
state.windowWidth = width;
state.windowHeight = height;
state.windowX = left;
state.windowY = top;
}
```
Update `openImportWindow()` to call `applyResponsiveWindowRect()` after `initWindowFromSession()` when the window is not minimized or maximized.
- [ ] **Step 4: Run the focused tests**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: derive import window geometry from viewport"
```
### Task 3: Add Responsive Layout Switching And Resize Wiring
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing test**
Use the layout assertions from Task 1 as the failing contract. No new production code before running them.
- [ ] **Step 2: Run the failing test**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: FAIL if the resize listener or stacked layout rules are still missing.
- [ ] **Step 3: Write minimal implementation**
Extend the existing media query and event wiring in `src/zotero_kb/templates/index.html`:
```css
@media (max-width: 980px) {
.window-frame {
min-width: 0;
width: calc(100vw - 1rem);
height: calc(100vh - 1rem);
left: 0.5rem;
top: 0.5rem;
}
.window-toolbar,
.window-toolbar-actions {
align-items: stretch;
}
.window-body {
grid-template-columns: 1fr;
}
.window-pane.collections {
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
```
Wire the resize handler:
```javascript
window.addEventListener("resize", () => {
syncWindowToViewport();
});
```
- [ ] **Step 4: Run the focused tests**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py
git commit -m "feat: make import window layout responsive"
```
### Task 4: End-To-End Verification
**Files:**
- Modify: none
- Test: `tests/test_ui.py`, `tests/test_api.py`
- [ ] **Step 1: Run the UI test suite**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: PASS with all UI contract tests green.
- [ ] **Step 2: Run the API regression suite**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q`
Expected: PASS with all API tests green.
- [ ] **Step 3: Run fresh JS syntax verification through pytest**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
Expected: PASS
- [ ] **Step 4: Manual verification**
Run the app:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
```
Manual checks:
- open the import window at one browser zoom level and note the size
- close it, change browser zoom, reopen it, confirm it recenters and resizes
- shrink the viewport below the narrow breakpoint, reopen it, confirm the panes stack vertically
- maximize and minimize the window, confirm those modes still behave correctly
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_ui.py docs/superpowers/specs/2026-04-16-import-window-responsive-design.md docs/superpowers/plans/2026-04-16-import-window-responsive.md
git commit -m "feat: make import window adapt to viewport changes"
```

View File

@ -0,0 +1,406 @@
# Project Items And Inline Card Detail 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:** Show imported project items immediately after import, improve collection row readability, and render item detail inline under the active entry instead of in a fixed detail area.
**Architecture:** Normalize project view data around a single `items` list in `ProjectService`, with each item carrying `card_status` plus card fields when available. Update the center panel renderer in the inline template script to render both pending and done items, and expand one items detail inline at a time.
**Tech Stack:** Python, FastAPI template rendering, inline HTML/CSS/JavaScript, pytest
---
### Task 1: Define Pending-Item Project View Contract
**Files:**
- Modify: `tests/test_projects.py`
- Modify: `tests/test_api.py`
- Test: `tests/test_projects.py`
- Test: `tests/test_api.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_projects.py`:
```python
def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index_path.parent.mkdir(parents=True, exist_ok=True)
items_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"item_key": "PAPER0001",
"title": "Pending Item",
"creators": ["Alice Smith"],
"year": "2024",
"item_type": "journalArticle",
}
},
ensure_ascii=False,
),
encoding="utf-8",
)
service = ProjectService(config.workspace_dir)
payload = service.add_items("thesis-ch2", ["PAPER0001"])
assert payload["selected_items"] == ["PAPER0001"]
assert len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "pending"
```
Add to `tests/test_api.py`:
```python
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
assert payload["project_view"]["items"][0]["card_status"] == "pending"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q`
Expected: FAIL because project view currently only returns `cards`, not normalized `items`.
- [ ] **Step 3: Write minimal implementation**
Modify `src/zotero_kb/projects.py` so `_rebuild_project_index()` returns normalized items for both pending and done entries:
```python
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
cards_index = self._read_json(self.index_dir / "cards.json")
items_index = self._read_json(self.index_dir / "items.json")
collections_index = self._read_json(self.index_dir / "collections.json")
project_items: list[dict[str, object]] = []
project_cards: list[dict[str, object]] = []
for item_key in selected_items:
item_data = items_index.get(item_key, {})
card_data = cards_index.get(item_key)
item_payload = {
"item_key": item_key,
"title": item_data.get("title", "Untitled"),
"creators": item_data.get("creators", []),
"year": item_data.get("year"),
"item_type": item_data.get("item_type", "unknown"),
"card_status": "done" if card_data else "pending",
"summary": card_data.get("summary") if card_data else None,
"claims": card_data.get("claims", []) if card_data else [],
"quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
}
project_items.append(item_payload)
if card_data:
project_cards.append(card_data)
project_collections = [
payload
for payload in collections_index.values()
if set(payload.get("item_keys", [])) & set(selected_items)
]
payload = {
"project_id": project_id,
"selected_items": selected_items,
"items": project_items,
"cards": project_cards,
"collections": project_collections,
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add tests/test_projects.py tests/test_api.py src/zotero_kb/projects.py
git commit -m "feat: add pending project items to project view"
```
### Task 2: Define UI Contract For Inline Project Item Detail
**Files:**
- Modify: `tests/test_ui.py`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_ui.py`:
```python
def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="project-item-list"' in html
assert "expandedProjectItemKey" in html
assert "function renderProjectItems(items)" in html
assert "function toggleProjectItemDetail(itemKey)" in html
assert 'class="project-item-detail"' in html
```
```python
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".project-item strong" in html
assert ".project-item .meta" in html
assert ".collection-row-toggle" in html
```
- [ ] **Step 2: Run test to verify it fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: FAIL because the page still renders only cards and uses the fixed detail region.
- [ ] **Step 3: Write minimal implementation**
Update `src/zotero_kb/templates/index.html`:
- replace the center list container with:
```html
<div id="project-item-list" class="card-list"></div>
```
- replace the fixed detail surface with a passive note:
```html
<div class="section surface">
<h3>项目状态</h3>
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
</div>
```
- add state:
```javascript
expandedProjectItemKey: null,
```
- add renderers:
```javascript
function toggleProjectItemDetail(itemKey) {
state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey;
renderProjectItems(state.currentProjectItems);
}
```
```javascript
function renderProjectItems(items) {
state.currentProjectItems = items || [];
if (!state.currentProjectItems.length) {
elements.projectItemList.innerHTML = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
return;
}
elements.projectItemList.innerHTML = "";
for (const item of state.currentProjectItems) {
const card = document.createElement("div");
card.className = "card-item";
const statusLabel = item.card_status === "done" ? "已生成卡片" : "未生成卡片";
card.innerHTML = `
<strong>${escapeHtml(item.title)}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}</p>
<div class="row">
<button type="button" class="secondary">查看详情</button>
<button type="button" class="${item.card_status === "done" ? "secondary" : ""}">${statusLabel}</button>
<button type="button" class="danger">移出项目</button>
</div>
`;
const [detailButton, statusButton, removeButton] = card.querySelectorAll("button");
detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key));
statusButton.disabled = true;
removeButton.addEventListener("click", () => removeCard(item.item_key));
if (state.expandedProjectItemKey === item.item_key) {
const detail = document.createElement("div");
detail.className = "project-item-detail";
detail.innerHTML = item.card_status === "done"
? `
<div class="meta">${item.item_key}</div>
<p>${item.summary || "暂无摘要"}</p>
<h4>Claims</h4>
<ul>${(item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"}</ul>
<h4>Quotable</h4>
<ul>${(item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>"}</ul>
`
: `
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>这篇文献已经导入到当前项目,但还没有生成卡片内容。</p>
`;
card.appendChild(detail);
}
elements.projectItemList.appendChild(card);
}
}
```
- add CSS:
```css
.project-item strong,
.project-item .meta {
color: var(--ink);
}
.project-item.active strong,
.project-item.active .meta {
color: #f5f7ef;
}
.collection-row-toggle {
color: var(--accent-strong);
font-weight: 700;
}
.project-item-detail {
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add tests/test_ui.py src/zotero_kb/templates/index.html
git commit -m "feat: render project items with inline detail"
```
### Task 3: Wire Project Item Rendering Into Import And Project Selection Flows
**Files:**
- Modify: `src/zotero_kb/templates/index.html`
- Test: `tests/test_api.py`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write the failing test**
Reuse the UI/API failures from Tasks 1 and 2. No new production code first.
- [ ] **Step 2: Run tests to verify the current behavior fails**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q`
Expected: FAIL until the project list renderer uses `items`.
- [ ] **Step 3: Write minimal implementation**
Update the inline script in `src/zotero_kb/templates/index.html`:
- state:
```javascript
currentProjectItems: [],
```
- elements:
```javascript
projectItemList: document.getElementById("project-item-list"),
```
- remove `showCardDetail()` and stop using `elements.cardDetail`
- change empty-state and load paths:
```javascript
async function selectProject(projectId, rerender = true) {
state.currentProjectId = projectId;
updateImportActionState();
if (rerender) {
renderProjects();
}
const payload = await api(`/api/projects/${projectId}/cards`);
renderProjectItems(payload.items || []);
}
```
```javascript
renderProjectItems(payload.project_view?.items || []);
```
```javascript
renderProjectItems([]);
```
- [ ] **Step 4: Run full targeted tests**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py
git commit -m "feat: show imported project items before card generation"
```
### Task 4: Final Verification
**Files:**
- Modify: none
- Test: `tests/test_projects.py`
- Test: `tests/test_api.py`
- Test: `tests/test_ui.py`
- [ ] **Step 1: Run full test suite**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q`
Expected: PASS
- [ ] **Step 2: Run JS syntax guard**
Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q`
Expected: PASS
- [ ] **Step 3: Manual verification**
Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run python main.py
```
Manual checks:
- unselected collections remain readable in the import window
- importing an item into a project makes it appear immediately in the center panel
- pending item detail expands inline below the clicked item
- generated item detail expands inline below the clicked item
- [ ] **Step 4: Commit**
```bash
git add src/zotero_kb/projects.py src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md
git commit -m "feat: show project items inline before card generation"
```

View File

@ -0,0 +1,129 @@
# 2026-04-16 Import Window Responsive Design
## Goal
Make the floating import window adapt to the current browser viewport automatically so the user does not need to manually resize it after changing browser zoom or window size.
The floating window shell remains:
- draggable
- minimizable
- maximizable
- closable
The import content remains the restored collection importer:
- left pane: Zotero collection tree
- right pane: collection items
## User-Approved Behavior
### Window sizing policy
- Each time the import window opens, it recalculates its size and position from the current viewport.
- Previously saved manual width, height, and position are not reused on reopen.
- The window opens centered in the viewport.
- The default size is viewport-relative, using a large but bounded footprint.
Recommended sizing rule:
- width: about `88vw`
- height: about `82vh`
- clamp width and height to safe min/max values so content remains usable on smaller screens
### Resize and zoom behavior
- Browser zoom changes are treated the same as viewport changes.
- On `resize`, if the window is in normal mode, its dimensions and position are adjusted to remain visible within the viewport.
- If the window is maximized, existing maximize behavior remains authoritative.
- If the window is minimized, existing minimize behavior remains authoritative.
### Layout adaptation
- Wide viewport: keep the current two-pane horizontal layout.
- Narrow viewport: switch the import window body to a vertical stack.
- In stacked mode:
- top pane: collections
- bottom pane: collection items
This avoids crushed side-by-side panes and avoids requiring horizontal scrolling.
## Implementation Design
### State model
Keep the current floating-window state structure, but change how normal-mode geometry is derived:
- persisted maximize/minimize flags can remain
- persisted normal-mode width/height/left/top are no longer the source of truth on reopen
- on open, recompute width, height, left, and top from the current viewport
### New geometry helpers
Add small helpers in the template script:
- `computeResponsiveWindowRect()`
- derives width/height/left/top from `window.innerWidth` and `window.innerHeight`
- clamps to minimum and maximum bounds
- returns a centered rect
- `applyResponsiveWindowRect()`
- applies the computed rect to the floating window when in normal mode
- `syncWindowToViewport()`
- runs on resize
- keeps the window inside the visible viewport
- does nothing destructive when minimized or maximized
### Layout switching
Use CSS plus a narrow-width breakpoint for `.window-body`:
- default: `grid-template-columns: 22rem minmax(0, 1fr)`
- narrow mode: `grid-template-columns: 1fr`
The existing `.window-pane.collections` separator changes from right border to bottom border in stacked mode.
### Interaction rules
- Opening the window always recomputes the normal-mode rect.
- Manual dragging still works during the current open session.
- If the viewport changes while the window is open, normal mode is re-constrained to the viewport.
- Closing and reopening discards the sessions manual geometry and recomputes from the viewport again.
## Testing
Add or update UI tests to verify:
- the responsive helper logic is present in the inline script
- opening the window uses viewport-based sizing instead of reopening from stale manual geometry
- the template contains the narrow-layout CSS for stacked panes
- the inline script remains valid JavaScript
Manual verification target:
- open import window at normal zoom
- change browser zoom or viewport size
- close and reopen
- confirm the window opens centered and proportionate to the new viewport
- confirm narrow viewport stacks collections above items
## Risks and Mitigations
### Risk: resize fights user drag
Mitigation:
- only recompute automatically on open
- on live resize, constrain only enough to keep the window visible
### Risk: minimized/maximized modes get overwritten
Mitigation:
- gate responsive normal-mode logic behind checks for non-minimized and non-maximized state
### Risk: small screens become unusable
Mitigation:
- stack panes vertically below the chosen breakpoint
- clamp dimensions and leave a small viewport margin

View File

@ -0,0 +1,138 @@
# 2026-04-16 Project Items And Inline Card Detail Design
## Goal
Fix three related UI/data issues:
- collection 列表里未选中项文字太浅,保证未选中时也清晰可读
- 文献导入项目后,项目主区要立刻显示这些条目,而不是等生成卡片后才出现
- 卡片/条目详情改成在当前条目下方展开,不再集中显示在固定详情区域
## Approved Direction
Use a single project-scoped item list as the main source of truth in the center panel.
Each project item carries a `card_status`:
- `pending`: 已导入项目,但还没生成卡片
- `done`: 已生成卡片
The center panel renders all project items, not only completed cards.
## Data Model
### Backend project view
`ProjectService.get_project_view()` should return:
- `project_id`
- `selected_items`
- `items`
- `collections`
Each `items[]` element should contain:
- `item_key`
- `title`
- `creators`
- `year`
- `item_type`
- `card_status`
- card fields when available:
- `summary`
- `claims`
- `quotable_spans`
This makes imported-but-not-generated items visible immediately after import.
### Status semantics
- imported via `imports/item-keys` and not yet generated => `pending`
- present in `cards.json` => `done`
## UI Behavior
### Collection tree readability
Unselected collection cards keep the current light theme, but their text contrast must be increased:
- title remains readable on light background
- meta line remains readable without relying on hover or selection state
### Center panel item list
Replace the current “only cards” rendering with a project item list:
- `pending` items render as project entries with a clear “未生成卡片” state
- `done` items render as completed card entries
- remove the assumption that invisible means “not imported”
### Inline detail expansion
Clicking “查看详情” or the item itself expands detail directly below that same entry.
Rules:
- only one item detail is expanded at a time
- clicking the same item again collapses it
- clicking another item moves the expanded detail to that item
### Expanded detail content
For `pending`:
- title
- item key / year / type
- a short message that the item is already in the project but card generation has not happened yet
For `done`:
- existing summary
- claims
- quotable spans
## Frontend Structure
### State
Add a current expanded item key in the page script, e.g.:
- `expandedProjectItemKey`
### Rendering
Replace the current `renderCards()` + fixed `showCardDetail()` pattern with:
- a renderer for all project items
- inline expansion markup inserted under the active item
The fixed side detail area can be removed or converted into a passive empty state, because detail no longer lives there.
## Testing
Add or update tests to cover:
- project view includes imported pending items
- index template still contains the main page and valid inline script
- center panel contract no longer depends on a fixed detail-only area for current item detail
- inline project item detail hooks exist in the template script
## Risks
### Risk: center panel mixes two incompatible data shapes
Mitigation:
- make backend project view emit a single normalized `items` list
### Risk: pending items have no detail data
Mitigation:
- pending inline detail shows metadata plus explicit status messaging, not empty summary sections
### Risk: old fixed detail panel becomes misleading
Mitigation:
- remove dependence on it from the item interaction flow

View File

@ -23,6 +23,10 @@ class CreateProjectRequest(BaseModel):
llm_model: str = "gpt-5-mini"
class RenameProjectRequest(BaseModel):
name: str = Field(min_length=1)
class WritingPromptRequest(BaseModel):
prompt: str = Field(min_length=1)
@ -76,6 +80,26 @@ def create_app(
result.append(_read_json(project_file))
return result
@app.patch("/api/projects/{project_id}")
def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]:
try:
project = workspace.rename_project(project_id, payload.name)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Project not found") from exc
return {"id": project.project_id, "name": project.name}
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: str) -> dict[str, object]:
try:
workspace.delete_project(project_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Project not found") from exc
return {"deleted": project_id}
@app.post("/api/projects/{project_id}/delete")
def delete_project_via_post(project_id: str) -> dict[str, object]:
return delete_project(project_id)
@app.get("/api/zotero/collections/tree")
def zotero_collection_tree() -> dict[str, object]:
return {"collections": reader.get_collection_tree()}

View File

@ -30,8 +30,27 @@ class ProjectService:
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
cards_index = self._read_json(self.index_dir / "cards.json")
items_index = self._read_json(self.index_dir / "items.json")
collections_index = self._read_json(self.index_dir / "collections.json")
project_cards = [cards_index[item_key] for item_key in selected_items if item_key in cards_index]
project_items: list[dict[str, object]] = []
project_cards: list[dict[str, object]] = []
for item_key in selected_items:
item_data = items_index.get(item_key, {})
card_data = cards_index.get(item_key)
item_payload = {
"item_key": item_key,
"title": item_data.get("title", "Untitled"),
"creators": item_data.get("creators", []),
"year": item_data.get("year"),
"item_type": item_data.get("item_type", "unknown"),
"card_status": "done" if card_data else "pending",
"summary": card_data.get("summary") if card_data else None,
"claims": card_data.get("claims", []) if card_data else [],
"quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
}
project_items.append(item_payload)
if card_data:
project_cards.append(card_data)
project_collections = [
payload
for payload in collections_index.values()
@ -40,6 +59,7 @@ class ProjectService:
payload = {
"project_id": project_id,
"selected_items": selected_items,
"items": project_items,
"cards": project_cards,
"collections": project_collections,
}
@ -64,4 +84,3 @@ class ProjectService:
@staticmethod
def _write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

View File

@ -114,10 +114,145 @@
padding: 0.9rem;
background: rgba(255, 255, 255, 0.65);
}
.project-item strong,
.project-item .meta {
color: var(--ink);
}
.project-item.active {
border-color: var(--accent);
background: var(--accent-strong);
color: #f7f2e8;
box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15);
}
.project-item.active strong,
.project-item.active .meta {
color: inherit;
}
.project-list-entry {
display: grid;
gap: 0.75rem;
}
.project-list-main {
width: 100%;
min-height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
text-align: left;
box-shadow: none;
}
.project-list-main:hover {
transform: none;
background: transparent;
}
.project-list-main strong,
.project-list-main .meta {
color: inherit;
}
.project-list-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.project-list-actions button {
width: auto;
min-height: 2rem;
padding: 0.45rem 0.85rem;
}
.project-inline-form,
.project-delete-confirmation {
display: grid;
gap: 0.7rem;
}
.project-inline-form input,
.project-delete-confirmation p {
margin: 0;
}
.project-delete-confirmation p {
color: inherit;
}
.project-list-entry.active .project-list-actions .secondary {
background: rgba(247, 242, 232, 0.14);
color: #f7f2e8;
}
.project-list-entry.active .project-list-actions .danger {
background: rgba(139, 58, 42, 0.22);
color: #fff1ea;
}
.project-item-detail {
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
}
.project-item-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.project-item-card-main {
min-width: 0;
}
.project-item-card-main strong {
display: block;
}
.project-item-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
}
.project-item-status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0.2rem 0.8rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
white-space: nowrap;
border: 1px solid transparent;
}
.project-item-status-pill.pending {
background: rgba(185, 132, 53, 0.14);
color: #855d1d;
border-color: rgba(185, 132, 53, 0.25);
}
.project-item-status-pill.generating {
background: rgba(44, 110, 73, 0.14);
color: var(--accent-strong);
border-color: rgba(44, 110, 73, 0.22);
}
.project-item-status-pill.done {
background: rgba(44, 110, 73, 0.18);
color: var(--accent-strong);
border-color: rgba(44, 110, 73, 0.25);
}
.project-item-status-pill.failed {
background: rgba(139, 58, 42, 0.12);
color: var(--danger);
border-color: rgba(139, 58, 42, 0.2);
}
.project-item-generate-button {
width: auto;
min-height: 2rem;
padding: 0.45rem 0.9rem;
font-size: 0.82rem;
white-space: nowrap;
}
.project-item-status {
display: inline-flex;
align-items: center;
justify-content: center;
}
.collection-row-toggle {
color: var(--accent-strong);
font-weight: 700;
}
.row {
display: flex;
gap: 0.6rem;
@ -303,6 +438,44 @@
.window-pane.items {
background: rgba(255, 253, 248, 0.96);
}
.batch-generate-frame {
width: min(52rem, calc(100vw - 2rem));
height: min(42rem, calc(100vh - 2rem));
left: max(1rem, calc((100vw - min(52rem, calc(100vw - 2rem))) / 2));
top: max(1rem, calc((100vh - min(42rem, calc(100vh - 2rem))) / 2));
min-width: 0;
min-height: 0;
}
.batch-generate-body {
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
}
.batch-generate-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem 0.5rem;
}
.batch-generate-list {
padding: 0.5rem 1.25rem 1.25rem;
}
.batch-generate-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: start;
}
.batch-generate-item input[type="checkbox"] {
margin-top: 0.35rem;
}
.batch-generate-item-main strong {
display: block;
}
.batch-generate-item[aria-disabled="true"] {
opacity: 0.72;
}
.window-scroll {
min-height: 0;
overflow: auto;
@ -457,20 +630,50 @@
<section class="panel">
<div class="row" style="justify-content: space-between">
<div>
<h2>Cards</h2>
<h2>项目文献</h2>
<p id="current-project-label">当前未选择项目。</p>
</div>
<button id="reload-cards-button" type="button" class="secondary">刷新卡片</button>
<div class="row" style="justify-content:flex-end">
<button id="open-batch-generate-button" type="button" class="secondary">批量生成文献</button>
<button id="reload-cards-button" type="button" class="secondary">刷新文献</button>
</div>
</div>
<div id="card-list" class="card-list"></div>
<div id="project-item-list" class="card-list"></div>
<div class="section surface">
<h3>卡片详情</h3>
<div id="card-detail" class="empty">点击卡片后这里会显示摘要、claims 和可引用内容</div>
<h3>项目状态</h3>
<div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情</div>
</div>
</section>
<div id="batch-generate-shell" class="window-shell" hidden aria-hidden="true">
<section id="batch-generate-window" class="window-frame batch-generate-frame" role="dialog" aria-modal="true" aria-labelledby="batch-generate-title">
<div class="window-toolbar">
<div>
<h3 id="batch-generate-title">批量生成文献卡片</h3>
<p class="meta">当前项目内已导入文献</p>
</div>
<div class="window-toolbar-actions">
<div class="window-action-cluster">
<button id="select-pending-batch-button" type="button" class="secondary window-toolbar-button">一键选择未生成</button>
<button id="clear-batch-selection-button" type="button" class="danger window-toolbar-button">清空选择</button>
<button id="start-batch-generate-button" type="button" class="window-toolbar-button">开始生成</button>
</div>
<div class="window-control-cluster" aria-label="批量生成窗口控制">
<button id="close-batch-generate-button" type="button" class="window-control-button close" title="关闭">×</button>
</div>
</div>
</div>
<div class="batch-generate-body">
<div class="batch-generate-summary">
<div id="batch-generate-progress" class="status">未开始批量生成。</div>
</div>
<div id="batch-generate-list" class="batch-generate-list window-scroll collection-items-list"></div>
</div>
</section>
</div>
<section class="panel">
<h2>Writing</h2>
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
@ -535,9 +738,16 @@
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
currentProjectItems: [],
projectItemGenerationState: {},
editingProjectId: null,
editingProjectName: "",
confirmingDeleteProjectId: null,
savingProjectId: null,
deletingProjectId: null,
collectionTree: [],
isImportWindowOpen: false,
isBatchGenerateWindowOpen: false,
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
@ -567,6 +777,9 @@
loadingCollectionKey: null,
nextCollectionItemsRequestToken: 0,
activeCollectionItemsRequestToken: 0,
expandedProjectItemKey: null,
batchGenerateSelectedKeys: new Set(),
batchGenerateProgress: { current: 0, total: 0, active: false },
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
@ -594,9 +807,17 @@
windowCollectionItems: document.getElementById("window-collection-items"),
previewPopover: document.getElementById("item-preview-popover"),
currentProjectLabel: document.getElementById("current-project-label"),
cardList: document.getElementById("card-list"),
cardDetail: document.getElementById("card-detail"),
projectItemList: document.getElementById("project-item-list"),
openBatchGenerateButton: document.getElementById("open-batch-generate-button"),
reloadCardsButton: document.getElementById("reload-cards-button"),
batchGenerateShell: document.getElementById("batch-generate-shell"),
batchGenerateWindow: document.getElementById("batch-generate-window"),
closeBatchGenerateButton: document.getElementById("close-batch-generate-button"),
selectPendingBatchButton: document.getElementById("select-pending-batch-button"),
clearBatchSelectionButton: document.getElementById("clear-batch-selection-button"),
startBatchGenerateButton: document.getElementById("start-batch-generate-button"),
batchGenerateProgress: document.getElementById("batch-generate-progress"),
batchGenerateList: document.getElementById("batch-generate-list"),
refreshProjectsButton: document.getElementById("refresh-projects-button"),
recommendForm: document.getElementById("recommend-form"),
recommendStatus: document.getElementById("recommend-status"),
@ -851,59 +1072,405 @@
elements.projectList.innerHTML = "";
for (const project of state.projects) {
const item = document.createElement("button");
item.type = "button";
item.className = "project-item" + (project.id === state.currentProjectId ? " active" : "");
item.innerHTML = `
<strong>${project.name}</strong>
<div class="meta">${project.id}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
`;
item.addEventListener("click", () => selectProject(project.id));
const item = document.createElement("div");
item.className = "project-item project-list-entry" + (project.id === state.currentProjectId ? " active" : "");
const isEditing = state.editingProjectId === project.id;
const isConfirmingDelete = state.confirmingDeleteProjectId === project.id;
const isSaving = state.savingProjectId === project.id;
const isDeleting = state.deletingProjectId === project.id;
if (isEditing) {
item.innerHTML = `
<form class="project-inline-form">
<input type="text" value="${escapeHtml(state.editingProjectName)}" aria-label="项目名称" />
<div class="meta">${project.id}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
<div class="project-list-actions">
<button type="submit" ${isSaving ? "disabled" : ""}>保存</button>
<button type="button" class="secondary">取消</button>
</div>
</form>
`;
const form = item.querySelector(".project-inline-form");
const input = form.querySelector("input");
const cancelButton = form.querySelector('button[type="button"]');
input.addEventListener("input", (event) => {
state.editingProjectName = event.target.value;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
saveProjectRename(project.id).catch(() => null);
});
cancelButton.addEventListener("click", cancelProjectRename);
} else if (isConfirmingDelete) {
item.innerHTML = `
<div class="project-delete-confirmation">
<strong>${escapeHtml(project.name)}</strong>
<p>删除后只会移除这个项目及其项目内索引,不会删除全局文献库。</p>
<div class="project-list-actions">
<button type="button" class="danger" ${isDeleting ? "disabled" : ""}>确认删除</button>
<button type="button" class="secondary" ${isDeleting ? "disabled" : ""}>取消</button>
</div>
</div>
`;
const [confirmButton, cancelButton] = item.querySelectorAll(".project-list-actions button");
confirmButton.addEventListener("click", () => {
deleteProject(project.id).catch(() => null);
});
cancelButton.addEventListener("click", cancelProjectDelete);
} else {
item.innerHTML = `
<button type="button" class="project-list-main">
<strong>${escapeHtml(project.name)}</strong>
<div class="meta">${project.id}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
</button>
<div class="project-list-actions">
<button type="button" class="secondary">改名</button>
<button type="button" class="danger">删除</button>
</div>
`;
const mainButton = item.querySelector(".project-list-main");
const [renameButton, deleteButton] = item.querySelectorAll(".project-list-actions button");
mainButton.addEventListener("click", () => selectProject(project.id));
renameButton.addEventListener("click", () => startProjectRename(project.id));
deleteButton.addEventListener("click", () => confirmProjectDelete(project.id));
}
elements.projectList.appendChild(item);
}
}
function renderCards(cards) {
state.currentCards = cards || [];
if (!state.currentCards.length) {
elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>';
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
function startProjectRename(projectId) {
const project = state.projects.find((entry) => entry.id === projectId);
if (!project) {
return;
}
state.editingProjectId = projectId;
state.editingProjectName = project.name || "";
state.confirmingDeleteProjectId = null;
renderProjects();
}
function cancelProjectRename() {
state.editingProjectId = null;
state.editingProjectName = "";
state.savingProjectId = null;
renderProjects();
}
async function saveProjectRename(projectId) {
const nextName = state.editingProjectName.trim();
if (!nextName) {
setStatus(elements.projectStatus, "项目名称不能为空。", true);
return;
}
state.savingProjectId = projectId;
renderProjects();
try {
await api(`/api/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify({ name: nextName }),
});
state.editingProjectId = null;
state.editingProjectName = "";
state.savingProjectId = null;
setStatus(elements.projectStatus, "项目名称已更新。");
await loadProjects();
} catch (error) {
state.savingProjectId = null;
renderProjects();
setStatus(elements.projectStatus, error.message, true);
}
}
function confirmProjectDelete(projectId) {
state.confirmingDeleteProjectId = projectId;
state.editingProjectId = null;
state.editingProjectName = "";
renderProjects();
}
function cancelProjectDelete() {
state.confirmingDeleteProjectId = null;
state.deletingProjectId = null;
renderProjects();
}
async function deleteProject(projectId) {
state.deletingProjectId = projectId;
renderProjects();
try {
try {
await api(`/api/projects/${projectId}`, { method: "DELETE" });
} catch (error) {
if (error.message === "Method Not Allowed") {
await api(`/api/projects/${projectId}/delete`, { method: "POST" });
} else {
throw error;
}
}
state.confirmingDeleteProjectId = null;
state.deletingProjectId = null;
if (state.currentProjectId === projectId) {
state.currentProjectId = null;
state.expandedProjectItemKey = null;
}
setStatus(elements.projectStatus, "项目已删除。");
await loadProjects();
} catch (error) {
state.deletingProjectId = null;
renderProjects();
setStatus(elements.projectStatus, error.message, true);
}
}
function toggleProjectItemDetail(itemKey) {
state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey;
renderProjectItems(state.currentProjectItems);
}
function getDisplayedProjectItemStatus(item) {
return state.projectItemGenerationState[item.item_key] || item.card_status;
}
function getProjectItemStatusLabel(status) {
if (status === "done") return "已生成";
if (status === "generating") return "生成中";
if (status === "failed") return "生成失败";
return "未生成";
}
function canGenerateProjectItem(status) {
return status === "pending" || status === "failed";
}
function updateBatchGenerateControls() {
const hasProject = Boolean(state.currentProjectId);
const hasItems = state.currentProjectItems.length > 0;
const hasSelection = state.batchGenerateSelectedKeys.size > 0;
elements.openBatchGenerateButton.disabled = !hasProject || !hasItems;
elements.selectPendingBatchButton.disabled = !hasItems || state.batchGenerateProgress.active;
elements.clearBatchSelectionButton.disabled = !state.batchGenerateSelectedKeys.size || state.batchGenerateProgress.active;
elements.startBatchGenerateButton.disabled = !hasSelection || state.batchGenerateProgress.active;
}
function updateBatchGenerateProgress() {
const progress = state.batchGenerateProgress;
if (!progress.total) {
elements.batchGenerateProgress.textContent = "未开始批量生成。";
updateBatchGenerateControls();
return;
}
elements.batchGenerateProgress.textContent = progress.active
? `正在批量生成 ${progress.current} / ${progress.total}`
: `批量生成完成 ${progress.current} / ${progress.total}`;
updateBatchGenerateControls();
}
async function refreshCurrentProjectItems() {
if (!state.currentProjectId) {
state.currentProjectItems = [];
renderProjectItems([]);
return;
}
const payload = await api(`/api/projects/${state.currentProjectId}/cards`);
const freshItems = payload.items || [];
state.batchGenerateSelectedKeys = new Set(
Array.from(state.batchGenerateSelectedKeys).filter((itemKey) => freshItems.some((item) => item.item_key === itemKey))
);
renderProjectItems(freshItems);
}
function renderBatchGenerateList() {
if (!state.currentProjectItems.length) {
elements.batchGenerateList.innerHTML = '<div class="empty">当前项目还没有已导入文献。</div>';
updateBatchGenerateControls();
return;
}
elements.cardList.innerHTML = "";
for (const card of state.currentCards) {
const item = document.createElement("div");
item.className = "card-item";
item.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
elements.batchGenerateList.innerHTML = "";
for (const item of state.currentProjectItems) {
const status = getDisplayedProjectItemStatus(item);
const selectable = status !== "done" && status !== "generating";
const row = document.createElement("label");
row.className = "project-item batch-generate-item";
row.setAttribute("aria-disabled", selectable ? "false" : "true");
row.innerHTML = `
<input type="checkbox" ${state.batchGenerateSelectedKeys.has(item.item_key) ? "checked" : ""} ${selectable ? "" : "disabled"} />
<div class="batch-generate-item-main">
<strong>${escapeHtml(item.title)}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
</div>
<span class="project-item-status-pill ${status}">${getProjectItemStatusLabel(status)}</span>
`;
const checkbox = row.querySelector("input");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
state.batchGenerateSelectedKeys.add(item.item_key);
} else {
state.batchGenerateSelectedKeys.delete(item.item_key);
}
updateBatchGenerateControls();
});
elements.batchGenerateList.appendChild(row);
}
updateBatchGenerateControls();
}
function openBatchGenerateWindow() {
if (!state.currentProjectId) {
return;
}
state.isBatchGenerateWindowOpen = true;
elements.batchGenerateShell.hidden = false;
elements.batchGenerateShell.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
renderBatchGenerateList();
updateBatchGenerateProgress();
}
function closeBatchGenerateWindow() {
state.isBatchGenerateWindowOpen = false;
elements.batchGenerateShell.hidden = true;
elements.batchGenerateShell.setAttribute("aria-hidden", "true");
if (!state.isImportWindowOpen) {
document.body.classList.toggle("modal-open", false);
}
}
function selectAllPendingBatchItems() {
state.batchGenerateSelectedKeys = new Set(
state.currentProjectItems
.filter((item) => canGenerateProjectItem(getDisplayedProjectItemStatus(item)))
.map((item) => item.item_key)
);
renderBatchGenerateList();
}
function clearBatchGenerateSelection() {
state.batchGenerateSelectedKeys.clear();
renderBatchGenerateList();
}
async function generateProjectItem(itemKey) {
if (!state.currentProjectId) {
return;
}
state.projectItemGenerationState[itemKey] = "generating";
renderProjectItems(state.currentProjectItems);
renderBatchGenerateList();
try {
const payload = await api(`/api/projects/${state.currentProjectId}/cards/generate`, {
method: "POST",
body: JSON.stringify({ item_keys: [itemKey] }),
});
if ((payload.failed || []).includes(itemKey)) {
state.projectItemGenerationState[itemKey] = "failed";
} else {
delete state.projectItemGenerationState[itemKey];
await refreshCurrentProjectItems();
}
} catch (_error) {
state.projectItemGenerationState[itemKey] = "failed";
}
renderProjectItems(state.currentProjectItems);
renderBatchGenerateList();
}
async function runBatchGenerate() {
const itemKeys = Array.from(state.batchGenerateSelectedKeys);
if (!itemKeys.length) {
updateBatchGenerateControls();
return;
}
state.batchGenerateProgress = { current: 0, total: itemKeys.length, active: true };
updateBatchGenerateProgress();
for (const itemKey of itemKeys) {
await generateProjectItem(itemKey);
state.batchGenerateProgress.current += 1;
state.batchGenerateSelectedKeys.delete(itemKey);
updateBatchGenerateProgress();
}
state.batchGenerateProgress.active = false;
renderBatchGenerateList();
updateBatchGenerateProgress();
}
function renderProjectItems(items) {
state.currentProjectItems = items || [];
if (!state.currentProjectItems.length) {
elements.projectItemList.innerHTML = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
updateBatchGenerateControls();
if (state.isBatchGenerateWindowOpen) {
renderBatchGenerateList();
}
return;
}
elements.projectItemList.innerHTML = "";
for (const item of state.currentProjectItems) {
const card = document.createElement("div");
card.className = "card-item";
const status = getDisplayedProjectItemStatus(item);
const statusLabel = getProjectItemStatusLabel(status);
card.innerHTML = `
<div class="project-item-card-header">
<div class="project-item-card-main">
<strong>${escapeHtml(item.title)}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
</div>
<div class="project-item-actions">
<span class="project-item-status-pill ${status}">${statusLabel}</span>
${canGenerateProjectItem(status) ? '<button type="button" class="secondary project-item-generate-button">生成</button>' : ''}
</div>
</div>
<p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p>
<div class="row">
<button type="button" class="secondary">查看详情</button>
<button type="button" class="danger">移出项目</button>
</div>
`;
const [detailButton, removeButton] = item.querySelectorAll("button");
detailButton.addEventListener("click", () => showCardDetail(card));
removeButton.addEventListener("click", () => removeCard(card.item_key));
elements.cardList.appendChild(item);
}
}
const detailButton = card.querySelector(".row .secondary");
const generateButton = card.querySelector(".project-item-generate-button");
const removeButton = card.querySelector(".danger");
detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key));
if (generateButton) {
generateButton.addEventListener("click", () => {
generateProjectItem(item.item_key).catch(() => null);
});
}
removeButton.addEventListener("click", () => removeCard(item.item_key));
function showCardDetail(card) {
const claims = (card.claims || []).map((claim) => `<li>${claim}</li>`).join("") || "<li>暂无 claims</li>";
const quotes = (card.quotable_spans || []).map((quote) => `<li>${quote}</li>`).join("") || "<li>暂无可引用片段</li>";
elements.cardDetail.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
<h4>Claims</h4>
<ul>${claims}</ul>
<h4>Quotable</h4>
<ul>${quotes}</ul>
`;
if (state.expandedProjectItemKey === item.item_key) {
const detail = document.createElement("div");
detail.className = "project-item-detail";
if (status === "done") {
const claims = (item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>";
const quotes = (item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>";
detail.innerHTML = `
<div class="meta">${item.item_key}</div>
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
<h4>Claims</h4>
<ul>${claims}</ul>
<h4>Quotable</h4>
<ul>${quotes}</ul>
`;
} else {
detail.innerHTML = `
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${status === "generating" ? "这篇文献正在生成卡片内容,请稍候刷新状态。" : (status === "failed" ? "这篇文献生成卡片失败,你可以再次点击生成重试。" : "这篇文献已经导入到当前项目,但还没有生成卡片内容。")}</p>
`;
}
card.appendChild(detail);
}
elements.projectItemList.appendChild(card);
}
updateBatchGenerateControls();
if (state.isBatchGenerateWindowOpen) {
renderBatchGenerateList();
}
}
function renderRecommendationResults(results) {
@ -1034,6 +1601,7 @@
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "secondary";
toggle.classList.add("collection-row-toggle");
toggle.style.flex = "0 0 auto";
toggle.style.width = "auto";
toggle.style.padding = "0.55rem 0.75rem";
@ -1226,7 +1794,7 @@
state.selectedItemKeys.clear();
persistImportSessionState();
renderCollectionItems();
renderCards(payload.project_view?.cards || []);
renderProjectItems(payload.project_view?.items || []);
setStatus(elements.windowCollectionStatus, "导入完成。");
updateImportActionState();
closeImportWindow();
@ -1276,6 +1844,15 @@
async function loadProjects() {
const projects = await api("/api/projects");
state.projects = projects;
if (state.editingProjectId && !projects.some((project) => project.id === state.editingProjectId)) {
state.editingProjectId = null;
state.editingProjectName = "";
state.savingProjectId = null;
}
if (state.confirmingDeleteProjectId && !projects.some((project) => project.id === state.confirmingDeleteProjectId)) {
state.confirmingDeleteProjectId = null;
state.deletingProjectId = null;
}
if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) {
state.currentProjectId = projects.length ? projects[0].id : null;
}
@ -1284,11 +1861,12 @@
}
renderProjects();
updateImportActionState();
updateBatchGenerateControls();
if (state.currentProjectId) {
await selectProject(state.currentProjectId, false);
return;
}
renderCards([]);
renderProjectItems([]);
}
async function selectProject(projectId, rerender = true) {
@ -1298,7 +1876,7 @@
renderProjects();
}
const payload = await api(`/api/projects/${projectId}/cards`);
renderCards(payload.cards || []);
renderProjectItems(payload.items || []);
}
async function removeCard(itemKey) {
@ -1333,6 +1911,13 @@
}
await selectProject(state.currentProjectId);
});
elements.openBatchGenerateButton.addEventListener("click", openBatchGenerateWindow);
elements.closeBatchGenerateButton.addEventListener("click", closeBatchGenerateWindow);
elements.selectPendingBatchButton.addEventListener("click", selectAllPendingBatchItems);
elements.clearBatchSelectionButton.addEventListener("click", clearBatchGenerateSelection);
elements.startBatchGenerateButton.addEventListener("click", () => {
runBatchGenerate().catch(() => null);
});
elements.refreshProjectsButton.addEventListener("click", loadProjects);
@ -1365,6 +1950,8 @@
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow();
} else if (event.key === "Escape" && state.isBatchGenerateWindowOpen) {
closeBatchGenerateWindow();
}
});

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@ -59,6 +60,23 @@ class Workspace:
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
def rename_project(self, project_id: str, name: str) -> ProjectRecord:
project_dir = self.config.workspace_dir / "projects" / project_id
project_file = project_dir / "project.json"
if not project_file.exists():
raise FileNotFoundError(project_id)
project_payload = json.loads(project_file.read_text(encoding="utf-8"))
project_payload["name"] = name
self._write_json(project_file, project_payload)
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
def delete_project(self, project_id: str) -> None:
project_dir = self.config.workspace_dir / "projects" / project_id
if not project_dir.exists():
raise FileNotFoundError(project_id)
shutil.rmtree(project_dir)
@staticmethod
def _write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

View File

@ -1,8 +1,10 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app
from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app
from zotero_kb.api import GenerateCardsRequest
from zotero_kb.config import AppConfig
@ -57,6 +59,71 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
assert response["id"] == "thesis-ch2"
def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
rename_project = _route(app, "/api/projects/{project_id}", "PATCH")
list_projects = _route(app, "/api/projects", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Old Name",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name"))
assert response["id"] == "thesis-ch2"
assert response["name"] == "New Name"
assert list_projects()[0]["name"] == "New Name"
def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
delete_project = _route(app, "/api/projects/{project_id}", "DELETE")
list_projects = _route(app, "/api/projects", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
response = delete_project("thesis-ch2")
assert response["deleted"] == "thesis-ch2"
assert list_projects() == []
def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
client = TestClient(app)
create_response = client.post(
"/api/projects",
json={
"project_id": "thesis-ch2",
"name": "Thesis Chapter 2",
"llm_provider": "openai",
"llm_model": "gpt-5-mini",
},
)
assert create_response.status_code == 201
delete_response = client.post("/api/projects/thesis-ch2/delete")
assert delete_response.status_code == 200
assert delete_response.json() == {"deleted": "thesis-ch2"}
assert client.get("/api/projects").json() == []
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
@ -300,3 +367,26 @@ def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None:
assert len(response["items"]) == 1
assert response["items"][0]["item_key"] == "PAPER0001"
assert response["items"][0]["card_status"] == "done"
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
assert payload["project_view"]["items"][0]["card_status"] == "pending"

View File

@ -74,3 +74,35 @@ def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None
payload = service.remove_item("thesis-ch2", "PAPER0001")
assert payload["selected_items"] == []
def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
items_index_path.parent.mkdir(parents=True, exist_ok=True)
items_index_path.write_text(
json.dumps(
{
"PAPER0001": {
"item_key": "PAPER0001",
"title": "Pending Item",
"creators": ["Alice Smith"],
"year": "2024",
"item_type": "journalArticle",
}
},
ensure_ascii=False,
),
encoding="utf-8",
)
service = ProjectService(config.workspace_dir)
payload = service.add_items("thesis-ch2", ["PAPER0001"])
assert payload["selected_items"] == ["PAPER0001"]
assert len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "pending"

View File

@ -122,6 +122,76 @@ def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None:
assert "border-bottom: 1px solid var(--line);" in html
def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="project-item-list"' in html
assert "expandedProjectItemKey" in html
assert "function renderProjectItems(items)" in html
assert "function toggleProjectItemDetail(itemKey)" in html
assert ".project-item-detail" in html
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".project-item strong" in html
assert ".project-item .meta" in html
assert ".collection-row-toggle" in html
def test_index_project_item_active_state_uses_high_contrast_text(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".project-item.active {" in html
assert "background: var(--accent-strong);" in html
assert "color: #f7f2e8;" in html
assert ".project-item.active strong," in html
assert ".project-item.active .meta {" in html
assert "color: inherit;" in html
def test_index_has_project_rename_delete_controls(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert "editingProjectId" in html
assert "confirmingDeleteProjectId" in html
assert "function startProjectRename(projectId)" in html
assert "function cancelProjectRename()" in html
assert "function saveProjectRename(projectId)" in html
assert "function confirmProjectDelete(projectId)" in html
assert "function deleteProject(projectId)" in html
assert 'method: "DELETE"' in html
assert 'method: "POST"' in html
assert 'api(`/api/projects/${projectId}/delete`' in html
assert 'error.message === "Method Not Allowed"' in html
assert ".project-list-entry" in html
assert ".project-list-main" in html
assert ".project-list-actions" in html
assert ".project-inline-form" in html
assert ".project-delete-confirmation" in html
def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert 'id="open-batch-generate-button"' in html
assert 'id="batch-generate-shell"' in html
assert 'id="batch-generate-window"' in html
assert 'id="batch-generate-list"' in html
assert 'id="select-pending-batch-button"' in html
assert 'id="clear-batch-selection-button"' in html
assert 'id="start-batch-generate-button"' in html
assert 'id="batch-generate-progress"' in html
assert "function openBatchGenerateWindow()" in html
assert "function closeBatchGenerateWindow()" in html
assert "function generateProjectItem(itemKey)" in html
assert "function selectAllPendingBatchItems()" in html
assert "function runBatchGenerate()" in html
assert ".project-item-status-pill" in html
assert ".project-item-generate-button" in html
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
html = _get_index_html(tmp_path)
script_start = html.rfind("<script>")

View File

@ -32,3 +32,42 @@ def test_create_project_writes_project_files(tmp_path: Path) -> None:
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
def test_rename_project_updates_project_name(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
workspace.create_project(
project_id="thesis-ch2",
name="Old Name",
llm_provider="openai",
llm_model="gpt-5-mini",
)
project = workspace.rename_project("thesis-ch2", "New Name")
assert project.project_id == "thesis-ch2"
assert project.name == "New Name"
assert '"name": "New Name"' in (
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
).read_text(encoding="utf-8")
def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config)
workspace.ensure_layout()
workspace.create_project(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
preserved_index = config.workspace_dir / "library" / "index" / "items.json"
preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8")
workspace.delete_project("thesis-ch2")
assert not (config.workspace_dir / "projects" / "thesis-ch2").exists()
assert preserved_index.is_file()